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

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