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-motion-npm-12.34.0-46af42793d-373a17ee13.zip b/.yarn/cache/framer-motion-npm-12.34.0-46af42793d-373a17ee13.zip
new file mode 100644
index 000000000..a83a19506
Binary files /dev/null and b/.yarn/cache/framer-motion-npm-12.34.0-46af42793d-373a17ee13.zip differ
diff --git a/.yarn/cache/motion-dom-npm-12.34.0-1442314eb4-017048ea7c.zip b/.yarn/cache/motion-dom-npm-12.34.0-1442314eb4-017048ea7c.zip
new file mode 100644
index 000000000..5244dfe5b
Binary files /dev/null and b/.yarn/cache/motion-dom-npm-12.34.0-1442314eb4-017048ea7c.zip differ
diff --git a/.yarn/cache/motion-npm-12.34.0-50a70e7ca5-fff9117a6a.zip b/.yarn/cache/motion-npm-12.34.0-50a70e7ca5-fff9117a6a.zip
new file mode 100644
index 000000000..463f44fdd
Binary files /dev/null and b/.yarn/cache/motion-npm-12.34.0-50a70e7ca5-fff9117a6a.zip differ
diff --git a/.yarn/cache/motion-utils-npm-12.29.2-868aec7208-ae5f9be58c.zip b/.yarn/cache/motion-utils-npm-12.29.2-868aec7208-ae5f9be58c.zip
new file mode 100644
index 000000000..84bc8b54b
Binary files /dev/null and b/.yarn/cache/motion-utils-npm-12.29.2-868aec7208-ae5f9be58c.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
+
+
\ 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..61b7d39c1
--- /dev/null
+++ b/plugins/crowdin/package.json
@@ -0,0 +1,28 @@
+{
+ "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",
+ "motion": "^12.29.2",
+ "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..bf8a71fd0
--- /dev/null
+++ b/plugins/crowdin/src/App.css
@@ -0,0 +1,302 @@
+/* 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: 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: 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 > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.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 {
+ 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);
+}
+
+.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);
+}
diff --git a/plugins/crowdin/src/App.tsx b/plugins/crowdin/src/App.tsx
new file mode 100644
index 000000000..a3da10ef1
--- /dev/null
+++ b/plugins/crowdin/src/App.tsx
@@ -0,0 +1,838 @@
+import cx from "classnames"
+import { framer, type Locale, type LocalizationData, useIsAllowedTo } from "framer-plugin"
+import { useCallback, useEffect, useRef, useState } from "react"
+import "./App.css"
+import { ConfirmationModal } from "./ConfirmationModal"
+import {
+ type CrowdinStorageResponse,
+ createCrowdinClient,
+ type Project,
+ validateAccessTokenAndGetProjects,
+} from "./crowdin"
+import { CheckIcon, ChevronDownIcon, InfoIcon, LinkArrowIcon, XIcon } from "./Icons"
+import { Progress } from "./Progress"
+import { useDynamicPluginHeight } from "./useDynamicPluginHeight"
+import {
+ createValuesBySourceFromXliff,
+ ensureSourceFile,
+ generateXliff,
+ getProjectTargetLanguageIds,
+ parseXliff,
+ updateTranslation,
+ uploadStorage,
+} from "./xliff"
+
+const PLUGIN_UI_OPTIONS = { 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",
+}
+
+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 [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(PLUGIN_UI_OPTIONS)
+
+ // 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: only locales in both Framer and Crowdin.
+ const effectiveAvailableLocaleIds = mode === "export" ? locales.map(locale => locale.id) : availableLocaleIds
+ const localeIdsToSync = selectedLocaleIds === ALL_LOCALES_ID ? effectiveAvailableLocaleIds : 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))
+
+ // 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)
+
+ 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" })
+ }
+
+ setExportProgress(prev => (prev ? { ...prev, current: Math.min(prev.current + 1, prev.total) } : prev))
+ }
+
+ 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([])
+ setLocalesLoading(true)
+
+ let cancelled = false
+ const task = async () => {
+ let targetLanguageIds: string[] = []
+ try {
+ const ids: string[] = await getProjectTargetLanguageIds(projectId, accessToken)
+ if (!cancelled) {
+ targetLanguageIds = ids
+ setCrowdinTargetLanguageCount(ids.length)
+ }
+ } catch {
+ if (!cancelled) {
+ targetLanguageIds = []
+ setCrowdinTargetLanguageCount(0)
+ }
+ } finally {
+ if (!cancelled) {
+ setLocalesLoading(false)
+ }
+ }
+
+ if (!cancelled) {
+ // 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 })
+ }
+ }}
+ update={() => {
+ 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,
+ })
+ }
+ }}
+ updateAll={() => {
+ 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,
+ })
+ }}
+ />
+ )
+ }
+
+ 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 (
+
+
+
+
+

+
+
Translate with Crowdin
+
Enter your access token from Crowdin and select a project to export Locales.
+
+
+
+
+
+
+ )
+}
+
+function Configuration({
+ mode,
+ locales,
+ availableLocaleIds,
+ crowdinTargetLanguageCount,
+ localesLoading,
+ accessToken,
+ accessTokenState,
+ projectId,
+ projectList,
+ validateAccessToken,
+ setProjectId,
+ selectedLocaleIds,
+ setSelectedLocaleIds,
+ operationInProgress,
+ onSubmit,
+}: {
+ mode: "export" | "import"
+ locales: readonly Locale[]
+ availableLocaleIds: 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 hasSelectedLocales = selectedLocaleIds === ALL_LOCALES_ID || selectedLocaleIds.length > 0
+ const hasLocalesForMode = mode === "export" ? availableLocaleIds.length > 0 : true
+ const canPerformAction =
+ accessToken &&
+ projectId &&
+ hasLocalesForMode &&
+ 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…" : "Select…") : "Select…"}
+
+
+
+
+ ) : 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 (
+
+ )
+}
diff --git a/plugins/crowdin/src/ConfirmationModal.tsx b/plugins/crowdin/src/ConfirmationModal.tsx
new file mode 100644
index 000000000..55cd29f3a
--- /dev/null
+++ b/plugins/crowdin/src/ConfirmationModal.tsx
@@ -0,0 +1,59 @@
+import { useState } from "react"
+
+interface ConfirmationModalProps {
+ localeName: string
+ currentStep: number
+ totalSteps: number
+ remainingLocaleCount: number
+ skip: () => void
+ update: () => void
+ updateAll: () => void
+}
+
+export function ConfirmationModal({
+ localeName,
+ currentStep,
+ totalSteps,
+ remainingLocaleCount,
+ skip,
+ update,
+ updateAll,
+}: ConfirmationModalProps) {
+ const [allChecked, setAllChecked] = useState(false)
+
+ return (
+
+
+
+
Import Locale{totalSteps === 1 ? "" : "s"}
+
+ {currentStep} / {totalSteps}
+
+
+
+
+ By importing you are going to modify the existing locale “{localeName}”.
+
+ {totalSteps > 1 && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/plugins/crowdin/src/Icons.tsx b/plugins/crowdin/src/Icons.tsx
new file mode 100644
index 000000000..560bcea33
--- /dev/null
+++ b/plugins/crowdin/src/Icons.tsx
@@ -0,0 +1,59 @@
+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/Progress.tsx b/plugins/crowdin/src/Progress.tsx
new file mode 100644
index 000000000..6284b390d
--- /dev/null
+++ b/plugins/crowdin/src/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/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/crowdin.ts b/plugins/crowdin/src/crowdin.ts
new file mode 100644
index 000000000..439461e9d
--- /dev/null
+++ b/plugins/crowdin/src/crowdin.ts
@@ -0,0 +1,53 @@
+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)
+ framer.notify("Invalid access token", { variant: "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/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/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..e0f787c6d
--- /dev/null
+++ b/plugins/crowdin/src/xliff.ts
@@ -0,0 +1,389 @@
+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 } 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
+}
+
+// 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"
+ }
+}
+
+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 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 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)) {
+ 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 {
+ 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,
+ }),
+ })
+}
+
+/** 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 a54d49bcc..ae60bf28a 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"
@@ -4032,7 +4041,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:
@@ -4510,6 +4519,22 @@ __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"
+ motion: "npm:^12.29.2"
+ 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"
@@ -5554,6 +5579,28 @@ __metadata:
languageName: node
linkType: hard
+"framer-motion@npm:^12.34.0":
+ version: 12.34.0
+ resolution: "framer-motion@npm:12.34.0"
+ dependencies:
+ motion-dom: "npm:^12.34.0"
+ motion-utils: "npm:^12.29.2"
+ tslib: "npm:^2.4.0"
+ peerDependencies:
+ "@emotion/is-prop-valid": "*"
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@emotion/is-prop-valid":
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ checksum: 10/373a17ee1324671c1d6fa0223092f0da369a9d7eadd212515f0936f1a2e0140e5990044a807ad79c237f88641833ad5e231b5573b948629af894558c089861e2
+ languageName: node
+ linkType: hard
+
"framer-plugin-tools@workspace:*, framer-plugin-tools@workspace:packages/plugin-tools":
version: 0.0.0-use.local
resolution: "framer-plugin-tools@workspace:packages/plugin-tools"
@@ -5581,7 +5628,7 @@ __metadata:
languageName: node
linkType: hard
-"framer-plugin@npm:3.10.3":
+"framer-plugin@npm:3.10.3, framer-plugin@npm:^3.10.3":
version: 3.10.3
resolution: "framer-plugin@npm:3.10.3"
peerDependencies:
@@ -6635,6 +6682,15 @@ __metadata:
languageName: node
linkType: hard
+"motion-dom@npm:^12.34.0":
+ version: 12.34.0
+ resolution: "motion-dom@npm:12.34.0"
+ dependencies:
+ motion-utils: "npm:^12.29.2"
+ checksum: 10/017048ea7c53171711cf9fd2f446b7901c7ab321f1337993e9e8e26673ce188f3268be6acea32f9b8c936ca91f7fe656cecb42ae91d3043cd4a04ef6d84d2cd9
+ languageName: node
+ linkType: hard
+
"motion-utils@npm:^12.23.6":
version: 12.23.6
resolution: "motion-utils@npm:12.23.6"
@@ -6642,6 +6698,13 @@ __metadata:
languageName: node
linkType: hard
+"motion-utils@npm:^12.29.2":
+ version: 12.29.2
+ resolution: "motion-utils@npm:12.29.2"
+ checksum: 10/ae5f9be58c07939af72334894ed1a18653d724946182a718dc3d11268ef26e63804c3f16dee5a6110596d4406b539c4513822b74f86adebef9488601c34b18b7
+ languageName: node
+ linkType: hard
+
"motion@npm:^12.23.12":
version: 12.23.12
resolution: "motion@npm:12.23.12"
@@ -6663,6 +6726,27 @@ __metadata:
languageName: node
linkType: hard
+"motion@npm:^12.29.2":
+ version: 12.34.0
+ resolution: "motion@npm:12.34.0"
+ dependencies:
+ framer-motion: "npm:^12.34.0"
+ tslib: "npm:^2.4.0"
+ peerDependencies:
+ "@emotion/is-prop-valid": "*"
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@emotion/is-prop-valid":
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ checksum: 10/fff9117a6aa077dcd2d47a1c322e6ec85aa247a83c9352fee5afd09f6717cb2d53e336aa7358c47c679405872d46c89b687b9b94a26782086e3c97b25d2fedcf
+ languageName: node
+ linkType: hard
+
"mrmime@npm:^2.0.0":
version: 2.0.1
resolution: "mrmime@npm:2.0.1"