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
+
+
\ 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 (
+
+
+
+
+

+
+
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 (
+
+ )
+}
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 (
+ <>
+
{
+ 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"