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.11.0-alpha.4-70b1b67f13-9662eda12e.zip b/.yarn/cache/framer-plugin-npm-3.11.0-alpha.4-70b1b67f13-9662eda12e.zip new file mode 100644 index 000000000..9205f73b8 Binary files /dev/null and b/.yarn/cache/framer-plugin-npm-3.11.0-alpha.4-70b1b67f13-9662eda12e.zip differ diff --git a/.yarn/cache/p-limit-npm-7.3.0-7289921502-bd3f3487ec.zip b/.yarn/cache/p-limit-npm-7.3.0-7289921502-bd3f3487ec.zip new file mode 100644 index 000000000..6f82091bb Binary files /dev/null and b/.yarn/cache/p-limit-npm-7.3.0-7289921502-bd3f3487ec.zip differ diff --git a/.yarn/cache/yocto-queue-npm-1.2.2-04a540db16-92dd9880c3.zip b/.yarn/cache/yocto-queue-npm-1.2.2-04a540db16-92dd9880c3.zip new file mode 100644 index 000000000..59755e23d Binary files /dev/null and b/.yarn/cache/yocto-queue-npm-1.2.2-04a540db16-92dd9880c3.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..bd49321a9 --- /dev/null +++ b/plugins/crowdin/README.md @@ -0,0 +1,12 @@ +# 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 + +**By:** @sushilzore, @clementroche, @madebyisaacr, and @johannes-ger + +![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..a1d5ec52f --- /dev/null +++ b/plugins/crowdin/package.json @@ -0,0 +1,29 @@ +{ + "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.11.0-alpha.4", + "motion": "^12.29.2", + "p-limit": "^7.3.0", + "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..f2d996ff8 --- /dev/null +++ b/plugins/crowdin/src/App.css @@ -0,0 +1,337 @@ +/* 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); +} + +#root { + height: fit-content; +} + +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: 255px; +} + +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: 250px; +} + +.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: 28px; +} + +.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; + height: 100%; + width: 28px; + flex-shrink: 0; + 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; + width: 100%; + min-width: 0; + padding-right: 0; + font-weight: 500; + background-color: var(--framer-color-bg-tertiary) !important; +} + +.dropdown-button .content { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 8px; + overflow: hidden; +} + +.dropdown-button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-button[disabled] { + color: var(--framer-color-text-secondary); +} + +.icon-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + height: 100%; + width: 28px; + flex-shrink: 0; + 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-loading { + justify-content: center; + padding: 0; +} + +.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); +} + +.no-locales-message { + width: 100%; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--framer-color-text-tertiary); +} + +/* Progress State */ + +.progress-bar-text { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--framer-color-text-tertiary); +} + +.progress-bar-percent { + font-weight: 600; + color: var(--framer-color-text); +} + +.progress-bar { + height: 3px; + width: 100%; + flex-shrink: 0; + border-radius: 10px; + background-color: var(--framer-color-bg-tertiary); + position: relative; +} + +.progress-bar-fill { + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-radius: 10px; + background-color: var(--framer-color-tint); +} + +/* Flag icons */ + +.flag-icon { + flex-shrink: 0; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2)); +} + +.flag-icon.basic { + width: 16px; + height: 12px; + border-radius: 2px; + background-color: #888; + color: var(--framer-color-bg); + font-size: 8px; + font-weight: 700; + text-align: center; + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; +} + +.flag-icon.emoji { + width: 16px; + height: 16px; +} diff --git a/plugins/crowdin/src/App.tsx b/plugins/crowdin/src/App.tsx new file mode 100644 index 000000000..dd160f79c --- /dev/null +++ b/plugins/crowdin/src/App.tsx @@ -0,0 +1,1023 @@ +import cx from "classnames" +import { framer, type Locale, type LocalizationData, useIsAllowedTo } from "framer-plugin" +import pLimit from "p-limit" +import { useCallback, useEffect, useRef, useState } from "react" +import "./App.css" +import { Flag } from "./components/Flag" +import { CheckIcon, ChevronDownIcon, InfoIcon, LinkArrowIcon, XIcon } from "./components/Icons" +import { ConfirmationModal, CreateLocaleModal } from "./components/Modals" +import { Progress } from "./components/Progress" +import { + type CrowdinStorageResponse, + createCrowdinClient, + type Project, + validateAccessTokenAndGetProjects, +} from "./crowdin" +import { useDynamicPluginHeight } from "./useDynamicPluginHeight" +import { parseLocaleCode } from "./utils" +import { + createValuesBySourceFromXliff, + ensureSourceFile, + generateXliff, + getProjectTargetLanguages, + parseXliff, + updateTranslation, + uploadStorage, +} from "./xliff" + +const NO_PROJECT_PLACEHOLDER = "Select…" +const ALL_LOCALES_ID = "__ALL_LOCALES__" +const DROPDOWN_MENU_WIDTH = 250 + +/** Crowdin allows 20 simultaneous API requests per account. Limit concurrent locale exports to stay under that. */ +const CROWDIN_EXPORT_CONCURRENCY = 5 + +type LocaleIds = string[] | typeof ALL_LOCALES_ID + +interface ImportDisplayLocale { + id: string + name: string + code: string +} + +interface ImportConfirmationState { + locales: Locale[] + valuesByLocale: Record> + currentIndex: number + confirmedLocaleIds: Set + /** Locale codes (e.g. "en-US") to create */ + localesToCreate: string[] +} + +enum AccessTokenState { + None = "none", + Valid = "valid", + Invalid = "invalid", + Loading = "loading", +} + +export function App({ activeLocale, locales }: { activeLocale: Locale | null; locales: readonly Locale[] }) { + const isAllowedToCreateLocale = useIsAllowedTo("createLocale") + 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 [crowdinTargetLanguages, setCrowdinTargetLanguages] = useState<{ id: string; name: string }[]>([]) + const [crowdinTargetLanguageCount, setCrowdinTargetLanguageCount] = useState(0) + const [localesLoading, setLocalesLoading] = useState(false) + const [operationInProgress, setOperationInProgress] = useState(false) + const [exportProgress, setExportProgress] = useState<{ current: number; total: number } | null>(null) + const [importConfirmation, setImportConfirmation] = useState(null) + const validatingAccessTokenRef = useRef(false) + + useDynamicPluginHeight({ width: 280 }) + + // 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] + ) + + // Export: all Framer locales are available. Import: all Crowdin target locales are available. + const effectiveAvailableLocaleIds = mode === "export" ? locales.map(locale => locale.id) : availableLocaleIds + const localeIdsToSync = selectedLocaleIds === ALL_LOCALES_ID ? effectiveAvailableLocaleIds : selectedLocaleIds + + // For import: show all Crowdin target languages (Framer name when locale exists, else Crowdin name). Use Crowdin code as id. + const importDisplayLocales: ImportDisplayLocale[] = crowdinTargetLanguages.map(ct => ({ + id: ct.id, + name: locales.find(l => l.code === ct.id)?.name ?? ct.name, + code: ct.id, + })) + const configurationLocales: readonly (Locale | ImportDisplayLocale)[] = + mode === "import" ? importDisplayLocales : locales + + // When createLocale not allowed, new (non-Framer) locales cannot be selected in import mode. + const disabledImportLocaleIds = + mode === "import" && !isAllowedToCreateLocale + ? availableLocaleIds.filter(id => !locales.some(l => l.code === id)) + : [] + + // When switching to import, selection is by Crowdin code; if current selection is invalid, reset. When createLocale not allowed, default to existing locales only. + useEffect(() => { + if ( + mode === "import" && + availableLocaleIds.length > 0 && + selectedLocaleIds !== ALL_LOCALES_ID && + !selectedLocaleIds.every(id => availableLocaleIds.includes(id)) + ) { + const targetIds = isAllowedToCreateLocale + ? availableLocaleIds + : availableLocaleIds.filter(id => locales.some(l => l.code === id)) + setSelectedLocaleIds(targetIds) + } + }, [mode, availableLocaleIds, selectedLocaleIds, isAllowedToCreateLocale, locales]) + + // ------------------ 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 valuesByLocale: Record> = {} + const allLocalesForParse: Locale[] = [ + ...locales, + ...crowdinTargetLanguages + .filter(ct => !locales.some(l => l.code === ct.id)) + .map(ct => ({ id: ct.id, name: ct.name, code: ct.id, slug: ct.id })), + ] + + try { + for (const code of localeIdsToSync) { + const exportRes = await client.translations.exportProjectTranslation(projectId, { + targetLanguageId: code, + format: "xliff", + }) + const url = exportRes.data.url + if (!url) { + framer.notify(`Crowdin export URL not found for ${code}`, { + variant: "error", + }) + continue + } + const resp = await fetch(url) + const fileContent = await resp.text() + const { xliff, targetLocale } = parseXliff(fileContent, allLocalesForParse) + const valuesBySource = await createValuesBySourceFromXliff(xliff, targetLocale) + if (!valuesBySource) continue + valuesByLocale[code] = valuesBySource + } + + if (Object.keys(valuesByLocale).length === 0) { + framer.notify("No translations could be fetched from Crowdin", { + variant: "error", + }) + return + } + + const withNewFlag = localeIdsToSync + .filter(code => code in valuesByLocale) + .map(code => { + const fr = locales.find(l => l.code === code) + const ct = crowdinTargetLanguages.find(c => c.id === code) + if (!ct) return null + const locale = fr ?? { id: ct.id, name: ct.name, code: ct.id, slug: ct.id } + return { locale, isNew: !fr } + }) + .filter((x): x is { locale: Locale; isNew: boolean } => x != null) + const orderedLocales: Locale[] = [...withNewFlag] + .sort((a, b) => (a.isNew === b.isNew ? 0 : a.isNew ? -1 : 1)) + .map(x => x.locale) + setImportConfirmation({ + locales: orderedLocales, + valuesByLocale, + currentIndex: 0, + confirmedLocaleIds: new Set(), + localesToCreate: [], + }) + } 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) + } + } + + async function finishImportConfirmation(state: ImportConfirmationState) { + if (state.confirmedLocaleIds.size === 0) { + framer.notify("No locales selected for import", { variant: "info" }) + setImportConfirmation(null) + return + } + let createdIdsByCode: Record | undefined + if (state.localesToCreate.length > 0 && isAllowedToCreateLocale) { + setOperationInProgress(true) + try { + createdIdsByCode = {} + for (const code of state.localesToCreate) { + const { language, region } = parseLocaleCode(code) + const crowdinName = crowdinTargetLanguages.find(ct => ct.id === code)?.name + const created = await framer.createLocale({ + language, + ...(region && { region }), + ...(crowdinName && { name: crowdinName }), + draft: true, + }) + createdIdsByCode[code] = created.id + } + } catch (error) { + console.error("Error creating locales:", error) + framer.notify( + `Failed to create locale(s): ${error instanceof Error ? error.message : "Unknown error"}`, + { variant: "error", durationMs: 10000 } + ) + setOperationInProgress(false) + return + } + setOperationInProgress(false) + } + applyConfirmedImport(state, createdIdsByCode) + } + + function applyConfirmedImport(state: ImportConfirmationState, createdIdsByCode?: Record) { + if (state.confirmedLocaleIds.size === 0) { + framer.notify("No locales selected for import", { variant: "info" }) + setImportConfirmation(null) + return + } + + const mergedValuesBySource: NonNullable = {} + let appliedCount = 0 + for (const confirmedLocaleId of state.confirmedLocaleIds) { + const code = locales.find(l => l.id === confirmedLocaleId)?.code ?? confirmedLocaleId + const localeValues = state.valuesByLocale[code] + if (!localeValues) continue + const framerLocale = createdIdsByCode?.[code] + ? { id: createdIdsByCode[code], code, name: code, slug: code } + : locales.find(l => l.code === code) + if (!framerLocale) continue + appliedCount++ + for (const sourceId of Object.keys(localeValues)) { + const localeData = localeValues[sourceId] + if (localeData) { + const val = Object.values(localeData)[0] + if (val) { + mergedValuesBySource[sourceId] ??= {} + mergedValuesBySource[sourceId][framerLocale.id] = val + } + } + } + } + + 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 = appliedCount + 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)) + + // Show progress bar if exporting multiple locales + if (localesToSync.length > 1) { + setExportProgress({ current: 0, total: localesToSync.length }) + } + + 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) + + const limit = pLimit(CROWDIN_EXPORT_CONCURRENCY) + const exportLocale = async (locale: Locale) => { + 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", + }) + return + } + const storageData = (await storageRes.json()) as CrowdinStorageResponse + const storageId = storageData.data.id + + const uploadResult = await updateTranslation(projectId, storageId, fileId, accessToken, locale) + if (!uploadResult.ok) { + framer.notify( + `Crowdin upload failed for ${locale.code}: ${uploadResult.errorMessage ?? "Unknown error"}`, + { variant: "error" } + ) + } + + setExportProgress(prev => (prev ? { ...prev, current: Math.min(prev.current + 1, prev.total) } : prev)) + } + + await Promise.all(localesToSync.map(locale => limit(() => exportLocale(locale)))) + + 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) + setExportProgress(null) + } + } + + 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([]) + setCrowdinTargetLanguageCount(0) + setSelectedLocaleIds([]) + setLocalesLoading(false) + return + } + + setAvailableLocaleIds([]) + setSelectedLocaleIds([]) + setCrowdinTargetLanguages([]) + setLocalesLoading(true) + + let cancelled = false + const task = async () => { + let targetLanguages: { id: string; name: string }[] = [] + try { + const list = await getProjectTargetLanguages(projectId, accessToken) + if (!cancelled) { + targetLanguages = list + setCrowdinTargetLanguageCount(list.length) + } + } catch { + if (!cancelled) { + targetLanguages = [] + setCrowdinTargetLanguageCount(0) + } + } finally { + if (!cancelled) { + setLocalesLoading(false) + } + } + + if (!cancelled) { + const targetLanguageIds = targetLanguages.map(t => t.id) + setCrowdinTargetLanguages(targetLanguages) + setAvailableLocaleIds(targetLanguageIds) + const exportAvailableLocaleIds = locales + .filter(locale => targetLanguageIds.includes(locale.code)) + .map(locale => locale.id) + setSelectedLocaleIds(exportAvailableLocaleIds) + } + } + 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 + const remainingExistingLocaleCount = confirmLocales + .slice(currentIndex) + .filter(loc => locales.some(l => l.code === loc.code)).length + const isNewLocale = currentLocale != null && !locales.some(l => l.code === currentLocale.code) + + const goToNext = () => { + const nextIndex = currentIndex + 1 + if (nextIndex >= confirmLocales.length) { + void finishImportConfirmation({ ...importConfirmation, currentIndex: nextIndex }) + } else { + setImportConfirmation({ ...importConfirmation, currentIndex: nextIndex }) + } + } + + if (isNewLocale) { + return ( + { + const nextConfirmed = new Set(confirmedLocaleIds) + nextConfirmed.add(currentLocale.id) + const nextLocalesToCreate = [...importConfirmation.localesToCreate, currentLocale.code] + const nextIndex = currentIndex + 1 + const nextState: ImportConfirmationState = { + ...importConfirmation, + currentIndex: nextIndex, + confirmedLocaleIds: nextConfirmed, + localesToCreate: nextLocalesToCreate, + } + if (nextIndex >= confirmLocales.length) { + void finishImportConfirmation(nextState) + } else { + setImportConfirmation(nextState) + } + }} + /> + ) + } + + return ( + { + const nextConfirmed = new Set(confirmedLocaleIds) + if (currentLocale) nextConfirmed.add(currentLocale.id) + const nextIndex = currentIndex + 1 + if (nextIndex >= confirmLocales.length) { + void finishImportConfirmation({ + ...importConfirmation, + currentIndex: nextIndex, + confirmedLocaleIds: nextConfirmed, + }) + } else { + setImportConfirmation({ + ...importConfirmation, + currentIndex: nextIndex, + confirmedLocaleIds: nextConfirmed, + }) + } + }} + updateAll={() => { + const nextConfirmed = new Set(confirmedLocaleIds) + for (let i = currentIndex; i < confirmLocales.length; i++) { + const loc = confirmLocales[i] + if (loc) nextConfirmed.add(loc.id) + } + void finishImportConfirmation({ + ...importConfirmation, + currentIndex: confirmLocales.length, + confirmedLocaleIds: nextConfirmed, + }) + }} + /> + ) + } + + if (mode === "export" && exportProgress && exportProgress.total > 1) { + return + } + + return ( + + ) +} + +function Home({ setMode, locales }: { setMode: (mode: "export" | "import") => void; locales: readonly Locale[] }) { + const isAllowedToSetLocalizationData = useIsAllowedTo("setLocalizationData") + const hasLocales = locales.length > 0 + + return ( +
+
+
+
+ Crowdin Logo +
+

Translate with Crowdin

+

Enter the token from Crowdin and export or import locales.

+
+
+ + +
+
+ ) +} + +function Configuration({ + mode, + locales, + availableLocaleIds, + disabledImportLocaleIds = [], + crowdinTargetLanguageCount, + localesLoading, + accessToken, + accessTokenState, + projectId, + projectList, + validateAccessToken, + setProjectId, + selectedLocaleIds, + setSelectedLocaleIds, + operationInProgress, + onSubmit, +}: { + mode: "export" | "import" + locales: readonly (Locale | ImportDisplayLocale)[] + availableLocaleIds: string[] + disabledImportLocaleIds?: string[] + crowdinTargetLanguageCount: number + 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 selectableLocaleIds = + disabledImportLocaleIds.length > 0 + ? availableLocaleIds.filter(id => !disabledImportLocaleIds.includes(id)) + : availableLocaleIds + const isAllSelectableSelected = + selectableLocaleIds.length > 0 && + selectableLocaleIds.every(id => selectedLocaleIds.includes(id)) && + (selectedLocaleIds === ALL_LOCALES_ID || selectedLocaleIds.length === selectableLocaleIds.length) + const hasSelectedLocales = selectedLocaleIds === ALL_LOCALES_ID || selectedLocaleIds.length > 0 + const hasLocalesForMode = mode === "export" ? availableLocaleIds.length > 0 : true + const localesDisabled = !accessToken || !projectId + const canPerformAction = + accessToken && + projectId && + hasLocalesForMode && + hasSelectedLocales && + (mode === "import" ? isAllowedToSetLocalizationData : true) + const accessTokenValueHasChanged = accessTokenValue !== accessToken + const projectName = projectList.find(p => p.id === projectId)?.name + + useEffect(() => { + setAccessTokenValue(accessToken) + }, [accessToken]) + + function onProjectButtonClick(e: React.MouseEvent) { + const rect = e.currentTarget.getBoundingClientRect() + void framer.showContextMenu( + projectList.map(p => ({ + label: p.name, + checked: p.id === projectId, + onAction: () => { + setProjectId(p.id) + }, + })), + { + location: { + x: rect.right - 4, + y: rect.bottom + 4, + }, + width: DROPDOWN_MENU_WIDTH, + placement: "bottom-left", + } + ) + } + + function onLocaleButtonClick(e: React.MouseEvent, localeId: string | null) { + const rect = e.currentTarget.getBoundingClientRect() + const localeDisabled = (id: string) => disabledImportLocaleIds.includes(id) + + void framer.showContextMenu( + [ + { + label: "All Locales", + checked: + disabledImportLocaleIds.length > 0 + ? isAllSelectableSelected + : selectedLocaleIds === ALL_LOCALES_ID, + onAction: () => { + if (disabledImportLocaleIds.length > 0) { + setSelectedLocaleIds(isAllSelectableSelected ? [] : [...selectableLocaleIds]) + } else { + 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: + !localeDisabled(locale.id) && + 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: DROPDOWN_MENU_WIDTH, + 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 && ( +
+ +
+ )} +
+ + + + + + {localesDisabled || localesLoading || availableLocaleIds.length === 0 ? ( + + ) : selectedLocaleIds === ALL_LOCALES_ID ? ( + + ) : ( +
+ {selectedLocaleIds.map(id => ( + + ))} + {selectedLocaleIds.length < availableLocaleIds.length && ( + + )} +
+ )} +
+
+
+ {accessToken && projectId !== 0 && availableLocaleIds.length === 0 ? ( +
+ + {crowdinTargetLanguageCount === 0 ? "No locales found in Crowdin" : "No matching locales in Framer"} +
+ ) : ( + + )} +
+ ) +} + +function PropertyControl({ label, children }: { label: string; children: React.ReactNode | React.ReactNode[] }) { + return ( +
+

{label}

+
{children}
+
+ ) +} diff --git a/plugins/crowdin/src/api-types.ts b/plugins/crowdin/src/api-types.ts new file mode 100644 index 000000000..e4e71485f --- /dev/null +++ b/plugins/crowdin/src/api-types.ts @@ -0,0 +1,64 @@ +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 ProjectsSchema = v.object({ + data: v.nullable(ProjectSchema), +}) + +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/components/Flag.tsx b/plugins/crowdin/src/components/Flag.tsx new file mode 100644 index 000000000..eebbe512d --- /dev/null +++ b/plugins/crowdin/src/components/Flag.tsx @@ -0,0 +1,39 @@ +import { useState } from "react" +import { regionToFlagEmoji } from "../regionFlags" +import { parseLocaleCode } from "../utils" + +export function Flag({ code }: { code: string }) { + const { language, region } = parseLocaleCode(code) + const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading") + + if (region && status !== "error") { + const flagEmoji = regionToFlagEmoji[region] + if (flagEmoji) { + return ( + <> + {flagEmoji} { + setStatus("loaded") + }} + onError={() => { + setStatus("error") + }} + /> + {status === "loading" &&
} + + ) + } + } + + return
{language}
+} + +function emojiToURL(emoji: string): string { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + const codepoint = [...emoji].map(char => (char.codePointAt(0) ?? 0).toString(16)).join("-") + return `https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/72x72/${codepoint}.png` +} diff --git a/plugins/crowdin/src/components/Icons.tsx b/plugins/crowdin/src/components/Icons.tsx new file mode 100644 index 000000000..5945ba95d --- /dev/null +++ b/plugins/crowdin/src/components/Icons.tsx @@ -0,0 +1,60 @@ +export function XIcon() { + return ( + + + + + + ) +} + +export function ChevronDownIcon() { + return ( + + + + ) +} + +export function CheckIcon() { + return ( + + + + ) +} + +export function LinkArrowIcon() { + return ( + + + + ) +} + +export function InfoIcon() { + return ( + + + + ) +} diff --git a/plugins/crowdin/src/components/Modals.tsx b/plugins/crowdin/src/components/Modals.tsx new file mode 100644 index 000000000..ceae88786 --- /dev/null +++ b/plugins/crowdin/src/components/Modals.tsx @@ -0,0 +1,133 @@ +import { useState } from "react" + +interface ModalProps { + title: string + currentStep?: number + totalSteps?: number + description: React.ReactNode + content?: React.ReactNode + primaryButtonText: string + onSkipClick: () => void + onPrimaryClick: () => void +} + +interface ConfirmationModalProps { + localeName: string + currentStep: number + totalSteps: number + remainingLocaleCount: number + /** Count of remaining locales that already exist in Framer (not new). Used to show "All" checkbox only when > 1. */ + remainingExistingLocaleCount: number + skip: () => void + update: () => void + updateAll: () => void +} + +interface CreateLocaleModalProps { + localeCode: string + currentStep?: number + totalSteps?: number + onSkip: () => void + onAdd: () => void +} + +export function ConfirmationModal({ + localeName, + currentStep, + totalSteps, + remainingLocaleCount, + remainingExistingLocaleCount, + skip, + update, + updateAll, +}: ConfirmationModalProps) { + const [allChecked, setAllChecked] = useState(false) + + return ( + + By importing you are going to modify the existing locale “{localeName}”. + + } + content={ + remainingExistingLocaleCount > 1 ? ( + + ) : undefined + } + primaryButtonText="Update" + onSkipClick={skip} + onPrimaryClick={allChecked ? updateAll : update} + /> + ) +} + +export function CreateLocaleModal({ localeCode, currentStep, totalSteps, onSkip, onAdd }: CreateLocaleModalProps) { + return ( + + No locale with code “{localeCode}” found. +
+ Add a new one or skip this locale. + + } + primaryButtonText="Add" + onSkipClick={onSkip} + onPrimaryClick={onAdd} + /> + ) +} + +export function Modal({ + title, + currentStep, + totalSteps, + description, + content, + primaryButtonText, + onSkipClick, + onPrimaryClick, +}: ModalProps) { + const showStepIndicator = currentStep != null && totalSteps != null && totalSteps > 1 + + return ( +
+
+
+

{title}

+ {showStepIndicator && ( + + {currentStep} / {totalSteps} + + )} +
+
+

{description}

+ {content} +
+ + +
+
+ ) +} diff --git a/plugins/crowdin/src/components/Progress.tsx b/plugins/crowdin/src/components/Progress.tsx new file mode 100644 index 000000000..6284b390d --- /dev/null +++ b/plugins/crowdin/src/components/Progress.tsx @@ -0,0 +1,35 @@ +import { animate, motion, useMotionValue, useTransform } from "motion/react" +import { useEffect } from "react" + +export function Progress({ current, total }: { current: number; total: number }) { + const percent = (current / total) * 100 + const formatter = new Intl.NumberFormat("en-US") + const formattedCurrent = formatter.format(current) + const formattedTotal = formatter.format(total) + + const animatedValue = useMotionValue(0) + + useEffect(() => { + void animate(animatedValue, percent, { type: "tween" }) + }, [percent, animatedValue]) + + return ( +
+
+ {Math.round(percent)}% + + {formattedCurrent} / {formattedTotal} + +
+
+ `${animatedValue.get()}%`), + }} + /> +
+

Exporting… please keep the plugin open until the process is complete.

+
+ ) +} diff --git a/plugins/crowdin/src/crowdin.ts b/plugins/crowdin/src/crowdin.ts new file mode 100644 index 000000000..da3041400 --- /dev/null +++ b/plugins/crowdin/src/crowdin.ts @@ -0,0 +1,52 @@ +import { ProjectsGroups, Translations } from "@crowdin/crowdin-api-client" +import { framer } from "framer-plugin" + +export interface Project { + readonly id: number + readonly name: string +} + +export interface CrowdinStorageResponse { + data: { + id: number + } +} + +export function createCrowdinClient(token: string) { + return { + projects: new ProjectsGroups({ token }), + translations: new Translations({ token }), + } +} + +// Returns a list of projects or null if the access token is invalid +export 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) + return { isValid: false, projects: null } + } + } else { + return { isValid: false, projects: null } + } +} diff --git a/plugins/crowdin/src/main.tsx b/plugins/crowdin/src/main.tsx new file mode 100644 index 000000000..7147f1d6f --- /dev/null +++ b/plugins/crowdin/src/main.tsx @@ -0,0 +1,17 @@ +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()]) + +ReactDOM.createRoot(root).render( + + + +) diff --git a/plugins/crowdin/src/regionFlags.ts b/plugins/crowdin/src/regionFlags.ts new file mode 100644 index 000000000..512d85765 --- /dev/null +++ b/plugins/crowdin/src/regionFlags.ts @@ -0,0 +1,168 @@ +export const regionToFlagEmoji: Record = { + AE: "🇦🇪", + AF: "🇦🇫", + AL: "🇦🇱", + AM: "🇦🇲", + AR: "🇦🇷", + AS: "🇦🇸", + AT: "🇦🇹", + AU: "🇦🇺", + BA: "🇧🇦", + BD: "🇧🇩", + BE: "🇧🇪", + BF: "🇧🇫", + BG: "🇧🇬", + BH: "🇧🇭", + BI: "🇧🇮", + BJ: "🇧🇯", + BL: "🇧🇱", + BN: "🇧🇳", + BO: "🇧🇴", + BR: "🇧🇷", + BW: "🇧🇼", + BY: "🇧🇾", + BZ: "🇧🇿", + CA: "🇨🇦", + CD: "🇨🇩", + CF: "🇨🇫", + CG: "🇨🇬", + CH: "🇨🇭", + CI: "🇨🇮", + CL: "🇨🇱", + CM: "🇨🇲", + CO: "🇨🇴", + CR: "🇨🇷", + CV: "🇨🇻", + CZ: "🇨🇿", + DE: "🇩🇪", + DJ: "🇩🇯", + DK: "🇩🇰", + DO: "🇩🇴", + DZ: "🇩🇿", + EC: "🇪🇨", + EE: "🇪🇪", + EG: "🇪🇬", + ER: "🇪🇷", + ES: "🇪🇸", + ET: "🇪🇹", + FI: "🇫🇮", + FO: "🇫🇴", + FR: "🇫🇷", + GA: "🇬🇦", + GB: "🇬🇧", + GE: "🇬🇪", + GH: "🇬🇭", + GL: "🇬🇱", + GN: "🇬🇳", + GP: "🇬🇵", + GQ: "🇬🇶", + GR: "🇬🇷", + GT: "🇬🇹", + GU: "🇬🇺", + GW: "🇬🇼", + HK: "🇭🇰", + HN: "🇭🇳", + HR: "🇭🇷", + HU: "🇭🇺", + ID: "🇮🇩", + IE: "🇮🇪", + IL: "🇮🇱", + IN: "🇮🇳", + IQ: "🇮🇶", + IR: "🇮🇷", + IS: "🇮🇸", + IT: "🇮🇹", + JM: "🇯🇲", + JO: "🇯🇴", + JP: "🇯🇵", + KE: "🇰🇪", + KH: "🇰🇭", + KM: "🇰🇲", + KR: "🇰🇷", + KW: "🇰🇼", + LB: "🇱🇧", + LI: "🇱🇮", + LK: "🇱🇰", + LT: "🇱🇹", + LU: "🇱🇺", + LV: "🇱🇻", + LY: "🇱🇾", + MA: "🇲🇦", + MC: "🇲🇨", + MD: "🇲🇩", + MF: "🇲🇫", + MG: "🇲🇬", + MH: "🇲🇭", + MK: "🇲🇰", + ML: "🇲🇱", + MM: "🇲🇲", + MP: "🇲🇵", + MQ: "🇲🇶", + MT: "🇲🇹", + MU: "🇲🇺", + MX: "🇲🇽", + MY: "🇲🇾", + MZ: "🇲🇿", + NA: "🇳🇦", + NE: "🇳🇪", + NG: "🇳🇬", + NI: "🇳🇮", + NL: "🇳🇱", + NO: "🇳🇴", + NP: "🇳🇵", + NZ: "🇳🇿", + OM: "🇴🇲", + PA: "🇵🇦", + PE: "🇵🇪", + PH: "🇵🇭", + PK: "🇵🇰", + PL: "🇵🇱", + PR: "🇵🇷", + PT: "🇵🇹", + PY: "🇵🇾", + QA: "🇶🇦", + RE: "🇷🇪", + RO: "🇷🇴", + RU: "🇷🇺", + RW: "🇷🇼", + SA: "🇸🇦", + SD: "🇸🇩", + SE: "🇸🇪", + SG: "🇸🇬", + SI: "🇸🇮", + SK: "🇸🇰", + SN: "🇸🇳", + SO: "🇸🇴", + SV: "🇸🇻", + SY: "🇸🇾", + TD: "🇹🇩", + TG: "🇹🇬", + TH: "🇹🇭", + TN: "🇹🇳", + TO: "🇹🇴", + TR: "🇹🇷", + TT: "🇹🇹", + TZ: "🇹🇿", + UA: "🇺🇦", + UG: "🇺🇬", + UM: "🇺🇲", + US: "🇺🇸", + UY: "🇺🇾", + VE: "🇻🇪", + VI: "🇻🇮", + VN: "🇻🇳", + YE: "🇾🇪", + ZA: "🇿🇦", + ZM: "🇿🇲", + ZW: "🇿🇼", + AZ: null, + CN: null, + CY: null, + KZ: null, + ME: null, + MO: null, + RS: null, + TW: null, + UZ: null, + 419: null, +} diff --git a/plugins/crowdin/src/useDynamicPluginHeight.tsx b/plugins/crowdin/src/useDynamicPluginHeight.tsx new file mode 100644 index 000000000..114510195 --- /dev/null +++ b/plugins/crowdin/src/useDynamicPluginHeight.tsx @@ -0,0 +1,35 @@ +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 updateHeight = () => { + const height = root.offsetHeight + 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(root) + + // Cleanup + return () => { + resizeObserver.disconnect() + } + }, [options]) +} diff --git a/plugins/crowdin/src/utils.ts b/plugins/crowdin/src/utils.ts new file mode 100644 index 000000000..02ba2fc48 --- /dev/null +++ b/plugins/crowdin/src/utils.ts @@ -0,0 +1,13 @@ +/** Parse BCP 47–style code (e.g. "en-US", "zh-Hans") into language and optional region for createLocale. */ +export function parseLocaleCode(code: string): { language: string; region?: string } { + const parts = code.split("-") + if (parts.length <= 1) return { language: code } + const last = parts[parts.length - 1] + if (last && last.length === 2 && /^[a-zA-Z]{2}$/.test(last)) { + return { + language: parts.slice(0, -1).join("-"), + region: last, + } + } + return { language: code } +} 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..427b0242d --- /dev/null +++ b/plugins/crowdin/src/xliff.ts @@ -0,0 +1,404 @@ +import { framer, type Locale, type LocalizationData, type LocalizationGroup } from "framer-plugin" +import * as v from "valibot" +import { CreateFileResponseSchema, FileResponseSchema, LanguagesResponseSchema, ProjectsSchema } from "./api-types" + +const API_URL = "https://api.crowdin.com/api/v2" +const IS_LOCALHOST = window.location.hostname === "localhost" + +// -------------------- 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 +} + +function escapeXml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +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 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) { + if (IS_LOCALHOST) { + 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) { + if (IS_LOCALHOST) { + 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 getProjectTargetLanguages( + projectId: number, + accessToken: string +): Promise<{ id: string; name: string }[]> { + 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 => ({ id: l.id, name: l.name })) +} + +export async function getProjectTargetLanguageIds(projectId: number, accessToken: string): Promise { + const targetLanguages = await getProjectTargetLanguages(projectId, accessToken) + return 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)) { + if (IS_LOCALHOST) { + 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<{ ok: boolean; errorMessage?: string }> { + await checkAndCreateLanguage(projectId, activeLocale, accessToken) + + // Use the new translations imports API (the old POST .../translations/{languageId} is deprecated) + const importRes = await fetch(`${API_URL}/projects/${projectId}/translations/imports`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + storageId, + fileId, + languageIds: [activeLocale.code], + importEqSuggestions: true, + autoApproveImported: true, + }), + }) + + if (!importRes.ok) { + const errText = await importRes.text() + return { ok: false, errorMessage: errText } + } + + const importData = (await importRes.json()) as { data?: { identifier?: string } } + const importId = importData.data?.identifier + if (!importId) { + return { ok: false, errorMessage: "Import response missing identifier" } + } + + // Poll until import completes (async operation) + const maxAttempts = 60 + const pollIntervalMs = 1000 + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)) + + const statusRes = await fetch(`${API_URL}/projects/${projectId}/translations/imports/${importId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!statusRes.ok) { + return { ok: false, errorMessage: await statusRes.text() } + } + + const statusData = (await statusRes.json()) as { data?: { status?: string } } + const status = statusData.data?.status + + if (status === "finished") { + return { ok: true } + } + if (status === "failed") { + const reportRes = await fetch(`${API_URL}/projects/${projectId}/translations/imports/${importId}/report`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + const reportText = reportRes.ok ? await reportRes.text() : "" + return { ok: false, errorMessage: `Import failed${reportText ? `: ${reportText}` : ""}` } + } + // status is "created" or "inProgress" – keep polling + } + + return { ok: false, errorMessage: "Import timed out" } +} + +/** Extract human-readable messages from Crowdin API error response shape: { errors: [{ error: { errors: [{ message }] } }] } */ +function extractCrowdinErrorMessages(body: unknown): string | null { + if (typeof body !== "object" || body === null || !("errors" in body)) return null + const errors = (body as { errors: unknown[] }).errors + if (!Array.isArray(errors)) return null + const messages: string[] = [] + for (const item of errors) { + if (typeof item !== "object" || item === null || !("error" in item)) continue + const err = (item as { error: unknown }).error + if (typeof err !== "object" || err === null || !("errors" in err)) continue + const errList = (err as { errors: unknown[] }).errors + if (!Array.isArray(errList)) continue + for (const e of errList) { + if ( + typeof e === "object" && + e !== null && + "message" in e && + typeof (e as { message: unknown }).message === "string" + ) { + messages.push((e as { message: string }).message) + } + } + } + return messages.length > 0 ? messages.join(" ") : null +} + +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() + + if (!fileRes.ok) { + const messages = extractCrowdinErrorMessages(fileData) + throw new Error(messages ?? `Crowdin API error (${fileRes.status})`) + } + + const parsed = v.parse(CreateFileResponseSchema, fileData) + return parsed.data.id + } catch (err) { + console.error("Error in createFile:", err) + throw err + } +} 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 99e1f9712..c784cdf13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -223,6 +223,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" @@ -4061,7 +4070,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: @@ -4574,6 +4583,23 @@ __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.11.0-alpha.4" + motion: "npm:^12.29.2" + p-limit: "npm:^7.3.0" + 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" @@ -5710,6 +5736,16 @@ __metadata: languageName: node linkType: hard +"framer-plugin@npm:3.11.0-alpha.4": + version: 3.11.0-alpha.4 + resolution: "framer-plugin@npm:3.11.0-alpha.4" + peerDependencies: + react: ^18.2.0 + react-dom: ^18.2.0 + checksum: 10/9662eda12ea8bb5336acbcff57e2efb7bd97457634531deaca045e0ac6d11008ff3ebcadd2bd8b21a1fe9340144925dd354649b1b99355752412b7ff2c3d6ed1 + languageName: node + linkType: hard + "framer-plugin@npm:3.7.0-alpha.0": version: 3.7.0-alpha.0 resolution: "framer-plugin@npm:3.7.0-alpha.0" @@ -7317,6 +7353,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^7.3.0": + version: 7.3.0 + resolution: "p-limit@npm:7.3.0" + dependencies: + yocto-queue: "npm:^1.2.1" + checksum: 10/bd3f3487ec84401e2cbf243122eef11813edacb621a27808e60a425646d0e75a79514acc2c01e39c41911550dbae5ef0f0ab01caa61cfc1c541cd17a19e8f01b + languageName: node + linkType: hard + "p-locate@npm:^5.0.0": version: 5.0.0 resolution: "p-locate@npm:5.0.0" @@ -9910,6 +9955,13 @@ __metadata: languageName: node linkType: hard +"yocto-queue@npm:^1.2.1": + version: 1.2.2 + resolution: "yocto-queue@npm:1.2.2" + checksum: 10/92dd9880c324dbc94ff4b677b7d350ba8d835619062b7102f577add7a59ab4d87f40edc5a03d77d369dfa9d11175b1b2ec4a06a6f8a5d8ce5d1306713f66ee41 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0, zwitch@npm:^2.0.4": version: 2.0.4 resolution: "zwitch@npm:2.0.4"