chore(i18n): add Claude-powered translation script and fill missing PO translations

- Add `packages/i18n/scripts/claude.ts` — a Bun script that translates empty PO entries via the Claude Code CLI, supporting batch mode, dry-run, optional AI context from `crowdin-context.jsonl`, and structured JSON output validation
- Add `i18n:claude` root script (`bun packages/i18n/scripts/claude.ts`) as a convenience alias
- Fill all previously empty msgstr entries across de, es, fr, it, pt PO catalogs and recompile `.ts` catalogs
- Polish README with badges (license, CI status, release, language, Crowdin localization) and minor formatting fixes
This commit is contained in:
2026-03-18 20:42:01 -04:00
parent 9b2d7caa3f
commit 7b0c07ad3c
23 changed files with 7775 additions and 7110 deletions
+10 -4
View File
@@ -1,10 +1,16 @@
<p align="center">
<a href="https://sofa.watch"><img width="72" height="72" alt="Sofa" src="https://sofa.watch/icon.svg" /></a>
<br/>
<a href="https://sofa.watch"><strong>Sofa</strong></a> — Self-hosted movie & TV tracker
<a href="https://sofa.watch"><img width="72" height="72" alt="Sofa" src="https://sofa.watch/icon.svg" /></a><br />
<a href="https://sofa.watch"><strong>Sofa</strong></a> — Self-hosted movie & TV tracker
</p>
<p align="center">
<a href="LICENSE"><img alt="GitHub License" src="https://img.shields.io/github/license/jakejarvis/sofa"></a>
<a href="https://github.com/jakejarvis/sofa/actions/workflows/docker.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/jakejarvis/sofa/docker.yml" /></a>
<a href="https://github.com/jakejarvis/sofa/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/jakejarvis/sofa"></a>
<a href="https://github.com/jakejarvis/sofa"><img alt="GitHub top language" src="https://img.shields.io/github/languages/top/jakejarvis/sofa"></a>
<a href="https://crowdin.com/project/sofa"><img alt="Crowdin" src="https://badges.crowdin.net/sofa/localized.svg" /></a>
</p>
Sofa is a self-hosted movie and TV tracker for nerds. Track what you've watched, discover what's next, and plug your data into your existing home media stack.
[Sofa](https://sofa.watch) is a self-hosted movie and TV tracker for nerds. Track what you've watched, discover what's next, and plug your data into your existing home media stack. All without anything leaving your homelab. 🍿
![Title page screenshot](screenshot.png)
+2 -2
View File
@@ -28,7 +28,7 @@
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.1",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.167.4",
"@tanstack/react-router": "1.167.5",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -54,7 +54,7 @@
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-router-devtools": "1.166.9",
"@tanstack/router-plugin": "1.166.13",
"@tanstack/router-plugin": "1.166.14",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
+24 -22
View File
@@ -10,7 +10,7 @@
"@lingui/format-po": "catalog:",
"oxfmt": "0.41.0",
"oxlint": "1.56.0",
"turbo": "2.8.18",
"turbo": "2.8.19",
"typescript": "catalog:",
},
},
@@ -156,7 +156,7 @@
"@tabler/icons-react": "3.40.0",
"@tanstack/react-hotkeys": "0.4.1",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "1.167.4",
"@tanstack/react-router": "1.167.5",
"better-auth": "catalog:",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -182,7 +182,7 @@
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-router-devtools": "1.166.9",
"@tanstack/router-plugin": "1.166.13",
"@tanstack/router-plugin": "1.166.14",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
@@ -318,7 +318,7 @@
"@orpc/contract": "1.13.8",
"@orpc/tanstack-query": "1.13.8",
"@tanstack/react-query": "5.91.0",
"@types/bun": "1.3.10",
"@types/bun": "1.3.11",
"@types/node": "25.5.0",
"@types/react": "19.2.14",
"better-auth": "1.5.5",
@@ -359,11 +359,11 @@
"@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="],
"@azure/msal-browser": ["@azure/msal-browser@4.29.1", "", { "dependencies": { "@azure/msal-common": "15.16.1" } }, "sha512-1Vrt27du1cl4QHkzLc6L4aeXqliPIDIs5l/1I4hWWMXkXccY/EznJT1+pBdoVze0azTAI8sCyq5B4cBVYG1t9w=="],
"@azure/msal-browser": ["@azure/msal-browser@4.30.0", "", { "dependencies": { "@azure/msal-common": "15.17.0" } }, "sha512-HBBKfbZkMVzzF5bofvS1cXuNHFVc+gt4/HOnCmG/0hsHuZRJvJvDg/+7nTwIpoqvJc8BQp5o23rBUfisOLxR+w=="],
"@azure/msal-common": ["@azure/msal-common@15.16.1", "", {}, "sha512-qxUG9TCl+TVSSX58onVDHDWrvT5CE0+NeeUAbkQqaESpSm79u5IePLnPWMMjCUnUR2zJd4+Bt9vioVRzLmJb2g=="],
"@azure/msal-common": ["@azure/msal-common@15.17.0", "", {}, "sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw=="],
"@azure/msal-node": ["@azure/msal-node@3.8.9", "", { "dependencies": { "@azure/msal-common": "15.16.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-jZ0pw/BbdEUWGhomCaAiVDfXRI/9K56m5hTNqB/CzcbZEYhXm5qpK1cDngN1iXfwSfmUMorOUQ2FC0dyuQ9uRg=="],
"@azure/msal-node": ["@azure/msal-node@3.8.10", "", { "dependencies": { "@azure/msal-common": "15.17.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-0Hz7Kx4hs70KZWep/Rd7aw/qOLUF92wUOhn7ZsOuB5xNR/06NL1E2RAI9+UKH1FtvN8nD6mFjH7UKSjv6vOWvQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -1287,19 +1287,19 @@
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.25", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.92.2" }, "peerDependencies": { "@tanstack/react-query": "^5.91.0", "react": "^18 || ^19" } }, "sha512-ZQElYJSIOS7fq7jv4VYhTZpY4ZLkmur9tSyeE+MW+kWxkWyzAE/h4FOODKP6Ii9N3+4IsGIrUgsMRJuSgRJBeQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.167.4", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.4", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-VpbZh382zX3WF4+X2Z+EUyd8eJhJyjg9C6ByYwrVZiWbhgbMK4+zQQIG2+lCAlIlDi7SV8fDcGL09NA8Z2kpGQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.167.5", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.5", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-s1nP6l/7BYZfSwhoNbB7/rUmZ07q/AvkmhBoiDQl3tgy5dpb9Q1qjtIapYdvCOrao1aA/QCaWqxcbGc2Ct1bvQ=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.9", "", { "dependencies": { "@tanstack/router-devtools-core": "1.166.9" }, "peerDependencies": { "@tanstack/react-router": "^1.167.2", "@tanstack/router-core": "^1.167.2", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-O49eZmaeEKB5YnKH/qd61AbxV/lW8ICm4stfZ4GNQNpzQQ6rhPIB0p3PMZDIgX+6DoMivdNvLRmXAOOpzpIpDg=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
"@tanstack/router-core": ["@tanstack/router-core@1.167.4", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "bin": { "intent": "bin/intent.js" } }, "sha512-Gk5V9Zr5JFJ4SbLyCheQLJ3MnXddccENPA+DJRz+9g3QxtN8DJB8w8KCUCgDeYlWp4LvmO4nX3fy3tupqVP2Pw=="],
"@tanstack/router-core": ["@tanstack/router-core@1.167.5", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "bin": { "intent": "bin/intent.js" } }, "sha512-8fRgJ0zNJf77R4grCaJQ5Imatjyc4YT5v8rlsPkYYYeUlcFNLbuFRhLlAMdND9gRUMznpnbRDXngpTPgx2K7HQ=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.166.9", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.167.2", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-PNlA7GmOUX9wY7LUG709Pk3Lg33dfHBztQwzjzrOiOsuf4ggp2R6bwarF8nYGNjG79z/MaB5PN+5yvkCVk8jGw=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.12", "", { "dependencies": { "@tanstack/router-core": "1.167.4", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-2HdxSTbCkbU9JeYogKVigIlXoLtIJE1x5rbEov+ZLTPjGCO9kicNQuljqg9Js+u2/ahtWewNrE5u1QCAyxmpIg=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.13", "", { "dependencies": { "@tanstack/router-core": "1.167.5", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-ALxSs6OzimiSgpOuIm+AXmc7eUx/oGPwSPpdQbpZ/kX7WHRh6qM7lv8DAN0K3jWcBpzF8eeOIdryWryX8gH+Yg=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.13", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.4", "@tanstack/router-generator": "1.166.12", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.4", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-xG3ND3AlMe6DN9PihJAYUbQJevqJvVdzN1QpZbfU1/jkHurL97ynP2yXfmMTh8Qgi1K+SWRko4bi7iZlYP9SUw=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.14", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.5", "@tanstack/router-generator": "1.166.13", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.5", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-hypyj0qlsAbJf60/glmVYqSVwnRB4hKRrMCUsSXjrPdO2g6gs3z6xHmcWsHQ831C4G9+bSFEK9Uy5EjO3A4THQ=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="],
@@ -1313,17 +1313,17 @@
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-3QNJ8VR2y1+AZX1HRB2uAvBWHWYpyWMYuDPAPHwhmpC74YweQmPh41dQZ9fkNcgT1eZNgYKf6Q3Hq7i0XK3Ybg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-WNzpqJV7rO/oKmsxjtSFXuV87fZb/qxzeGWXJng4oHFz7iPOFSWrPb2gTazJgoTvXUfe1kOH2o0F+zFrerwzRw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kiFEHQ+l64yzRtFcBSdHG9s/EF3aEzzmkuR8e1ygv7ol1YnWwz5EQEbZz6Ajt1sNYYRlo0GXw/d1YanVS9KWPA=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-KTq8/PH0/ml9bNFomZ2VjmOqW6RYPRqBqV5CfNeD5ekJMuNwJUdeQz0qXguxCzW4OkHCnTdPeKIehR0cdQObPg=="],
"@turbo/linux-64": ["@turbo/linux-64@2.8.18", "", { "os": "linux", "cpu": "x64" }, "sha512-GlMHBGfob6pOQv7t6imx11O0WJapVVNcJxw+HkliIr1ez8vr8SskM7njqOtqKBTaooCmGA71BFInNRZNYO9qlg=="],
"@turbo/linux-64": ["@turbo/linux-64@2.8.19", "", { "os": "linux", "cpu": "x64" }, "sha512-d0xcXiGUt1Q/HzMfgkqai5KyXrassLRuUZhxmUs4ZX99tQFbqRL2/CkSKCnMqyDv7c0K5eugLerVUY+DYr1DQg=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-ogWEZ8K/NznjnJU1idHYOA62Y7xYyMnt+kKTZzRNJ9Ye2japtjDkixC31yHaA9QJayb+yS5V8gNDjVfh2yGrIw=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-UX3r1iwqsOK8dMgKFtZpDqZk7feqHXZJ/EQAknh0SVQrnc4vkm6mZqjX4j2j3DVoSo4Ho8xGITYlor9Xxsyomw=="],
"@turbo/windows-64": ["@turbo/windows-64@2.8.18", "", { "os": "win32", "cpu": "x64" }, "sha512-29g6qryc18ls7BKUmVsjjHAbx2eDgab5CUSPhbCKZYCBfzaW+i79wCzyVoC0NJ8IRNwbLch2QEzgGEoRC6uGOQ=="],
"@turbo/windows-64": ["@turbo/windows-64@2.8.19", "", { "os": "win32", "cpu": "x64" }, "sha512-3MrvD/gMYWfQKZVZOeilKm1629zPG0+KlErZbI0n9Nr3L32+5qrZOF/cgWErukLnOSR38whgf8t2WaPJQALqxg=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-hLsgNDzDDO+Tq2YJ9uvmB2PFTYCgeNj6cyAOOBZP9rsOx0kxNBo4/OvDIgYl/cfo7wsoRBxNQ9giYR+rZMcAjA=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-j+8mi7kyEgT7cUTPOtPJS8KbTWxu4+OQBiDIo+zklz/RKV4hoN+k4+J8iCM9Nj+o+SVnzc01xSI+s3eBG0MDSA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -1337,7 +1337,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -1537,7 +1537,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1737,6 +1737,8 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
@@ -2091,7 +2093,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"jotai": ["jotai@2.18.1", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA=="],
@@ -2131,7 +2133,7 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.28.12", "", {}, "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw=="],
"kysely": ["kysely@0.28.13", "", {}, "sha512-jCkYDvlfzOyHaVsrvR4vnNZxG30oNv2jbbFBjTQAUG8n0h07HW0sZJHk4KAQIRyu9ay+Rg+L8qGa3lwt8Gve9w=="],
"lan-network": ["lan-network@0.2.0", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-EZgbsXMrGS+oK+Ta12mCjzBFse+SIewGdwrSTr5g+MSymnjpox2x05ceI20PQejJOFvOgzcXrfDk/SdY7dSCtw=="],
@@ -2775,7 +2777,7 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turbo": ["turbo@2.8.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.18", "@turbo/darwin-arm64": "2.8.18", "@turbo/linux-64": "2.8.18", "@turbo/linux-arm64": "2.8.18", "@turbo/windows-64": "2.8.18", "@turbo/windows-arm64": "2.8.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-ZO5aYR8wXIcZ+KRL8W7RFWbqIRDyYHtesF/g9zPMndmq+KkX34qrZgG9M+YswJ6BGX4jlYC1FlJjleKENagJdw=="],
"turbo": ["turbo@2.8.19", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.19", "@turbo/darwin-arm64": "2.8.19", "@turbo/linux-64": "2.8.19", "@turbo/linux-arm64": "2.8.19", "@turbo/windows-64": "2.8.19", "@turbo/windows-arm64": "2.8.19" }, "bin": { "turbo": "bin/turbo" } }, "sha512-9NTKvQZ/02XnJCSU4mF5gvAPK+nuLbvCtft/NE8dJeOwaup0bJ14rYx7TxmSXp8JJuAN8nAjqfXWFsWwoQSFHg=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
-2
View File
@@ -1,8 +1,6 @@
project_id: "881052"
api_token_env: CROWDIN_PERSONAL_TOKEN
preserve_hierarchy: true
files:
- source: /packages/i18n/src/po/en.po
translation: /packages/i18n/src/po/%two_letters_code%.po
+24 -15
View File
@@ -12,18 +12,19 @@
"fumadocs-openapi": "^10.3.18",
"fumadocs-ui": "^16.6.17",
"lucide-react": "^0.577.0",
"next": "16.1.7",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"next": "16.2.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/bun": "1.3.11",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"oxfmt": "0.41.0",
"oxlint": "1.56.0",
"postcss": "^8.5.8",
@@ -32,6 +33,10 @@
},
},
},
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -169,23 +174,23 @@
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@next/env": ["@next/env@16.1.7", "", {}, "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg=="],
"@next/env": ["@next/env@16.2.0", "", {}, "sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA=="],
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
@@ -421,6 +426,8 @@
"@takumi-rs/wasm": ["@takumi-rs/wasm@0.73.1", "", { "dependencies": { "@takumi-rs/helpers": "0.73.1" } }, "sha512-WDrstyQettPQSmYDyj7lrJMKlm3E5dg0ZC+PFvKTdfP4aMTZgWBchIL3MfdUwpRIRf96sdL+pAdrwI42X9iRXg=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -467,6 +474,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -763,7 +772,7 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"next": ["next@16.1.7", "", { "dependencies": { "@next/env": "16.1.7", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.7", "@next/swc-darwin-x64": "16.1.7", "@next/swc-linux-arm64-gnu": "16.1.7", "@next/swc-linux-arm64-musl": "16.1.7", "@next/swc-linux-x64-gnu": "16.1.7", "@next/swc-linux-x64-musl": "16.1.7", "@next/swc-win32-arm64-msvc": "16.1.7", "@next/swc-win32-x64-msvc": "16.1.7", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg=="],
"next": ["next@16.2.0", "", { "dependencies": { "@next/env": "16.2.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.0", "@next/swc-darwin-x64": "16.2.0", "@next/swc-linux-arm64-gnu": "16.2.0", "@next/swc-linux-arm64-musl": "16.2.0", "@next/swc-linux-x64-gnu": "16.2.0", "@next/swc-linux-x64-musl": "16.2.0", "@next/swc-win32-arm64-msvc": "16.2.0", "@next/swc-win32-x64-msvc": "16.2.0", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
+2 -1
View File
@@ -3,11 +3,12 @@
"---Documentation---",
"index",
"using-sofa",
"admin",
"configuration",
"integrations",
"admin",
"troubleshooting",
"mobile-app",
"translations",
"telemetry",
"---API Reference---",
"...api",
+76
View File
@@ -0,0 +1,76 @@
---
title: Translations
description: Language support and how to contribute translations.
icon: Languages
---
Sofa supports multiple languages for the web and mobile apps. The current supported locales are:
- English (source)
- French
- German
- Spanish
- Italian
- Portuguese
You can switch languages in **Settings > Language** on web or the mobile app. Sofa detects your browser or device language on first launch and defaults to it if supported. This setting is **not** saved to the server — it is only persisted to the current browser or device.
## How translations work
Sofa uses [Lingui](https://lingui.dev) for internationalization. All translatable strings are extracted from the source code into PO files (an industry-standard format), which live in [`packages/i18n/src/po/`](https://github.com/jakejarvis/sofa/tree/main/packages/i18n/src/po).
The translation pipeline:
1. React (web) and React Native (mobile) apps wrap UI strings in `<Trans>` components or `` msg`...` `` macros
2. `bun run i18n:extract` scans the codebase and updates `en.po` with new strings
3. Translations are added to each locale's `.po` file (e.g. `fr.po`, `de.po`)
4. `bun run i18n:compile` compiles `.po` files into TypeScript for use at runtime
5. Non-English locales are lazy-loaded on demand
## Contributing via Crowdin
The easiest way to contribute translations is through [Crowdin](https://crowdin.com/project/sofa), a community translation platform. No development setup required — just sign up and start translating in the browser!
When source strings change on `main`, they are automatically uploaded to Crowdin [via GitHub Actions](https://github.com/jakejarvis/sofa/blob/main/.github/workflows/crowdin.yml). To pull completed translations back into the repo, a maintainer triggers the download workflow which creates a PR with the updated `.po` files.
## AI translation with Claude
For maintainers, Sofa includes a script, [`claude.ts`](https://github.com/jakejarvis/sofa/blob/main/packages/i18n/scripts/claude.ts), that uses [Claude Code](https://docs.anthropic.com/en/docs/claude-code) to roughly translate any yet-to-be-translated strings.
```bash
# Translate a single locale
bun packages/i18n/scripts/claude.ts fr
# Translate all locales (runs in parallel)
bun packages/i18n/scripts/claude.ts all
# Preview without translating
bun packages/i18n/scripts/claude.ts all --dry-run
# Use a specific model
bun packages/i18n/scripts/claude.ts all --model opus
```
The script only translates strings with empty `msgstr` — existing translations are never overwritten. It reads the locale list from [`lingui.config.ts`](https://github.com/jakejarvis/sofa/blob/main/lingui.config.ts) automatically and compiles catalogs after translating.
<Callout type="info">
Using this script really only makes sense if you have a [Claude Max
subscription](https://claude.ai/upgrade) with Anthropic's cash to burn.
</Callout>
## Adding a new language
1. Add the locale code to the `locales` array in [`lingui.config.ts`](https://github.com/jakejarvis/sofa/blob/main/lingui.config.ts)
2. Run `bun run i18n:extract` — this creates a new `.po` file for the locale
3. Translate the strings (via Crowdin, manually, or with `bun packages/i18n/scripts/claude.ts <locale>`)
4. Add the locale to the `LOCALE_INFO` array in [`packages/i18n/src/locales.ts`](https://github.com/jakejarvis/sofa/blob/main/packages/i18n/src/locales.ts) with its native name
5. Run `bun run i18n:compile`
## Translation guidelines
Whether translating via Crowdin, AI, or manually, these rules apply:
- **Keep it concise** — these are UI labels, buttons, and short messages
- **Preserve placeholders** — `{0}`, `{name}`, `{count, plural, ...}`, `<0>text</0>` must appear in the translation exactly as in the source
- **Adapt plurals** — ICU plural blocks should use the correct plural categories for your language (e.g. French needs `one`/`other`, Czech needs `one`/`few`/`many`/`other`)
- **Don't translate brand names** — Sofa, TMDB, Plex, Jellyfin, etc. stay as-is
+11 -6
View File
@@ -21,23 +21,28 @@
"fumadocs-openapi": "^10.3.18",
"fumadocs-ui": "^16.6.17",
"lucide-react": "^0.577.0",
"next": "16.1.7",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"next": "16.2.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@types/bun": "1.3.11",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"oxfmt": "0.41.0",
"oxlint": "1.56.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
},
"packageManager": "bun@1.3.10"
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
},
"packageManager": "bun@1.3.11"
}
+78 -36
View File
@@ -99,6 +99,7 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type"
},
"title": {
@@ -555,6 +556,7 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type"
},
"title": {
@@ -901,6 +903,7 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type"
},
"title": {
@@ -992,6 +995,7 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type"
},
"title": {
@@ -1083,6 +1087,7 @@
"webhook",
"list"
],
"type": "string",
"description": "Integration type: webhook (Plex/Jellyfin/Emby) or list (Sonarr/Radarr)"
},
"token": {
@@ -1166,6 +1171,7 @@
"ignored",
"error"
],
"type": "string",
"description": "Event processing outcome"
},
"receivedAt": {
@@ -1204,6 +1210,7 @@
"scheduled",
"pre-restore"
],
"type": "string",
"description": "How the backup was created: manual, scheduled, or automatic pre-restore"
}
},
@@ -1273,7 +1280,8 @@
"running",
"success",
"error"
]
],
"type": "string"
},
{
"type": "null"
@@ -1781,7 +1789,8 @@
"enum": [
"in_progress",
"completed"
]
],
"type": "string"
},
{
"type": "null"
@@ -1983,7 +1992,8 @@
"watchlist",
"in_progress",
"completed"
]
],
"type": "string"
},
{
"type": "null"
@@ -2072,7 +2082,8 @@
"watchlist",
"in_progress",
"completed"
]
],
"type": "string"
},
"description": "Map of title ID to the user's tracking status"
}
@@ -2517,7 +2528,8 @@
"watchlist",
"in_progress",
"completed"
]
],
"type": "string"
},
"description": "Map of title ID to the user's tracking status"
},
@@ -2898,6 +2910,7 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type"
},
"title": {
@@ -2966,7 +2979,8 @@
"watchlist",
"in_progress",
"completed"
]
],
"type": "string"
},
{
"type": "null"
@@ -3079,10 +3093,9 @@
"movie",
"episode"
],
"type": "string",
"description": "What to count: movie watches or episode watches"
},
"style": "deepObject",
"explode": true,
"allowEmptyValue": true,
"allowReserved": true
},
@@ -3097,10 +3110,9 @@
"this_month",
"this_year"
],
"type": "string",
"description": "Time range for the histogram"
},
"style": "deepObject",
"explode": true,
"allowEmptyValue": true,
"allowReserved": true
}
@@ -3176,10 +3188,9 @@
"movie",
"tv"
],
"type": "string",
"description": "Trending category: all, movie, or tv"
},
"style": "deepObject",
"explode": true,
"allowEmptyValue": true,
"allowReserved": true
},
@@ -3231,6 +3242,7 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type"
},
"title": {
@@ -3283,7 +3295,8 @@
"watchlist",
"in_progress",
"completed"
]
],
"type": "string"
},
"description": "Map of title ID to the user's tracking status"
},
@@ -3433,10 +3446,9 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type filter"
},
"style": "deepObject",
"explode": true,
"allowEmptyValue": true,
"allowReserved": true
},
@@ -3479,7 +3491,8 @@
"watchlist",
"in_progress",
"completed"
]
],
"type": "string"
},
"description": "Map of title ID to the user's tracking status"
},
@@ -3628,10 +3641,9 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type filter"
},
"style": "deepObject",
"explode": true,
"allowEmptyValue": true,
"allowReserved": true
}
@@ -3784,10 +3796,9 @@
"tv",
"person"
],
"type": "string",
"description": "Optional type filter to narrow results"
},
"style": "deepObject",
"explode": true,
"allowEmptyValue": true,
"allowReserved": true
},
@@ -3833,6 +3844,7 @@
"tv",
"person"
],
"type": "string",
"description": "Result type"
},
"title": {
@@ -4057,10 +4069,9 @@
"movie",
"tv"
],
"type": "string",
"description": "Media type to discover"
},
"style": "deepObject",
"explode": true,
"allowEmptyValue": true,
"allowReserved": true
},
@@ -4116,7 +4127,8 @@
"watchlist",
"in_progress",
"completed"
]
],
"type": "string"
},
"description": "Map of title ID to the user's tracking status"
},
@@ -4432,6 +4444,7 @@
"webhook",
"list"
],
"type": "string",
"description": "Integration type: webhook (Plex/Jellyfin/Emby) or list (Sonarr/Radarr)"
},
"token": {
@@ -4515,6 +4528,7 @@
"sonarr",
"radarr"
],
"type": "string",
"description": "Media server provider to integrate"
},
"enabled": {
@@ -4570,6 +4584,7 @@
"sonarr",
"radarr"
],
"type": "string",
"description": "Media server provider type"
}
}
@@ -4617,6 +4632,7 @@
"sonarr",
"radarr"
],
"type": "string",
"description": "Media server provider type"
}
}
@@ -5110,6 +5126,7 @@
"1d",
"7d"
],
"type": "string",
"description": "Backup interval"
},
"time": {
@@ -5165,6 +5182,7 @@
"1d",
"7d"
],
"type": "string",
"description": "Backup interval"
},
"time": {
@@ -5526,6 +5544,7 @@
"updateCheck",
"telemetryReport"
],
"type": "string",
"description": "Cron job to trigger"
}
},
@@ -5881,6 +5900,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"file": {
@@ -5904,6 +5924,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"file": {
@@ -5936,6 +5957,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"movies": {
@@ -6043,7 +6065,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
}
},
"required": [
@@ -6077,7 +6100,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
},
"rating": {
"type": "integer",
@@ -6273,6 +6297,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"movies": {
@@ -6380,7 +6405,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
}
},
"required": [
@@ -6414,7 +6440,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
},
"rating": {
"type": "integer",
@@ -6472,6 +6499,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"movies": {
@@ -6579,7 +6607,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
}
},
"required": [
@@ -6613,7 +6642,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
},
"rating": {
"type": "integer",
@@ -6738,6 +6768,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"movies": {
@@ -6845,7 +6876,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
}
},
"required": [
@@ -6879,7 +6911,8 @@
"enum": [
"movie",
"tv"
]
],
"type": "string"
},
"rating": {
"type": "integer",
@@ -6960,6 +6993,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"status": {
@@ -6969,7 +7003,8 @@
"success",
"error",
"cancelled"
]
],
"type": "string"
},
"totalItems": {
"type": "number"
@@ -7239,6 +7274,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"status": {
@@ -7248,7 +7284,8 @@
"success",
"error",
"cancelled"
]
],
"type": "string"
},
"totalItems": {
"type": "number"
@@ -7386,6 +7423,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"status": {
@@ -7395,7 +7433,8 @@
"success",
"error",
"cancelled"
]
],
"type": "string"
},
"totalItems": {
"type": "number"
@@ -7598,7 +7637,8 @@
"progress",
"complete",
"timeout"
]
],
"type": "string"
},
"job": {
"type": "object",
@@ -7612,6 +7652,7 @@
"simkl",
"letterboxd"
],
"type": "string",
"description": "External service to import from"
},
"status": {
@@ -7621,7 +7662,8 @@
"success",
"error",
"cancelled"
]
],
"type": "string"
},
"totalItems": {
"type": "number"
+9 -2
View File
@@ -8,8 +8,8 @@ export const metadata: Metadata = {
export default function PrivacyPolicyPage() {
return (
<article className="prose prose-sm prose-invert [&_a]:text-fd-primary hover:[&_a]:text-fd-primary/80 [&_h2]:font-display [&_h3]:font-display [&_p]:text-fd-muted-foreground [&_li]:text-fd-muted-foreground [&_strong]:text-fd-foreground mx-auto max-w-3xl px-6 py-16 [&_a]:underline [&_a]:underline-offset-4 [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-2xl [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_li]:text-[15px] [&_li]:leading-relaxed [&_p]:text-[15px] [&_p]:leading-relaxed [&_strong]:font-medium [&_ul]:space-y-1">
<h1 className="font-display mb-2 text-3xl tracking-tight">Privacy Policy</h1>
<article className="prose prose-sm prose-invert [&_a]:text-fd-primary hover:[&_a]:text-fd-primary/80 [&_h2]:font-display [&_h3]:font-display [&_p]:text-fd-muted-foreground [&_li]:text-fd-muted-foreground [&_strong]:text-fd-foreground mx-auto max-w-3xl px-6 py-16 [&_a]:underline [&_a]:underline-offset-4 [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-2xl [&_h2]:font-semibold [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-normal [&_li]:text-[15px] [&_li]:leading-relaxed [&_p]:text-[15px] [&_p]:leading-relaxed [&_strong]:font-medium [&_ul]:space-y-1">
<h1 className="font-display mb-2 text-3xl font-semibold tracking-tight">Privacy Policy</h1>
<p className="text-fd-muted-foreground/60 !mt-0 mb-8 text-sm">Last updated: March 16, 2026</p>
<p>
@@ -95,6 +95,13 @@ export default function PrivacyPolicyPage() {
<strong>disabled by default</strong> and must be explicitly enabled by an admin. See the{" "}
<Link href="/docs/telemetry">telemetry documentation</Link> for full details.
</li>
<li>
<strong>Import helper</strong> &mdash; when importing watch history from Trakt or Simkl,
your server proxies OAuth device-code authorization through{" "}
<code>public-api.sofa.watch</code> so that a single OAuth client ID can be shared across
all instances. Only the provider&rsquo;s device code and token responses are relayed; no
personal data, watch history, or instance identifiers are included in the requests.
</li>
</ul>
<h3>PostHog (Mobile App Only)</h3>
+5 -4
View File
@@ -12,7 +12,7 @@
"@orpc/contract": "1.13.8",
"@orpc/tanstack-query": "1.13.8",
"@tanstack/react-query": "5.91.0",
"@types/bun": "1.3.10",
"@types/bun": "1.3.11",
"@types/node": "25.5.0",
"@types/react": "19.2.14",
"better-auth": "1.5.5",
@@ -45,7 +45,8 @@
"test": "turbo run test",
"generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs",
"i18n:extract": "lingui extract",
"i18n:compile": "lingui compile --typescript"
"i18n:compile": "lingui compile --typescript",
"i18n:claude": "bun packages/i18n/scripts/claude.ts"
},
"devDependencies": {
"@lingui/cli": "catalog:",
@@ -53,8 +54,8 @@
"@lingui/format-po": "catalog:",
"oxfmt": "0.41.0",
"oxlint": "1.56.0",
"turbo": "2.8.18",
"turbo": "2.8.19",
"typescript": "catalog:"
},
"packageManager": "bun@1.3.10"
"packageManager": "bun@1.3.11"
}
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env bun
/**
* Translates empty PO file entries using Claude Code CLI.
*
* Usage:
* bun scripts/translate.ts <locale|all> [--model <model>] [--batch-size <n>] [--dry-run]
*
* Examples:
* bun scripts/translate.ts fr
* bun scripts/translate.ts all --model sonnet
* bun scripts/translate.ts de --dry-run
*/
import { resolve } from "node:path";
import type { CatalogType } from "@lingui/conf";
import { formatter } from "@lingui/format-po";
import linguiConfig from "../../../lingui.config";
const poFormat = formatter();
// ─── Config ──────────────────────────────────────────────
const SOURCE_LOCALE = linguiConfig.sourceLocale ?? "en";
const LOCALES = (linguiConfig.locales as string[]).filter((l) => l !== SOURCE_LOCALE);
const DEFAULT_MODEL = "sonnet";
const DEFAULT_BATCH_SIZE = 100;
const MAX_RETRIES = 3;
const PO_DIR = resolve(import.meta.dir, "../src/po");
const CONTEXT_FILE = resolve(import.meta.dir, "../../../crowdin-context.jsonl");
/** Resolve a locale code to its English display name via Intl */
function localeName(code: string): string {
return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code;
}
function usage(): never {
console.log(
"Usage: bun scripts/translate.ts <locale|all> [--model <model>] [--batch-size <n>] [--dry-run]",
);
console.log(` Locales: ${LOCALES.join(", ")}`);
process.exit(1);
}
function fail(message: string): never {
console.error(message);
process.exit(1);
}
// ─── AI Context ──────────────────────────────────────────
interface ContextEntry {
text: string;
ai_context: string;
}
async function loadContext(): Promise<Map<string, string>> {
const contextMap = new Map<string, string>();
const file = Bun.file(CONTEXT_FILE);
if (!(await file.exists())) {
console.log(" (no crowdin-context.jsonl found, translating without AI context)\n");
return contextMap;
}
const text = await file.text();
const lines = text.split("\n");
for (const [index, line] of lines.entries()) {
if (!line.trim()) continue;
try {
const entry: ContextEntry = JSON.parse(line);
if (typeof entry.text !== "string" || typeof entry.ai_context !== "string") {
throw new Error("expected { text: string, ai_context: string }");
}
contextMap.set(entry.text, entry.ai_context);
} catch (error) {
// oxlint-disable-next-line preserve-caught-error
throw new Error(
`Invalid JSONL entry in ${CONTEXT_FILE} at line ${index + 1}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
console.log(` Loaded ${contextMap.size} AI context entries from crowdin-context.jsonl\n`);
return contextMap;
}
// ─── CLI Args ────────────────────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
let locale: string | undefined;
let model = DEFAULT_MODEL;
let batchSize = DEFAULT_BATCH_SIZE;
let dryRun = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--model") {
const value = args[++i];
if (!value || value.startsWith("--")) {
fail("Missing value for --model");
}
model = value;
continue;
}
if (arg === "--batch-size") {
const value = args[++i];
if (!value || value.startsWith("--")) {
fail("Missing value for --batch-size");
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
fail(`Invalid --batch-size: ${value}`);
}
batchSize = parsed;
continue;
}
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg.startsWith("--")) {
fail(`Unknown flag: ${arg}`);
}
if (locale) {
fail(`Unexpected extra positional argument: ${arg}`);
}
locale = arg;
}
if (!locale) {
usage();
}
if (locale !== "all" && !LOCALES.includes(locale)) {
fail(`Unknown locale: ${locale}. Valid: ${LOCALES.join(", ")}, all`);
}
const locales: string[] = locale === "all" ? [...LOCALES] : [locale];
return { locales, model, batchSize, dryRun };
}
// ─── PO Helpers ──────────────────────────────────────────
interface UntranslatedEntry {
/** Lingui catalog key (may be a hashed ID like "nJw77c") */
key: string;
/** The actual English source text to translate */
message: string;
}
function getUntranslatedEntries(catalog: CatalogType): UntranslatedEntry[] {
return Object.entries(catalog)
.filter(([, entry]) => entry && !entry.translation)
.map(([key, entry]) => ({
key,
// entry.message is the source text; fall back to key for explicit-id catalogs
message: entry.message || key,
}));
}
// ─── Basic Structural Validation ─────────────────────────
// Catches common placeholder/tag damage. NOT a full ICU MessageFormat validator —
// does not detect argument type changes, malformed plural/select, or nested corruption.
function extractSimplePlaceholders(text: string): string[] {
// Intentionally conservative. This catches the common placeholder cases:
// {0}, {name}, {count}
return [...text.matchAll(/\{[a-zA-Z0-9_]+\}/g)].map((m) => m[0]).sort();
}
function extractNumberedTags(text: string): string[] {
// Matches <0>, </0>, <0/>, <12>, </12>, <12/>
return [...text.matchAll(/<\/?\d+\/?>/g)].map((m) => m[0]).sort();
}
function leadingWhitespace(text: string): string {
return text.match(/^\s*/)?.[0] ?? "";
}
function trailingWhitespace(text: string): string {
return text.match(/\s*$/)?.[0] ?? "";
}
function validateTranslation(source: string, translated: string): string[] {
const issues: string[] = [];
if (translated.length === 0 && source.length > 0) {
issues.push("empty translation");
}
const srcPlaceholders = extractSimplePlaceholders(source);
const dstPlaceholders = extractSimplePlaceholders(translated);
if (JSON.stringify(srcPlaceholders) !== JSON.stringify(dstPlaceholders)) {
issues.push("placeholder mismatch");
}
const srcTags = extractNumberedTags(source);
const dstTags = extractNumberedTags(translated);
if (JSON.stringify(srcTags) !== JSON.stringify(dstTags)) {
issues.push("tag mismatch");
}
// Only enforce whitespace when the source has meaningful edge whitespace
if (/^\s|\s$/.test(source)) {
if (leadingWhitespace(source) !== leadingWhitespace(translated)) {
issues.push("leading whitespace mismatch");
}
if (trailingWhitespace(source) !== trailingWhitespace(translated)) {
issues.push("trailing whitespace mismatch");
}
}
return issues;
}
// ─── Consistency Hints ───────────────────────────────────
function buildConsistencyHints(accepted: Map<string, string>, limit = 50): string | undefined {
if (accepted.size === 0) return undefined;
const pairs = [...accepted.entries()]
.slice(-limit)
.map(([source, translated]) => `- ${JSON.stringify(source)} => ${JSON.stringify(translated)}`)
.join("\n");
return [
"Previously used translations for consistency:",
pairs,
"Reuse these choices where appropriate.",
].join("\n");
}
// ─── Translation via Claude CLI ──────────────────────────
async function translateBatchRaw(
strings: string[],
locale: string,
model: string,
contextMap: Map<string, string>,
consistencyHints?: string,
): Promise<string[]> {
const language = localeName(locale);
const input = strings.map((s, i) => {
const ctx = contextMap.get(s);
return ctx ? { index: i, text: s, context: ctx } : { index: i, text: s };
});
const systemPrompt = [
"You are a professional translator for a self-hosted movie & TV tracking app called Sofa.",
`Translate UI strings from English to ${language}.`,
"",
"Rules:",
"- Return translations in the SAME ORDER as the input array, one per input string.",
"- Return ONLY the structured output required by the schema.",
"- Preserve leading and trailing whitespace exactly.",
"- Preserve line breaks exactly unless the source meaning requires otherwise.",
"- Placeholder rules (critical):",
" - Every placeholder in the source ({0}, {name}, {count}, etc.) MUST appear in the translation. Do NOT add, remove, or rename placeholders.",
" - Do NOT change placeholder types: a simple placeholder like {0} CANNOT become a complex argument like {0, plural, ...}. Keep the same structural skeleton.",
' - You MAY reorder placeholders to fit target language grammar (e.g. "{0} {1}" → "{1} {0}").',
' - For ICU plural/select blocks that ALREADY EXIST in the source: you MUST adapt plural categories to the target language\'s CLDR rules. E.g. English "one/other" may need "one/few/many/other" in some languages. Add or remove categories as needed.',
" - Do NOT translate ICU keywords (one, other, few, many, zero, two) or the # symbol inside plurals.",
"- Preserve JSX-like component tags EXACTLY: <0/>, <0>text</0>, <1>…</1> — translate text between tags, keep the tags. You may reorder tags.",
"- When adjectives/participles need to agree in number with a dynamic count, move them INSIDE the plural block so each category can have correct agreement.",
"- Keep translations concise — these are UI labels, buttons, toasts, and short messages.",
"- Do NOT translate brand/product names: Sofa, TMDB, Plex, Jellyfin, Emby, Trakt, Simkl, Overseerr, Jellyseerr.",
"- Use natural, idiomatic phrasing for the target language.",
"- Consistency: translate the same English term the same way every time.",
'- Each input has a "text" field to translate. Some also have a "context" field explaining where/how the string is used — use this to produce more accurate translations.',
"- If a string is already a brand name, code token, identifier, or path, keep it unchanged.",
...(consistencyHints ? ["", consistencyHints] : []),
].join("\n");
const prompt = JSON.stringify(input);
const schema = JSON.stringify({
type: "object",
additionalProperties: false,
properties: {
translations: {
type: "array",
items: { type: "string" },
minItems: strings.length,
maxItems: strings.length,
},
},
required: ["translations"],
});
const proc = Bun.spawn(
[
"claude",
"-p",
"--output-format",
"json",
"--json-schema",
schema,
"--model",
model,
"--no-session-persistence",
"--system-prompt",
systemPrompt,
"--tools",
"",
],
{ stdout: "pipe", stderr: "pipe", stdin: new Response(prompt) },
);
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`Claude CLI failed (exit ${exitCode}): ${stderr || stdout}`);
}
let result: { translations: string[] };
try {
const parsed = JSON.parse(stdout);
const structured = parsed.structured_output ?? parsed.result ?? parsed;
result = typeof structured === "string" ? JSON.parse(structured) : structured;
} catch {
throw new Error(`Failed to parse Claude response. Raw output:\n${stdout.slice(0, 1000)}`);
}
if (!Array.isArray(result.translations) || result.translations.length !== strings.length) {
throw new Error(
`Expected ${strings.length} translations, got ${result.translations?.length ?? 0}.\nRaw: ${stdout.slice(0, 1000)}`,
);
}
return result.translations;
}
async function translateBatch(
strings: string[],
locale: string,
model: string,
contextMap: Map<string, string>,
consistencyHints?: string,
): Promise<string[]> {
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const translations = await translateBatchRaw(
strings,
locale,
model,
contextMap,
consistencyHints,
);
const validationErrors: string[] = [];
for (let i = 0; i < strings.length; i++) {
const issues = validateTranslation(strings[i], translations[i]);
if (issues.length > 0) {
validationErrors.push(
`index ${i}: ${issues.join(", ")}\nsource: ${JSON.stringify(strings[i])}\ntranslation: ${JSON.stringify(translations[i])}`,
);
}
}
if (validationErrors.length > 0) {
throw new Error(
`Translation validation failed:\n${validationErrors.slice(0, 10).join("\n\n")}${
validationErrors.length > 10
? `\n\n...and ${validationErrors.length - 10} more validation errors`
: ""
}`,
);
}
return translations;
} catch (error) {
lastError = error;
if (attempt < MAX_RETRIES) {
const delayMs = 500 * attempt;
console.warn(
` attempt ${attempt}/${MAX_RETRIES} failed; retrying in ${delayMs}ms\n ${
error instanceof Error ? error.message : String(error)
}`,
);
await Bun.sleep(delayMs);
}
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
// ─── Main ────────────────────────────────────────────────
async function translateLocale(
locale: string,
model: string,
batchSize: number,
dryRun: boolean,
contextMap: Map<string, string>,
) {
const filePath = resolve(PO_DIR, `${locale}.po`);
const file = Bun.file(filePath);
if (!(await file.exists())) {
throw new Error(`PO file not found for locale "${locale}": ${filePath}`);
}
const content = await file.text();
const ctx = { locale, sourceLocale: SOURCE_LOCALE, filename: filePath };
const catalog = await poFormat.parse(content, ctx);
const untranslated = getUntranslatedEntries(catalog);
if (untranslated.length === 0) {
console.log(` ${locale}: all strings already translated`);
return;
}
console.log(` ${locale}: ${untranslated.length} strings to translate`);
if (dryRun) {
console.log(` ${locale}: dry run — skipping`);
return;
}
// Map of catalog key → translated string
const translations = new Map<string, string>();
// Map of source message → translated string (for consistency hints + validation)
const messageTranslations = new Map<string, string>();
const totalBatches = Math.ceil(untranslated.length / batchSize);
for (let i = 0; i < totalBatches; i++) {
const batch = untranslated.slice(i * batchSize, (i + 1) * batchSize);
const messages = batch.map((e) => e.message);
console.log(` batch ${i + 1}/${totalBatches} (${batch.length} strings)...`);
const hints = buildConsistencyHints(messageTranslations);
const translated = await translateBatch(messages, locale, model, contextMap, hints);
for (let j = 0; j < batch.length; j++) {
translations.set(batch[j].key, translated[j]);
messageTranslations.set(batch[j].message, translated[j]);
}
}
// Write translations back to catalog using keys
for (const [key, msgstr] of translations) {
if (catalog[key]) {
catalog[key].translation = msgstr;
}
}
const serialized = await poFormat.serialize(catalog, { ...ctx, existing: content });
await Bun.write(filePath, serialized);
console.log(` ${locale}: wrote ${translations.size} translations`);
}
const { locales, model, batchSize, dryRun } = parseArgs();
console.log(
`Translating PO files via Claude (model: ${model}, batch size: ${batchSize})${dryRun ? " [DRY RUN]" : ""}\n`,
);
const contextMap = await loadContext();
// Process locales sequentially to reduce CLI/rate-limit pressure and keep logs readable.
for (const locale of locales) {
await translateLocale(locale, model, batchSize, dryRun, contextMap);
}
if (!dryRun) {
// Re-extract to restore #. placeholder comments that @lingui/format-po's
// serialize() strips during the round-trip.
console.log("Re-extracting to restore comments...");
const extract = Bun.spawn(["bun", "run", "i18n:extract"], {
stdout: "inherit",
stderr: "inherit",
});
if ((await extract.exited) !== 0) {
console.error("Extraction failed");
process.exit(1);
}
console.log("Compiling catalogs...");
const compile = Bun.spawn(["bun", "run", "i18n:compile"], {
stdout: "inherit",
stderr: "inherit",
});
if ((await compile.exited) !== 0) {
console.error("Catalog compilation failed");
process.exit(1);
}
}
console.log("Done!");
+1252 -1252
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+737 -738
View File
File diff suppressed because it is too large Load Diff
+1257 -1257
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1259 -1259
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1247 -1247
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1258 -1258
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long