diff --git a/.vscode/settings.json b/.vscode/settings.json index 53ce88cdd..3598a68d9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "biome.enabled": true, "editor.defaultFormatter": "biomejs.biome", "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/.yarn/cache/@crowdin-crowdin-api-client-npm-1.48.3-6aece9d47d-eb61dd52f6.zip b/.yarn/cache/@crowdin-crowdin-api-client-npm-1.48.3-6aece9d47d-eb61dd52f6.zip new file mode 100644 index 000000000..4e2e7768e Binary files /dev/null and b/.yarn/cache/@crowdin-crowdin-api-client-npm-1.48.3-6aece9d47d-eb61dd52f6.zip differ diff --git a/.yarn/cache/framer-plugin-npm-3.10.3-f82e9d58a5-c677a26146.zip b/.yarn/cache/framer-plugin-npm-3.10.3-f82e9d58a5-c677a26146.zip new file mode 100644 index 000000000..0d9ed6da3 Binary files /dev/null and b/.yarn/cache/framer-plugin-npm-3.10.3-f82e9d58a5-c677a26146.zip differ diff --git a/assets/crowdin.png b/assets/crowdin.png new file mode 100644 index 000000000..957e73ac7 Binary files /dev/null and b/assets/crowdin.png differ diff --git a/plugins/crowdin/README.md b/plugins/crowdin/README.md new file mode 100644 index 000000000..98e2a7477 --- /dev/null +++ b/plugins/crowdin/README.md @@ -0,0 +1,16 @@ +# Crowdin Localization Plugin for Framer + +A Framer plugin that synchronizes localization strings between **Framer** and **[Crowdin](https://crowdin.com/)**. +--- + +## ✨ Features +- **Export** source strings from Framer → Crowdin +- **Import** translations from Crowdin → Framer +- Simple UI with **two buttons**: + - `Export to Crowdin` + - `Import from Crowdin` + + +**By:** @sushilzore, @clementroche, and @madebyisaacr + +![Crowdin Image](../../assets/hero.png) \ No newline at end of file diff --git a/plugins/crowdin/framer.json b/plugins/crowdin/framer.json new file mode 100644 index 000000000..bedbc4003 --- /dev/null +++ b/plugins/crowdin/framer.json @@ -0,0 +1,6 @@ +{ + "id": "cr0d1n", + "name": "Crowdin", + "modes": ["localization"], + "icon": "/icon.svg" +} diff --git a/plugins/crowdin/index.html b/plugins/crowdin/index.html new file mode 100644 index 000000000..d847cc6c8 --- /dev/null +++ b/plugins/crowdin/index.html @@ -0,0 +1,13 @@ + + + + + + + Crowdin + + +
+ + + diff --git a/plugins/crowdin/package.json b/plugins/crowdin/package.json new file mode 100644 index 000000000..fd37e5099 --- /dev/null +++ b/plugins/crowdin/package.json @@ -0,0 +1,27 @@ +{ + "name": "crowdin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "run g:dev", + "build": "run g:build", + "check-biome": "run g:check-biome", + "check-eslint": "run g:check-eslint", + "preview": "run g:preview", + "pack": "npx framer-plugin-tools@latest pack", + "check-typescript": "run g:check-typescript" + }, + "dependencies": { + "@crowdin/crowdin-api-client": "^1.46.0", + "classnames": "^2.5.1", + "framer-plugin": "^3.10.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "valibot": "^1.2.0" + }, + "devDependencies": { + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7" + } +} diff --git a/plugins/crowdin/public/icon.svg b/plugins/crowdin/public/icon.svg new file mode 100644 index 000000000..15c1f9a64 --- /dev/null +++ b/plugins/crowdin/public/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/plugins/crowdin/src/App.css b/plugins/crowdin/src/App.css new file mode 100644 index 000000000..253b52f4d --- /dev/null +++ b/plugins/crowdin/src/App.css @@ -0,0 +1,244 @@ +/* Your Plugin CSS */ + +:root { + --crowdin-brand-color: #263238; + --color-error: #ff3366; +} + +[data-framer-theme="light"] { + --image-border-color: rgba(0, 0, 0, 0.05); +} + +[data-framer-theme="dark"] { + --image-border-color: rgba(255, 255, 255, 0.05); +} + +main { + display: flex; + flex-direction: column; + align-items: start; + padding: 0 15px 15px; + gap: 15px; + + user-select: none; + -webkit-user-select: none; +} + +main.home { + height: 270px; +} + +select { + padding: 0 16px 0 10px; +} + +select:not(:disabled) { + cursor: pointer; +} + +h1 { + font-size: 12px; + font-weight: 600; +} + +strong { + font-weight: 500; + color: var(--framer-color-text); +} + +.hero { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 10px; + width: 100%; + flex: 1; +} + +.hero p { + text-wrap: balance; + color: var(--framer-color-text-tertiary); + max-width: 200px; +} + +.hero .logo { + width: 30px; + height: 30px; + border-radius: 8px; + position: relative; + overflow: clip; + margin-bottom: 5px; +} + +.hero .logo img { + width: 100%; + height: 100%; +} + +.hero .logo:after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 1px solid var(--image-border-color); + border-radius: 8px; +} + +.button-row { + display: flex; + flex-direction: row; + gap: 10px; + width: 100%; +} + +.button-row button { + flex: 1; +} + +.controls-stack { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.property-control { + width: 100%; + display: flex; + flex-direction: row; + align-items: start; + gap: 10px; + padding-left: 10px; +} + +.property-control.disabled > p, +.controls-stack.disabled { + opacity: 0.5; + pointer-events: none; +} + +.property-control > p { + flex: 1; + height: 30px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.property-control .content { + display: flex; + flex-direction: column; + gap: 10px; + width: 150px; +} + +.property-control .content > * { + width: 100%; +} + +.access-token-input { + position: relative; +} + +.access-token-input input { + width: 100%; +} + +.access-token-input:has(.icon) input { + padding-right: 26px; +} + +.access-token-input .icon-button { + position: absolute; + top: 0; + right: 0; +} + +.link-icon:hover { + color: var(--framer-color-text); +} + +.access-token-input .icon { + position: absolute; + padding: 0 8px; + height: 100%; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.button-stack { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.dropdown-button { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + padding-right: 0; + font-weight: 500; + background-color: var(--framer-color-bg-tertiary) !important; +} + +.icon-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0 8px; + height: 100%; + color: var(--framer-color-text-tertiary); + transition: color 0.2s ease-in-out; +} + +input.error { + box-shadow: inset 0 0 0 1px var(--color-error); + color: var(--color-error); + background-color: color-mix(in srgb, var(--color-error) 10%, transparent); +} + +.locales-empty-state { + background-color: var(--framer-color-bg-tertiary); + border-radius: 8px; + opacity: 0.5; + height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0 0 10px; +} + +.checkbox-label { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.checkbox-label input[type="checkbox"]:not(:checked) { + background-color: var(--framer-color-bg-tertiary); +} + +.heading { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.step-indicator { + color: var(--framer-color-text-tertiary); +} diff --git a/plugins/crowdin/src/App.tsx b/plugins/crowdin/src/App.tsx new file mode 100644 index 000000000..bc10234a7 --- /dev/null +++ b/plugins/crowdin/src/App.tsx @@ -0,0 +1,899 @@ +import cx from "classnames" +import { framer, type LocalizationData, type Locale, useIsAllowedTo } from "framer-plugin" +import { useCallback, useEffect, useRef, useState } from "react" +import "./App.css" +import { ProjectsGroups, Translations } from "@crowdin/crowdin-api-client" +import { CheckIcon, ChevronDownIcon, LinkArrowIcon, XIcon } from "./Icons" +import { useDynamicPluginHeight } from "./useDynamicPluginHeight" +import { + createValuesBySourceFromXliff, + ensureSourceFile, + generateXliff, + getProjectTargetLanguageIds, + parseXliff, + updateTranslation, + uploadStorage, +} from "./xliff" + +const PLUGIN_WIDTH = 280 +const NO_PROJECT_PLACEHOLDER = "Select…" +const ALL_LOCALES_ID = "__ALL_LOCALES__" + +type LocaleIds = string[] | typeof ALL_LOCALES_ID + +interface ImportConfirmationState { + locales: Locale[] + valuesByLocale: Record> + currentIndex: number + confirmedLocaleIds: Set +} + +enum AccessTokenState { + None = "none", + Valid = "valid", + Invalid = "invalid", + Loading = "loading", +} + +interface Project { + readonly id: number + readonly name: string +} + +interface CrowdinStorageResponse { + data: { + id: number + } +} + +function createCrowdinClient(token: string) { + return { + projects: new ProjectsGroups({ token }), + translations: new Translations({ token }), + } +} + +export function App({ activeLocale, locales }: { activeLocale: Locale | null; locales: readonly Locale[] }) { + const [mode, setMode] = useState<"export" | "import" | null>(null) + const [accessToken, setAccessToken] = useState("") + const [accessTokenState, setAccessTokenState] = useState(AccessTokenState.None) + const [projectList, setProjectList] = useState([]) + const [projectId, setProjectId] = useState(0) + const [selectedLocaleIds, setSelectedLocaleIds] = useState(activeLocale ? [activeLocale.id] : []) + const [availableLocaleIds, setAvailableLocaleIds] = useState([]) + const [localesLoading, setLocalesLoading] = useState(false) + const [operationInProgress, setOperationInProgress] = useState(false) + const [importConfirmation, setImportConfirmation] = useState(null) + const validatingAccessTokenRef = useRef(false) + + useDynamicPluginHeight({ width: PLUGIN_WIDTH }) + + // Set close warning when importing or exporting + useEffect(() => { + try { + if (operationInProgress || (mode === "import" && importConfirmation)) { + if (mode === "import") { + void framer.setCloseWarning("Import in progress. Closing will cancel the import.") + } else if (mode === "export") { + void framer.setCloseWarning("Export in progress. Closing will cancel the export.") + } + } else { + void framer.setCloseWarning(false) + } + } catch (error) { + console.error("Error setting close warning:", error) + } + }, [mode, operationInProgress, importConfirmation]) + + const validateAccessToken = useCallback( + async (token: string): Promise => { + if (validatingAccessTokenRef.current) return + if (token === accessToken) return + + if (!token) { + if (framer.isAllowedTo("setPluginData")) { + void framer.setPluginData("accessToken", "") + void framer.setPluginData("projectId", null) + } + setAccessToken("") + setProjectList([]) + setProjectId(0) + setAccessTokenState(AccessTokenState.None) + return + } + + if (accessToken && framer.isAllowedTo("setPluginData")) { + void framer.setPluginData("projectId", null) + } + + validatingAccessTokenRef.current = true + setAccessTokenState(AccessTokenState.Loading) + + try { + const { isValid, projects } = await validateAccessTokenAndGetProjects(token) + + setAccessToken(token) + + if (isValid) { + setProjectList(projects ?? []) + + const storedProjectIdRaw = projects?.length ? await framer.getPluginData("projectId") : null + const storedProjectId = storedProjectIdRaw ? Number.parseInt(storedProjectIdRaw, 10) : null + const projectIdFromStorage = + storedProjectId && + Number.isFinite(storedProjectId) && + projects?.some(p => p.id === storedProjectId) + ? storedProjectId + : null + + if (projectIdFromStorage != null) { + setProjectId(projectIdFromStorage) + } else if (Array.isArray(projects) && projects.length === 1 && projects[0]?.id) { + setProjectId(projects[0].id) + } else { + setProjectId(0) + } + + setAccessTokenState(AccessTokenState.Valid) + } else { + setProjectList([]) + setProjectId(0) + setAccessTokenState(AccessTokenState.Invalid) + } + } catch (error) { + console.error(error) + framer.notify( + `Error validating access token: ${error instanceof Error ? error.message : "Unknown error"}`, + { variant: "error" } + ) + setProjectList([]) + setProjectId(0) + setAccessTokenState(AccessTokenState.Invalid) + } + + validatingAccessTokenRef.current = false + }, + [accessToken] + ) + + // Resolve selected locale IDs to an array (handles "All Locales") + const localeIdsToSync = selectedLocaleIds === ALL_LOCALES_ID ? availableLocaleIds : selectedLocaleIds + + // ------------------ Import from Crowdin ------------------ + async function startImportConfirmation() { + if (operationInProgress) return + + if (!framer.isAllowedTo("setLocalizationData")) { + return framer.notify("You are not allowed to set localization data", { + variant: "error", + }) + } else if (!accessToken) { + return framer.notify("Access token is missing", { + variant: "error", + }) + } else if (!projectId) { + return framer.notify("Project ID is missing", { + variant: "error", + }) + } else if (localeIdsToSync.length === 0) { + return framer.notify("Select at least one locale to import", { + variant: "error", + }) + } + + setOperationInProgress(true) + const client = createCrowdinClient(accessToken) + const localesToSync = locales.filter(locale => localeIdsToSync.includes(locale.id)) + const valuesByLocale: Record> = {} + + try { + for (const locale of localesToSync) { + const exportRes = await client.translations.exportProjectTranslation(projectId, { + targetLanguageId: locale.code, + format: "xliff", + }) + const url = exportRes.data.url + if (!url) { + framer.notify(`Crowdin export URL not found for ${locale.code}`, { + variant: "error", + }) + continue + } + const resp = await fetch(url) + const fileContent = await resp.text() + const { xliff, targetLocale } = parseXliff(fileContent, locales) + const valuesBySource = await createValuesBySourceFromXliff(xliff, targetLocale) + if (!valuesBySource) continue + valuesByLocale[locale.id] = valuesBySource + } + + if (Object.keys(valuesByLocale).length === 0) { + framer.notify("No translations could be fetched from Crowdin", { + variant: "error", + }) + return + } + + const orderedLocales = localesToSync.filter(locale => locale.id in valuesByLocale) + setImportConfirmation({ + locales: orderedLocales, + valuesByLocale, + currentIndex: 0, + confirmedLocaleIds: new Set(), + }) + } catch (error) { + console.error("Error fetching from Crowdin:", error) + framer.notify(`Import error: ${error instanceof Error ? error.message : "An unknown error occurred"}`, { + variant: "error", + durationMs: 10000, + }) + } finally { + setOperationInProgress(false) + } + } + + function applyConfirmedImport(state: ImportConfirmationState) { + if (state.confirmedLocaleIds.size === 0) { + framer.notify("No locales selected for import", { variant: "info" }) + setImportConfirmation(null) + return + } + + const mergedValuesBySource: NonNullable = {} + for (const localeId of state.confirmedLocaleIds) { + const localeValues = state.valuesByLocale[localeId] + if (!localeValues) continue + for (const sourceId of Object.keys(localeValues)) { + const localeData = localeValues[sourceId] + if (localeData) { + mergedValuesBySource[sourceId] ??= {} + Object.assign(mergedValuesBySource[sourceId], localeData) + } + } + } + + setOperationInProgress(true) + framer + .setLocalizationData({ valuesBySource: mergedValuesBySource }) + .then(result => { + if (result.valuesBySource.errors.length > 0) { + throw new Error( + result.valuesBySource.errors + .map(error => (error.sourceId ? `${error.error}: ${error.sourceId}` : error.error)) + .join(", ") + ) + } + const count = state.confirmedLocaleIds.size + framer.notify(`Successfully imported ${count} locale${count === 1 ? "" : "s"} from Crowdin`, { + variant: "success", + durationMs: 5000, + }) + }) + .catch((error: unknown) => { + console.error("Error applying import:", error) + framer.notify(`Import error: ${error instanceof Error ? error.message : "An unknown error occurred"}`, { + variant: "error", + durationMs: 10000, + }) + }) + .finally(() => { + setOperationInProgress(false) + setImportConfirmation(null) + }) + } + + async function exportToCrowdin() { + if (operationInProgress) return + + if (!accessToken) { + return framer.notify("Access Token is missing", { + variant: "error", + }) + } else if (!projectId) { + return framer.notify("Project ID is missing", { + variant: "error", + }) + } else if (localeIdsToSync.length === 0) { + return framer.notify("Select at least one locale to export", { + variant: "error", + }) + } + + setOperationInProgress(true) + const localesToSync = locales.filter(locale => localeIdsToSync.includes(locale.id)) + + try { + const groups = await framer.getLocalizationGroups() + const defaultLocale = await framer.getDefaultLocale() + const sourceFilename = `framer-source-${defaultLocale.code}.xliff` + const fileId = await ensureSourceFile(sourceFilename, projectId, accessToken, defaultLocale, groups) + + for (const locale of localesToSync) { + const xliffContent = generateXliff(defaultLocale, locale, groups) + const filename = `translations-${locale.code}.xliff` + + const storageRes = await uploadStorage(xliffContent, accessToken, filename) + if (!storageRes.ok) { + framer.notify(`Failed to upload ${locale.code} to Crowdin storage`, { + variant: "error", + }) + continue + } + const storageData = (await storageRes.json()) as CrowdinStorageResponse + const storageId = storageData.data.id + + const uploadRes = await updateTranslation(projectId, storageId, fileId, accessToken, locale) + if (!uploadRes.ok) { + const errMsg = await uploadRes.text() + framer.notify(`Crowdin upload failed for ${locale.code}: ${errMsg}`, { variant: "error" }) + } + } + + const count = localesToSync.length + framer.notify(`Export to Crowdin complete (${count} ${count === 1 ? "locale" : "locales"})`, { + variant: "success", + durationMs: 5000, + }) + } catch (error) { + console.error("Error exporting to Crowdin:", error) + framer.notify(`Export error: ${error instanceof Error ? error.message : "An unknown error occurred"}`, { + variant: "error", + durationMs: 10000, + }) + } finally { + setOperationInProgress(false) + } + } + + useEffect(() => { + async function loadStoredToken() { + const storedToken = await framer.getPluginData("accessToken") + if (storedToken) { + setAccessToken(storedToken) + void validateAccessToken(storedToken) + } + } + void loadStoredToken() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleSetProjectId = useCallback((id: number) => { + setProjectId(id) + if (framer.isAllowedTo("setPluginData")) { + void framer.setPluginData("projectId", id ? String(id) : null) + } + }, []) + + // Fetch Crowdin project target languages when project is selected + useEffect(() => { + if (!projectId || !accessToken || accessTokenState !== AccessTokenState.Valid) { + setAvailableLocaleIds([]) + setSelectedLocaleIds([]) + setLocalesLoading(false) + return + } + + setAvailableLocaleIds([]) + setSelectedLocaleIds([]) + setLocalesLoading(true) + let cancelled = false + const task = async () => { + let targetLanguageIds: string[] = [] + try { + const ids: string[] = await getProjectTargetLanguageIds(projectId, accessToken) + if (!cancelled) { + targetLanguageIds = ids + } + } catch { + if (!cancelled) { + targetLanguageIds = [] + } + } finally { + if (!cancelled) { + setLocalesLoading(false) + } + } + + // Locales that exist in both Framer and the selected Crowdin project + const availableLocaleIds = locales + .filter(locale => targetLanguageIds.includes(locale.code)) + .map(locale => locale.id) + setAvailableLocaleIds(availableLocaleIds) + setSelectedLocaleIds(availableLocaleIds) + } + void task() + + return () => { + cancelled = true + } + }, [projectId, accessToken, accessTokenState, locales]) + + function onSubmit() { + if (mode === "export") { + void exportToCrowdin() + } else if (mode === "import") { + void startImportConfirmation() + } + } + + if (mode === null) { + return + } + + if (mode === "import" && importConfirmation) { + const { locales: confirmLocales, currentIndex, confirmedLocaleIds } = importConfirmation + const currentLocale = confirmLocales[currentIndex] + const remainingCount = confirmLocales.length - currentIndex + + return ( + { + const nextIndex = currentIndex + 1 + if (nextIndex >= confirmLocales.length) { + applyConfirmedImport({ ...importConfirmation, currentIndex: nextIndex }) + } else { + setImportConfirmation({ ...importConfirmation, currentIndex: nextIndex }) + } + }} + replace={() => { + const nextConfirmed = new Set(confirmedLocaleIds) + if (currentLocale) nextConfirmed.add(currentLocale.id) + const nextIndex = currentIndex + 1 + if (nextIndex >= confirmLocales.length) { + applyConfirmedImport({ + ...importConfirmation, + currentIndex: nextIndex, + confirmedLocaleIds: nextConfirmed, + }) + } else { + setImportConfirmation({ + ...importConfirmation, + currentIndex: nextIndex, + confirmedLocaleIds: nextConfirmed, + }) + } + }} + replaceAll={() => { + const nextConfirmed = new Set(confirmedLocaleIds) + for (let i = currentIndex; i < confirmLocales.length; i++) { + const loc = confirmLocales[i] + if (loc) nextConfirmed.add(loc.id) + } + applyConfirmedImport({ + ...importConfirmation, + currentIndex: confirmLocales.length, + confirmedLocaleIds: nextConfirmed, + }) + }} + /> + ) + } + + return ( + + ) +} + +function Home({ setMode }: { setMode: (mode: "export" | "import") => void }) { + const isAllowedToSetLocalizationData = useIsAllowedTo("setLocalizationData") + + return ( +
+
+
+
+ Crowdin Logo +
+

Translate with Crowdin

+

Enter your access token from Crowdin and select a project to export Locales.

+
+
+ + +
+
+ ) +} + +function ConfigurationPage({ + mode, + locales, + availableLocaleIds, + localesLoading, + accessToken, + accessTokenState, + projectId, + projectList, + validateAccessToken, + setProjectId, + selectedLocaleIds, + setSelectedLocaleIds, + operationInProgress, + onSubmit, +}: { + mode: "export" | "import" + locales: readonly Locale[] + availableLocaleIds: string[] + localesLoading: boolean + accessToken: string + accessTokenState: AccessTokenState + projectId: number + projectList: readonly Project[] + validateAccessToken: (accessToken: string) => Promise + setProjectId: (projectId: number) => void + selectedLocaleIds: LocaleIds + setSelectedLocaleIds: (localeIds: LocaleIds) => void + operationInProgress: boolean + onSubmit: () => void +}) { + const [accessTokenValue, setAccessTokenValue] = useState(accessToken) + const accessTokenInputRef = useRef(null) + + const isAllowedToSetLocalizationData = useIsAllowedTo("setLocalizationData") + const hasSelectedLocales = selectedLocaleIds === ALL_LOCALES_ID || selectedLocaleIds.length > 0 + const canPerformAction = + accessToken && projectId && hasSelectedLocales && (mode === "import" ? isAllowedToSetLocalizationData : true) + const accessTokenValueHasChanged = accessTokenValue !== accessToken + + useEffect(() => { + setAccessTokenValue(accessToken) + }, [accessToken]) + + function onProjectButtonClick(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect() + void framer.showContextMenu( + [ + { + label: NO_PROJECT_PLACEHOLDER, + enabled: false, + }, + ...projectList.map(p => ({ + label: p.name, + checked: p.id === projectId, + onAction: () => { + setProjectId(p.id) + }, + })), + ], + { + location: { + x: rect.right - 4, + y: rect.bottom + 4, + }, + width: 250, + placement: "bottom-left", + } + ) + } + + function onLocaleButtonClick(e: React.MouseEvent, localeId: string | null) { + const rect = e.currentTarget.getBoundingClientRect() + + void framer.showContextMenu( + [ + { + label: "All Locales", + checked: selectedLocaleIds === ALL_LOCALES_ID, + onAction: () => { + setSelectedLocaleIds(selectedLocaleIds === ALL_LOCALES_ID ? [] : ALL_LOCALES_ID) + }, + }, + { + type: "separator", + }, + ...locales.map(locale => ({ + label: locale.name, + secondaryLabel: locale.code, + checked: selectedLocaleIds.includes(locale.id), + enabled: + availableLocaleIds.includes(locale.id) && + !(selectedLocaleIds === ALL_LOCALES_ID + ? false + : selectedLocaleIds.includes(locale.id) && locale.id !== localeId), + onAction: () => { + if (selectedLocaleIds === ALL_LOCALES_ID) { + setSelectedLocaleIds([locale.id]) + } else { + if (selectedLocaleIds.includes(locale.id)) { + setSelectedLocaleIds(selectedLocaleIds.filter(id => id !== locale.id)) + } else { + setSelectedLocaleIds([...selectedLocaleIds, locale.id]) + } + } + }, + })), + ], + { + location: { + x: rect.right - 4, + y: rect.bottom + 4, + }, + width: 250, + placement: "bottom-left", + } + ) + } + + function onRemoveLocaleClick(e: React.MouseEvent, localeId: string) { + e.stopPropagation() + setSelectedLocaleIds( + selectedLocaleIds === ALL_LOCALES_ID ? [] : selectedLocaleIds.filter(id => id !== localeId) + ) + } + + return ( +
+
+
+ +
+ { + setAccessTokenValue(e.target.value) + }} + onKeyDown={e => { + if (e.key === "Enter") { + void validateAccessToken(accessTokenValue) + } + }} + onBlur={() => { + void validateAccessToken(accessTokenValue) + }} + /> + {accessTokenState === AccessTokenState.None && !accessTokenValueHasChanged && ( + + + + )} + {accessTokenState === AccessTokenState.Loading && ( +
+
+
+ )} + {accessTokenState === AccessTokenState.Valid && !accessTokenValueHasChanged && ( +
+ +
+ )} +
+ + + + + + {availableLocaleIds.length === 0 ? ( +
+ {projectId ? (localesLoading ? "Loading" : "No matching locales") : "Select…"} + {!projectId || localesLoading ? ( +
+ +
+ ) : null} +
+ ) : selectedLocaleIds === ALL_LOCALES_ID ? ( + + ) : ( +
+ {selectedLocaleIds.map(id => ( + + ))} + {selectedLocaleIds.length < availableLocaleIds.length && ( + + )} +
+ )} +
+
+
+ +
+ ) +} + +function PropertyControl({ label, children }: { label: string; children: React.ReactNode | React.ReactNode[] }) { + return ( +
+

{label}

+
{children}
+
+ ) +} + +function ConfirmationModal({ + localeName, + currentStep, + totalSteps, + remainingLocaleCount, + skip, + replace, + replaceAll, +}: { + localeName: string + currentStep: number + totalSteps: number + remainingLocaleCount: number + skip: () => void + replace: () => void + replaceAll: () => void +}) { + const [allChecked, setAllChecked] = useState(false) + + return ( +
+
+
+

Replace Locale{totalSteps === 1 ? "" : "s"}

+ + {currentStep} / {totalSteps} + +
+
+

+ By importing you are going to override the existing locale "{localeName}". +

+ {totalSteps > 1 && ( + + )} +
+ + +
+
+ ) +} + +// Returns a list of projects or null if the access token is invalid +async function validateAccessTokenAndGetProjects( + token: string +): Promise<{ isValid: boolean; projects: Project[] | null }> { + // Persist token + if (framer.isAllowedTo("setPluginData")) { + void framer.setPluginData("accessToken", token) + } + + if (token) { + try { + const projectsGroupsApi = new ProjectsGroups({ token }) + const response = await projectsGroupsApi.withFetchAll().listProjects() + + // Only log in development + if (window.location.hostname === "localhost") { + console.log(response.data) + } + const projects = response.data.map(({ data }: { data: Project }) => ({ + id: data.id, + name: data.name, + })) + return { isValid: true, projects } + } catch (error) { + console.error(error) + framer.notify("Invalid access token", { variant: "error" }) + return { isValid: false, projects: null } + } + } else { + return { isValid: false, projects: null } + } +} diff --git a/plugins/crowdin/src/Icons.tsx b/plugins/crowdin/src/Icons.tsx new file mode 100644 index 000000000..8030d4332 --- /dev/null +++ b/plugins/crowdin/src/Icons.tsx @@ -0,0 +1,57 @@ +export function XIcon() { + return ( + + + + + + ) +} + +export function ChevronDownIcon() { + return ( + + + + ) +} + +export function CheckIcon() { + return ( + + + + ) +} + +export function LinkArrowIcon() { + return ( + + + + ) +} diff --git a/plugins/crowdin/src/api-types.ts b/plugins/crowdin/src/api-types.ts new file mode 100644 index 000000000..e87222c21 --- /dev/null +++ b/plugins/crowdin/src/api-types.ts @@ -0,0 +1,73 @@ +import * as v from "valibot" + +export const TargetLanguageSchema = v.object({ + id: v.string(), + name: v.string(), +}) + +export const ProjectSchema = v.object({ + id: v.optional(v.number()), + name: v.nullable(v.string()), + targetLanguages: v.array(TargetLanguageSchema), +}) + +export const StorageSchema = v.object({ + id: v.optional(v.number()), + fileName: v.nullable(v.string()), +}) + +export const ProjectsSchema = v.object({ + data: v.nullable(ProjectSchema), +}) + +export const StoragesSchema = v.object({ + data: v.nullable(StorageSchema), +}) + +export const FileSchema = v.object({ + id: v.number(), + projectId: v.number(), + name: v.string(), + path: v.string(), + type: v.string(), + status: v.string(), + createdAt: v.string(), + updatedAt: v.string(), +}) + +export const CreateFileResponseSchema = v.object({ + data: FileSchema, +}) + +export const FileResponseSchema = v.object({ + data: v.array(v.object({ data: FileSchema })), + pagination: v.object({ + offset: v.number(), + limit: v.number(), + }), +}) + +export const LanguageSchema = v.object({ + id: v.string(), + name: v.string(), + editorCode: v.string(), + twoLettersCode: v.string(), + threeLettersCode: v.string(), + locale: v.string(), + androidCode: v.string(), + osxCode: v.string(), + osxLocale: v.string(), + pluralCategoryNames: v.array(v.string()), + pluralRules: v.string(), + pluralExamples: v.array(v.string()), + textDirection: v.string(), + dialectOf: v.nullable(v.string()), +}) + +export const LanguagesResponseSchema = v.object({ + data: v.array(v.object({ data: LanguageSchema })), + pagination: v.object({ + offset: v.number(), + limit: v.number(), + }), +}) diff --git a/plugins/crowdin/src/main.tsx b/plugins/crowdin/src/main.tsx new file mode 100644 index 000000000..21ac4a720 --- /dev/null +++ b/plugins/crowdin/src/main.tsx @@ -0,0 +1,30 @@ +import "framer-plugin/framer.css" + +import { framer } from "framer-plugin" +import React from "react" +import ReactDOM from "react-dom/client" +import { App } from "./App.tsx" + +const root = document.getElementById("root") +if (!root) throw new Error("Root element not found") + +const [activeLocale, locales] = await Promise.all([framer.getActiveLocale(), framer.getLocales()]) + +if (window.location.hostname === "localhost") { + console.log({ activeLocale, locales }) +} + +if (!activeLocale) { + framer.closePlugin( + locales.length > 0 + ? "No active locale found. Please select a locale." + : "No locales found. Please create a locale.", + { variant: "error" } + ) +} else { + ReactDOM.createRoot(root).render( + + + + ) +} diff --git a/plugins/crowdin/src/useDynamicPluginHeight.tsx b/plugins/crowdin/src/useDynamicPluginHeight.tsx new file mode 100644 index 000000000..1e5d7a673 --- /dev/null +++ b/plugins/crowdin/src/useDynamicPluginHeight.tsx @@ -0,0 +1,38 @@ +import { framer, type UIOptions } from "framer-plugin" +import { useLayoutEffect } from "react" + +// Automatically resize the plugin to match the height of the content. +// Use this in place of framer.showUI() inside a React component. +export function useDynamicPluginHeight(options: Partial = {}) { + useLayoutEffect(() => { + const root = document.getElementById("root") + if (!root) return + + const contentElement = root.firstElementChild + if (!contentElement) return + + const updateHeight = () => { + const height = contentElement.scrollHeight + void framer.showUI({ + ...options, + height: Math.max(options.minHeight ?? 0, Math.min(height, options.maxHeight ?? Infinity)), + }) + } + + // Initial height update + updateHeight() + + // Create ResizeObserver to watch for height changes + const resizeObserver = new ResizeObserver(() => { + updateHeight() + }) + + // Start observing the content element + resizeObserver.observe(contentElement) + + // Cleanup + return () => { + resizeObserver.disconnect() + } + }, [options]) +} diff --git a/plugins/crowdin/src/vite-env.d.ts b/plugins/crowdin/src/vite-env.d.ts new file mode 100644 index 000000000..25d1a3e93 --- /dev/null +++ b/plugins/crowdin/src/vite-env.d.ts @@ -0,0 +1,5 @@ +/// + +interface ViteTypeOptions { + strictImportMetaEnv: unknown +} diff --git a/plugins/crowdin/src/xliff.ts b/plugins/crowdin/src/xliff.ts new file mode 100644 index 000000000..f93cdaa8e --- /dev/null +++ b/plugins/crowdin/src/xliff.ts @@ -0,0 +1,507 @@ +import { + framer, + type Locale, + type LocalizationData, + type LocalizationGroup, + type LocalizationSource, + type LocalizedValueStatus, +} from "framer-plugin" +import * as v from "valibot" +import { + CreateFileResponseSchema, + FileResponseSchema, + LanguagesResponseSchema, + ProjectsSchema, + StoragesSchema, +} from "./api-types" + +const API_URL = "https://api.crowdin.com/api/v2" + +// -------------------- Types -------------------- + +interface StorageResponse { + data: { id: number; fileName?: string } +} + +export function parseXliff(xliffText: string, locales: readonly Locale[]): { xliff: Document; targetLocale: Locale } { + const parser = new DOMParser() + const xliff = parser.parseFromString(xliffText, "text/xml") + + const xliffElement = xliff.querySelector("file") + if (!xliffElement) throw new Error("No xliff element found in XLIFF") + + const targetLanguage = xliffElement.getAttribute("target-language") + if (!targetLanguage) throw new Error("No target language found in XLIFF") + + const targetLocale = locales.find(locale => locale.code === targetLanguage) + if (!targetLocale) { + throw new Error(`No locale found for language code: ${targetLanguage}`) + } + + return { xliff, targetLocale } +} + +export async function createValuesBySourceFromXliff( + xliffDocument: Document, + targetLocale: Locale +): Promise { + const valuesBySource: LocalizationData["valuesBySource"] = {} + + // Get all localization groups to find source IDs by text + const groups = await framer.getLocalizationGroups() + + // Create a map of source text to source ID for quick lookup + const sourceTextToId = new Map() + for (const group of groups) { + for (const source of group.sources) { + sourceTextToId.set(source.value, source.id) + } + } + + const units = xliffDocument.querySelectorAll("trans-unit") + for (const unit of units) { + const sourceElement = unit.querySelector("source") + const target = unit.querySelector("target") + if (!sourceElement || !target) continue + + const sourceText = sourceElement.textContent + const targetValue = target.textContent + + // Ignore missing or empty values + if (!sourceText || !targetValue) continue + + // Find the actual source ID by matching the source text + const sourceId = sourceTextToId.get(sourceText) + if (!sourceId) { + console.warn(`No source ID found for text: "${sourceText}"`) + continue + } + + valuesBySource[sourceId] = { + [targetLocale.id]: { + action: "set", + value: targetValue, + needsReview: false, + }, + } + } + + return valuesBySource +} + +// The two functions below have `undefined` in their return types as to future-proof against LocalizedValueStatus and +// XliffState unions being expanded in minor releases. + +function statusToXliffState(status: LocalizedValueStatus): "new" | "needs-translation" | "translated" | "signed-off" { + switch (status) { + case "new": + return "new" + case "needsReview": + return "needs-translation" + case "done": + return "translated" + case "warning": + // Crowdin doesn’t know “warning”, map it to translated but we can add subState note + return "translated" + default: + return "new" + } +} + +export async function getTranslationFileContent(targetLocale: Locale): Promise { + try { + const groups = await framer.getLocalizationGroups() + if (groups.length === 0) { + framer.notify("No localization data to export", { variant: "error" }) + return "" + } + const xliffUnits: string[] = [] + for (const group of groups) { + for (const source of group.sources) { + const sourceValue = source.value + const targetValue = source.valueByLocale[targetLocale.id]?.value ?? source.value + + xliffUnits.push(` + + ${sourceValue} + ${targetValue} + `) + } + } + + return ` + + + +${xliffUnits.join("\n")} + + +` + } catch (e) { + console.log(e) + } + return "" +} + +function escapeXml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +function generateUnit(source: LocalizationSource, targetLocale: Locale, groupName?: string): string { + const localeData = source.valueByLocale[targetLocale.id] + if (!localeData) { + throw new Error(`No locale data found for locale: ${targetLocale.id}`) + } + + const state = statusToXliffState(localeData.status) + const sourceValue = escapeXml(source.value) + const targetValue = escapeXml(localeData.value ?? "") + + return ` + ${sourceValue} + ${targetValue} + ${groupName ? `${escapeXml(groupName)}` : ""} + ` +} +function wrapIfHtml(text: string): string { + // If text looks like HTML, wrap in CDATA + if (/<[a-z][\s\S]*>/i.test(text)) { + return `` + } + return escapeXml(text) +} +export function generateSourceXliff(defaultLocale: Locale, groups: readonly LocalizationGroup[]): string { + let units = "" + for (const group of groups) { + for (const source of group.sources) { + const sourceValue = wrapIfHtml(source.value) + units += ` + ${sourceValue} + ${escapeXml(group.name)} + \n` + } + } + + return ` + + + +${units} + +` +} + +export function generateXliff( + defaultLocale: Locale, + targetLocale: Locale, + groups: readonly LocalizationGroup[] +): string { + let units = "" + + for (const group of groups) { + for (const source of group.sources) { + const sourceValue = wrapIfHtml(source.value) + const targetRaw = source.valueByLocale[targetLocale.id]?.value ?? "" + const targetValue = wrapIfHtml(targetRaw) + + units += ` + ${sourceValue} + ${targetValue} + ${escapeXml(group.name)} + \n` + } + } + + return ` + + + +${units} + +` +} + +export function generateGroup(localizationGroup: LocalizationGroup, targetLocale: Locale): string { + const units = localizationGroup.sources.map(source => generateUnit(source, targetLocale)) + + return ` + ${escapeXml(localizationGroup.name)} +${units.join("\n")} + ` +} + +export async function uploadStorage(content: string, accessToken: string, fileName: string): Promise { + return fetch(`${API_URL}/storages`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/octet-stream", + "Crowdin-API-FileName": fileName, + }, + body: new Blob([content], { type: "application/x-xliff+xml" }), + }) +} +export async function ensureSourceFile( + filename: string, + projectId: number, + accessToken: string, + defaultLocale: Locale, + groups: readonly LocalizationGroup[] +): Promise { + // Step 1: Check if file already exists in Crowdin + const fileRes = await fetch(`${API_URL}/projects/${projectId}/files?limit=500`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + if (!fileRes.ok) { + throw new Error(`Failed to fetch files: ${await fileRes.text()}`) + } + + const fileData: unknown = await fileRes.json() + const parsed = v.parse(FileResponseSchema, fileData) + + const existingFile = parsed.data.find(f => f.data.name === filename) + if (existingFile) { + console.log(`Source file already exists in Crowdin: ${filename} (id: ${existingFile.data.id})`) + return existingFile.data.id + } + // Step 2: Upload storage for new source file + const xliffContent = generateSourceXliff(defaultLocale, groups) + const storageRes = await uploadStorage(xliffContent, accessToken, filename) + const storageData = (await storageRes.json()) as StorageResponse + const storageId = storageData.data.id + + return await createFile(projectId, storageId, filename, accessToken) +} + +async function checkAndCreateLanguage(projectId: number, language: Locale, accessToken: string): Promise { + const res = await fetch(`${API_URL}/languages?limit=500`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + const data: unknown = await res.json() + const parsed = v.parse(LanguagesResponseSchema, data) + const languages = parsed.data.map(l => l.data) + + const targetLanguage = languages.find(l => l.id === language.code) + + if (!targetLanguage) { + console.log("no target language found") + throw new Error( + `Language "${language.code}" is not available in Crowdin. Please check your locale's region and language code in Framer` + ) + } + await ensureLanguageInProject(projectId, language.code, accessToken) +} + +export async function getProjectTargetLanguageIds(projectId: number, accessToken: string): Promise { + const res = await fetch(`${API_URL}/projects/${projectId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) { + throw new Error(`Failed to fetch project: ${res.statusText}`) + } + const raw: unknown = await res.json() + const parsed = v.parse(ProjectsSchema, raw) + if (!parsed.data) { + throw new Error("Crowdin did not return a project object") + } + return parsed.data.targetLanguages.map(l => l.id) +} + +export async function ensureLanguageInProject( + projectId: number, + newLanguageId: string, + accessToken: string +): Promise { + const currentLanguages = await getProjectTargetLanguageIds(projectId, accessToken) + + if (currentLanguages.includes(newLanguageId)) { + console.log(`Language ${newLanguageId} already exists in project`) + return + } + + const updatedLanguages = [...currentLanguages, newLanguageId] + + const patchRes = await fetch(`${API_URL}/projects/${projectId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([ + { + op: "replace", + path: "/targetLanguageIds", + value: updatedLanguages, + }, + ]), + }) + + if (!patchRes.ok) { + const err = await patchRes.text() + throw new Error(`Failed to update languages: ${err}`) + } +} + +export async function updateTranslation( + projectId: number, + storageId: number, + fileId: number, + accessToken: string, + activeLocale: Locale +): Promise { + await checkAndCreateLanguage(projectId, activeLocale, accessToken) + return fetch(`${API_URL}/projects/${projectId}/translations/${activeLocale.code}`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + storageId, + fileId, + }), + }) +} + +// -------------------- Get or Create Storage -------------------- +export async function getStorageId(fileName: string, accessToken: string): Promise { + try { + const storageList = await fetch(`${API_URL}/storages`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + const storageData = (await storageList.json()) as unknown + + // Validate response with valibot + const parsed = v.safeParse(v.object({ data: v.array(StoragesSchema) }), storageData) + if (!parsed.success) { + console.error("Error parsing Crowdin storages:", parsed.issues) + throw new Error("Invalid storage response") + } + + const existingStorage = parsed.output.data.find(item => item.data?.fileName?.includes(fileName)) + + if (existingStorage) { + return Number(existingStorage.data?.id ?? "") + } else { + return await createStorage(fileName, accessToken) + } + } catch (err) { + console.error("Error in getStorageId:", err) + throw err + } +} + +export async function createStorage(fileName: string, accessToken: string): Promise { + try { + const groups = await framer.getLocalizationGroups() + const stringsObject: Record = {} + + for (const group of groups) { + for (const src of group.sources) { + if (src.id) stringsObject[src.id] = src.value + } + } + + const jsonString = JSON.stringify(stringsObject) + const uint8Array = new TextEncoder().encode(jsonString) + + const storageRes = await fetch(`${API_URL}/storages`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Crowdin-API-FileName": fileName, + }, + body: uint8Array, + }) + + const storageData = (await storageRes.json()) as StorageResponse + return storageData.data.id + } catch (err) { + console.error("Error in createStorage:", err) + throw err + } +} + +// -------------------- Get or Create File -------------------- +export async function getFileId(projectId: number, fileName: string, accessToken: string): Promise { + try { + const filesRes = await fetch(`${API_URL}/projects/${projectId}/files`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + const filesData = (await filesRes.json()) as unknown + const parsed = v.safeParse(v.object({ data: v.array(ProjectsSchema) }), filesData) + if (!parsed.success) { + console.error("Error parsing Crowdin files:", parsed.issues) + throw new Error("Invalid file response") + } + + const storageId = await getStorageId(fileName, accessToken) + const existingFile = parsed.output.data.find(item => item.data?.name?.includes(fileName)) + const existingFileId = Number(existingFile?.data?.id ?? "") + + if (existingFileId) { + await fetch(`${API_URL}/projects/${projectId}/files/${existingFileId}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ storageId }), + }) + return existingFileId + } else { + return await createFile(projectId, storageId, fileName, accessToken) + } + } catch (err) { + console.error("Error in getFileId:", err) + throw err + } +} + +export async function createFile( + projectId: number, + storageId: number, + filename: string, + accessToken: string +): Promise { + try { + const fileRes = await fetch(`${API_URL}/projects/${projectId}/files`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + storageId, + name: filename, + }), + }) + + const fileData: unknown = await fileRes.json() + const parsed = v.parse(CreateFileResponseSchema, fileData) + return parsed.data.id + } catch (err) { + console.error("Error in createFile:", err) + throw err + } +} + +export function downloadBlob(value: string, filename: string, type: string): void { + const blob = new Blob([value], { type }) + const url = URL.createObjectURL(blob) + + const a = document.createElement("a") + a.href = url + a.download = filename + + a.click() + URL.revokeObjectURL(url) +} diff --git a/plugins/crowdin/tsconfig.json b/plugins/crowdin/tsconfig.json new file mode 100644 index 000000000..69ad5d606 --- /dev/null +++ b/plugins/crowdin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "*"] +} diff --git a/yarn.lock b/yarn.lock index 427bba63e..996685337 100644 --- a/yarn.lock +++ b/yarn.lock @@ -156,6 +156,15 @@ __metadata: languageName: node linkType: hard +"@crowdin/crowdin-api-client@npm:^1.46.0": + version: 1.48.3 + resolution: "@crowdin/crowdin-api-client@npm:1.48.3" + dependencies: + axios: "npm:^1" + checksum: 10/eb61dd52f6bfd13a971b6641d02b5131a57b6cea174f0874354ea79508ed1db54c820db63e5d6a3333346d94331829f57294271bb6ad8163ad8e0ea24dbdd97e + languageName: node + linkType: hard + "@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.4.5": version: 1.5.0 resolution: "@emnapi/core@npm:1.5.0" @@ -3285,7 +3294,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.8.3": +"axios@npm:^1, axios@npm:^1.8.3": version: 1.12.2 resolution: "axios@npm:1.12.2" dependencies: @@ -3673,6 +3682,21 @@ __metadata: languageName: node linkType: hard +"crowdin@workspace:plugins/crowdin": + version: 0.0.0-use.local + resolution: "crowdin@workspace:plugins/crowdin" + dependencies: + "@crowdin/crowdin-api-client": "npm:^1.46.0" + "@types/react": "npm:^18.3.23" + "@types/react-dom": "npm:^18.3.7" + classnames: "npm:^2.5.1" + framer-plugin: "npm:^3.10.3" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + valibot: "npm:^1.2.0" + languageName: unknown + linkType: soft + "css-select@npm:^5.1.0": version: 5.1.0 resolution: "css-select@npm:5.1.0" @@ -4604,6 +4628,16 @@ __metadata: languageName: node linkType: hard +"framer-plugin@npm:^3.10.3": + version: 3.10.3 + resolution: "framer-plugin@npm:3.10.3" + peerDependencies: + react: ^18.2.0 + react-dom: ^18.2.0 + checksum: 10/c677a261461cc3a79605cd74fc9de746de7c32e531a0c6a7aba0d5794af1c6c194cdea3576a6e74034c0228e7cc5a8ca8b0e40c213fe85e0dacece7cc01ce6c2 + languageName: node + linkType: hard + "framer-plugin@npm:^3.6.0": version: 3.6.0 resolution: "framer-plugin@npm:3.6.0"