diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6703df2c..ff462519 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,11 @@ jobs: - name: Typecheck consoles run: bunx turbo typecheck --filter=app --filter=admin + # Compares key sets and source hashes only — no network, no API key, so + # it runs on forks too. Fix a failure with `bun run i18n:sync`. + - name: Check i18n locale parity + run: bun run i18n:check + - name: Lint run: bun run lint diff --git a/app/i18n.sync.json b/app/i18n.sync.json new file mode 100644 index 00000000..ba9dd67e --- /dev/null +++ b/app/i18n.sync.json @@ -0,0 +1,84 @@ +{ + "version": 1, + "hashes": { + "agent:ru": { + "agent_name": "28145084c725", + "agent_resources": "e89b30aa1dc3", + "agent_status": "920e413c7d41", + "agent_template": "0575f29df888", + "agents": "279b44d2ab4b", + "create_agent": "5a614e73e098" + }, + "bridle:ru": { + "chat.agent": "11b39c93777e", + "chat.empty_hint": "df21fa572470", + "chat.empty_title": "012c15d65531", + "chat.error": "a95d0d36ee79", + "chat.input_hint": "8a8c17c82d18", + "chat.placeholder": "57ef13894e3b", + "chat.send": "f6f4688ff23d", + "chat.sending": "a02f1cea3c1d", + "chat.starter_hint": "76085886cbdf", + "chat.you": "08b041935798" + }, + "common:ru": { + "app_name": "d64517995073", + "auth.sign_in": "bfd402b2f6f3", + "auth.sign_out": "48f0d3d397d4", + "auth.sign_up": "5e2b8e96503d", + "demo.agent_greeting": "4741cc1329af", + "demo.agent_working": "9370717ab72c", + "demo.cpu": "db9a4c7d4c19", + "demo.memory": "c3963aedaac6", + "demo.user_request": "87467b7a12ed", + "features.admin_body": "9e1a6af2c7da", + "features.admin_title": "625b6464d0b0", + "features.chat_body": "7bc43aa29827", + "features.chat_title": "a0de0cf7dde9", + "features.logs_body": "1cadc89e731e", + "features.logs_title": "2763db1bbff3", + "features.restart_body": "d3abe64a1f92", + "features.restart_title": "9195a10357ba", + "features.status_body": "66b3943c194c", + "features.status_title": "4de30f8440dd", + "features.templates_body": "371112efb24e", + "features.templates_title": "96eccfa5c632", + "footer.note": "ab7218029e6a", + "hero.badge": "d1afeb3a5261", + "hero.cta_dashboard": "00519f8c9af0", + "hero.cta_deploy": "e852f76c4d13", + "hero.cta_sign_in": "04cca6b25913", + "hero.lede": "05932b878bcc", + "hero.stat_agents": "279b44d2ab4b", + "hero.stat_running": "f4ccae29e1bb", + "hero.stat_uptime": "d63ab4711473", + "hero.title": "9f28248b81cc", + "hero.title_accent": "a37b050de90d", + "landing.cta_button": "baad3429e418", + "landing.cta_lede": "8ef46042d616", + "landing.cta_title": "90c76b13ac6d", + "landing.how_eyebrow": "9c870aa6e5e9", + "landing.how_title": "c025c7422fa3", + "landing.step": "a0cc2a474806", + "landing.what_eyebrow": "9c8ea72883bb", + "landing.what_lede": "1efa22dfed12", + "landing.what_title": "c852637df453", + "locale.label": "a4fe65264ef7", + "nav.agents": "279b44d2ab4b", + "nav.history": "0e7696009337", + "nav.templates": "56b564b75c7f", + "steps.chat_body": "8f80dfeff166", + "steps.chat_title": "e5e09a778387", + "steps.deploy_body": "e358d64527e3", + "steps.deploy_title": "833c2e33e68c", + "steps.template_body": "ae90b69c7099", + "steps.template_title": "21a2714bf862" + }, + "template:ru": { + "template_description": "526e0087cc3f", + "template_image": "140d5d5895d7", + "template_name": "f95e97c2f767", + "templates": "56b564b75c7f" + } + } +} diff --git a/app/slices/agent/i18n/locales/ru.json b/app/slices/agent/i18n/locales/ru.json new file mode 100644 index 00000000..cd7fd4ac --- /dev/null +++ b/app/slices/agent/i18n/locales/ru.json @@ -0,0 +1,8 @@ +{ + "agents": "Агенты", + "create_agent": "Создать агента", + "agent_name": "Имя агента", + "agent_status": "Статус", + "agent_template": "Шаблон", + "agent_resources": "Ресурсы" +} diff --git a/app/slices/agent/nuxt.config.ts b/app/slices/agent/nuxt.config.ts index f81dffa2..1075d3d4 100644 --- a/app/slices/agent/nuxt.config.ts +++ b/app/slices/agent/nuxt.config.ts @@ -1,6 +1,8 @@ import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { LOCALES } from '../setup/i18n/locales'; + const currentDir = dirname(fileURLToPath(import.meta.url)); export default defineNuxtConfig({ @@ -13,6 +15,6 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/i18n'], i18n: { langDir: 'locales', - locales: [{ code: 'en', file: 'en.json' }], + locales: LOCALES, }, }); diff --git a/app/slices/bridle/components/bridleChat/Empty.vue b/app/slices/bridle/components/bridleChat/Empty.vue index 69de83b2..591b6041 100644 --- a/app/slices/bridle/components/bridleChat/Empty.vue +++ b/app/slices/bridle/components/bridleChat/Empty.vue @@ -3,11 +3,8 @@ class="flex flex-col items-center justify-center h-full text-center text-muted-foreground px-6" >
💬
-

{{ t('chat.empty_title') }}

-

{{ t('chat.empty_hint') }}

+

{{ $t('chat.empty_title') }}

+

{{ $t('chat.empty_hint') }}

- diff --git a/app/slices/bridle/components/bridleChat/Input.vue b/app/slices/bridle/components/bridleChat/Input.vue index 4ece3089..d51768a7 100644 --- a/app/slices/bridle/components/bridleChat/Input.vue +++ b/app/slices/bridle/components/bridleChat/Input.vue @@ -1,7 +1,6 @@ diff --git a/app/slices/setup/i18n/i18n/i18n.config.ts b/app/slices/setup/i18n/i18n/i18n.config.ts index a8e1ec50..0a921a9a 100644 --- a/app/slices/setup/i18n/i18n/i18n.config.ts +++ b/app/slices/setup/i18n/i18n/i18n.config.ts @@ -1,7 +1,13 @@ +import { SOURCE_LOCALE } from '../locales'; + export default defineI18nConfig(() => ({ legacy: false, - locale: 'en', - messages: { - en: {}, - }, + locale: SOURCE_LOCALE, + // A key that hasn't been translated yet renders its English text instead of + // the raw key path — so a slice mid-extraction degrades to English rather + // than showing `chat.send` to the user. + fallbackLocale: SOURCE_LOCALE, + // No `messages` here on purpose: every slice ships its own locale files and + // the module merges them. Listing locales again would mean editing this file + // too whenever LOCALES changes. })); diff --git a/app/slices/setup/i18n/locales.ts b/app/slices/setup/i18n/locales.ts new file mode 100644 index 00000000..eef12a6b --- /dev/null +++ b/app/slices/setup/i18n/locales.ts @@ -0,0 +1,25 @@ +/** + * Single source of truth for the locales the app console ships. + * + * Every slice registers this list (`i18n: { langDir: 'locales', locales: LOCALES }`) + * instead of repeating the entries, so adding a language is a one-line change + * here rather than one edit per slice — and slices can't drift apart. + * + * Adding a locale means every slice that registers this list also needs the + * matching file on disk: a declared locale with no `.json` breaks the + * build. `bun run i18n:sync` creates and fills them; `bun run i18n:check` + * fails CI when one falls behind `en.json`. + * + * admin/ is deliberately not localized — see + * docs/superpowers/specs/2026-08-19-i18n-strategy-design.md. + */ +// `as const` on the codes keeps them literal ('en' | 'ru') instead of widening +// to string: @nuxtjs/i18n types the locale list by the literal codes it knows, +// and a plain string[] here fails to typecheck in every slice config. +export const LOCALES = [ + { code: 'en' as const, file: 'en.json' }, + { code: 'ru' as const, file: 'ru.json' }, +]; + +/** The locale translations are written in; every other locale is derived from it. */ +export const SOURCE_LOCALE = 'en'; diff --git a/app/slices/template/i18n/locales/ru.json b/app/slices/template/i18n/locales/ru.json new file mode 100644 index 00000000..91206961 --- /dev/null +++ b/app/slices/template/i18n/locales/ru.json @@ -0,0 +1,6 @@ +{ + "templates": "Шаблоны", + "template_name": "Название шаблона", + "template_image": "Docker-образ", + "template_description": "Описание" +} diff --git a/app/slices/template/nuxt.config.ts b/app/slices/template/nuxt.config.ts index 4adc9df3..da81b22e 100644 --- a/app/slices/template/nuxt.config.ts +++ b/app/slices/template/nuxt.config.ts @@ -1,6 +1,8 @@ import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { LOCALES } from '../setup/i18n/locales'; + const currentDir = dirname(fileURLToPath(import.meta.url)); export default defineNuxtConfig({ @@ -13,6 +15,6 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/i18n'], i18n: { langDir: 'locales', - locales: [{ code: 'en', file: 'en.json' }], + locales: LOCALES, }, }); diff --git a/bun.lock b/bun.lock index 256a484c..aab06eb9 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@cleanslice/paddock": "^0.2.3", }, "devDependencies": { + "@anthropic-ai/sdk": "^0.117.1", "turbo": "^2", }, }, @@ -158,7 +159,7 @@ "@antfu/ni": ["@antfu/ni@0.21.4", "", { "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nu": "bin/nu.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs" } }, "sha512-O0Uv9LbLDSoEg26fnMDdDRiPwFJnQSoD4WnrflDwKCJm8Cx/0mV4cGxwBLXan5mGIrpK4Dd7vizf4rQm0QCEAA=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.79.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ietmtM6glcnnrWq26H+BZm8J07iay9Cob6hRzDTr/A9QWF1m2T//TQhFO4MTKcZht2/7LS8bG9wUYEhcizKRnA=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.117.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg=="], "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -1148,6 +1149,8 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@swc/cli": ["@swc/cli@0.6.0", "", { "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^13.0.5", "commander": "^8.3.0", "fast-glob": "^3.2.5", "minimatch": "^9.0.3", "piscina": "^4.3.1", "semver": "^7.3.8", "slash": "3.0.0", "source-map": "^0.7.3" }, "peerDependencies": { "@swc/core": "^1.2.66", "chokidar": "^4.0.1" }, "optionalPeers": ["chokidar"], "bin": { "swc": "bin/swc.js", "swcx": "bin/swcx.js", "spack": "bin/spack.js" } }, "sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw=="], @@ -2212,6 +2215,8 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -3304,6 +3309,8 @@ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], @@ -3792,6 +3799,8 @@ "@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@cleanslice/paddock/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.79.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ietmtM6glcnnrWq26H+BZm8J07iay9Cob6hRzDTr/A9QWF1m2T//TQhFO4MTKcZht2/7LS8bG9wUYEhcizKRnA=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@dxup/nuxt/@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="], diff --git a/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md b/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md new file mode 100644 index 00000000..4eb7d443 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md @@ -0,0 +1,185 @@ +# i18n strategy — design (ADR) + +**Date:** 2026-08-19 +**Ticket:** CLEAN-33 +**Status:** approved (decisions confirmed interactively) + +## Problem + +`@nuxtjs/i18n` v9 is installed in both consoles and every slice carries an +`i18n/locales/en.json`, so the setup reads as "translations exist, only the +locale list is missing". The code says otherwise: + +| | admin | app | +|---|---|---| +| locale files | 15 | 4 | +| keys in them | 47 | 23 | +| `.vue` files | 272 | 35 | +| `.vue` calling `t()` | 2 | 3 | + +Six of the 15 admin locale files are empty (`{}`), `i18n.config.ts` ships +`messages: { en: {} }`, and no console renders a locale switcher. Every visible +string is hardcoded English in the templates — a few hundred in `app`, low +thousands in `admin`. + +So this is not "top up the missing translations". It is a from-scratch string +extraction, and the expensive half is the extraction, not the translating — +translation of short UI strings is exactly what an LLM does well. + +## Decisions + +### 1. Only `app/` gets localized. `admin/` stays English-only. + +`app/` is the customer-facing console; `admin/` is an internal operator tool +whose users are the team. Localizing `admin` would mean touching ~270 component +files to serve people who read English anyway. + +This is a decision, not a deferral: new `admin` code keeps writing plain English +in templates, and no `admin` slice gains a `ru.json`. If that ever changes, the +tooling below already works — only the slice list widens. + +### 2. Locales: `en` (default) + `ru`. + +`en` stays `defaultLocale`. `strategy: 'no_prefix'` is kept — `app` is a SPA +(`ssr: false`), so `/ru/` route prefixes would add routing surface with no SEO +payoff. `detectBrowserLanguage` is already configured with the `i18n_redirected` +cookie, so a Russian browser lands on `ru` without user action. + +A manual switcher still ships in the app shell +(`app/slices/common/components/layout/Provider.vue`): browser detection is a +guess, and a user whose browser is English must be able to choose `ru`. + +### 3. The locale list lives in one constant. + +```ts +// app/slices/setup/i18n/locales.ts +export const LOCALES = [ + { code: 'en', file: 'en.json' }, + { code: 'ru', file: 'ru.json' }, +]; +``` + +Every app slice does `i18n: { langDir: 'locales', locales: LOCALES }`. Adding a +third language is a one-line edit instead of one edit per slice, and the slices +cannot drift out of sync with each other. + +Consequence: because the list is shared, every slice that registers `LOCALES` +needs both files on disk — a declared locale with no file breaks the build. +Slices with nothing extracted yet carry an empty `ru.json` (`{}`); slices with no +`i18n` block at all (`chat`, `user`) stay out until their extraction turn. + +### 4. Translations are generated by a local script, verified in CI. + +`bun run i18n:sync` (`scripts/i18n-sync.ts`) walks `app/slices/*/i18n/locales/`, +diffs `en.json` against `ru.json`, sends **only** the missing or stale keys to +Claude in one request per slice, and writes the result back in `en.json`'s key +order so the two files read side by side. It runs under bun rather than node so +it can import `LOCALES` straight from the slice config — the script and the app +cannot disagree about which languages exist. + +Staleness is tracked in `app/i18n.sync.json`: a hash of the English value at the +time each key was translated. Without it the script would only ever see *new* +keys, and an edited English string would silently keep its old Russian text. One +manifest for the whole console, rather than a bookkeeping file per slice. + +A key the manifest has never recorded is **adopted**, not retranslated: if +someone writes or fixes a translation by hand, sync records its hash and leaves +the wording alone. Only a *changed* English source (recorded hash ≠ current) +sends a key back to the model. Without this rule the first sync after any manual +correction would quietly overwrite it. + +`bun run i18n:check` is the same script with `--check`: it compares key sets and +hashes and exits non-zero listing what drifted. It makes no network calls and +needs no API key, so it runs as an ordinary CI step and works on forks. + +Rejected alternatives: + +- **Autotranslate in CI** (a bot commits `ru.json` into the PR) — removes the + "I forgot" failure mode, but needs an LLM key in repository secrets and push + rights for the Action, and lands translations after code review has already + happened. The `--check` step closes the same hole without either cost. +- **Translating by hand** — defensible at today's volume, but it makes every + feature PR carry manual work, and that is what makes translations rot. + +The script reads `CLAUDE_API_KEY` from `.env.project` (already present, already +gitignored) or from the environment, and calls `claude-opus-5` through +`@anthropic-ai/sdk`. It refuses to run without a key rather than silently +producing nothing. Re-running with no source changes produces an empty diff. + +### 5. Key conventions. + +Namespacing stays per-slice, as the existing files already do: keys are grouped +under a feature object (`chat.empty_title`), `snake_case`, at most two levels +deep. Interpolation uses named placeholders (`{name}`), never positional ones, +because a translator — human or model — reorders words. + +Templates call the injected `$t` (`globalInjection` is on by default), so +components do not bind `const { t } = useI18n()`. `useI18n()` stays only where a +component genuinely needs the locale itself — the switcher needs `locale`, +`locales` and `setLocale`. + +### 6. Copy computed in script is held as a key, not as text. + +Validation messages, state-dependent headings, button labels that change with a +pending flag — script decides *which* string, never *what* it says: + +```ts +// script: pick the key +const error = computed(() => (invalidEmail.value ? 'auth.email_invalid' : null)); +const heading = computed(() => (isRegister.value ? 'auth.create_title' : 'auth.welcome_title')); +``` + +```vue + +

{{ $t(error) }}

+

{{ $t(heading) }}

+``` + +The alternative — calling `t()` inside the script — drags the composable back +into every component and, worse, leaves user-visible copy inside branching +logic, where the extraction sweep does not find it. This rule is what makes +"all translatable text lives in the locale files" true rather than aspirational. + +## Out of scope (deliberately) + +- **`admin/`** — see decision 1. +- **The language the agent answers in.** This is not UI i18n: it is a property of + the agent (its system prompt / a setting on the agent), and translating the + chat frame does not change what the model writes back. Separate ticket; this + ADR only draws the line. +- **API error text.** `api/` keeps returning English messages and machine-readable + codes. Where a message reaches the user, the console maps the code to a + localized string through the existing `app/slices/setup/error/data/error.mapper.ts`. + +## Rollout + +CLEAN-33 delivers the mechanism plus one slice as the worked example: + +1. `LOCALES` constant, wired into every app slice; `ru.json` in all of them. +2. `scripts/i18n-sync.ts` + `i18n:sync` / `i18n:check` scripts. +3. The `i18n:check` step in `.github/workflows/ci.yaml`. +4. Locale switcher in the app shell, and `fallbackLocale` so a slice that is + mid-extraction degrades to English instead of showing raw key paths. +5. `bridle` and `common` extracted end to end, plus `ru` for the 23 keys that + already existed. `bridle` alone turned out to be two strings — the chat was + nearly extracted already — so the pilot took `common` as well: it is the + shell the switcher lives in plus the landing page, i.e. the first screen a + customer sees, and a half-translated landing reads worse than either + extreme. + +Remaining slices, one PR each, in the order the user meets them: +`agent` → `chat` → `user` → `template`. Slices `chat` and `user` have no `i18n` +block at all yet; they get one when their turn comes. + +## Acceptance criteria + +- `app` renders Russian for a `ru` browser and English otherwise; the switcher + overrides both and survives a reload. +- `bun run i18n:sync` is idempotent — a second run with no source change writes + nothing. +- `bun run i18n:check` fails when a key is added to `en.json` without syncing, + when an existing English value is edited without re-syncing, and when a key + removed from `en.json` is left behind in `ru.json`. +- A translation written or corrected by hand survives the next `i18n:sync`. +- CI runs `i18n:check` without any new repository secret. +- No `admin` slice gains a `ru.json`. diff --git a/package.json b/package.json index 3c4f3ecb..b4657458 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,12 @@ "dev:api": "turbo dev --filter=api", "dev:app": "turbo dev --filter=app", "dev:admin": "turbo dev --filter=admin", + "i18n:sync": "bun scripts/i18n-sync.ts", + "i18n:check": "bun scripts/i18n-sync.ts --check", "release": "npm version --message 'chore(release): v%s'" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.117.1", "turbo": "^2" }, "dependencies": { diff --git a/scripts/i18n-sync.ts b/scripts/i18n-sync.ts new file mode 100644 index 00000000..5d325028 --- /dev/null +++ b/scripts/i18n-sync.ts @@ -0,0 +1,325 @@ +/** + * Keeps the app console's non-English locale files in step with `en.json`. + * + * bun run i18n:sync translate whatever is missing or stale, write it back + * bun run i18n:check verify only — no network, no API key, CI-safe + * + * `en.json` is written by hand and is the source of truth. Every other locale + * is generated from it, one Claude request per slice per locale, and only for + * the keys that actually need work. + * + * Staleness lives in app/i18n.sync.json: the hash of the English value at the + * time a key was translated. Without it we'd only ever notice *new* keys, and + * an edited English string would silently keep its outdated translation. + * + * Run with bun (not node) — it imports the LOCALES constant straight from the + * TypeScript slice config, so the script and the app can't disagree about which + * languages exist. + */ +import { createHash } from 'node:crypto'; +import { + existsSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { LOCALES, SOURCE_LOCALE } from '../app/slices/setup/i18n/locales'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SLICES_DIR = join(ROOT, 'app', 'slices'); +const MANIFEST_PATH = join(ROOT, 'app', 'i18n.sync.json'); +const ENV_FILE = join(ROOT, '.env.project'); + +const MODEL = 'claude-opus-5'; + +type FlatMessages = Record; +type Manifest = { version: number; hashes: Record }; + +/** Slice with a locale directory: `app/slices/bridle` → `.../i18n/locales`. */ +type Slice = { name: string; localesDir: string }; + +// ---------------------------------------------------------------- json utils + +function readJson(path: string, fallback: T): T { + if (!existsSync(path)) return fallback; + return JSON.parse(readFileSync(path, 'utf8')) as T; +} + +/** 2-space + trailing newline, matching the hand-written locale files. */ +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +/** `{ chat: { send: 'Send' } }` → `{ 'chat.send': 'Send' }`. */ +function flatten(value: unknown, prefix = ''): FlatMessages { + const out: FlatMessages = {}; + if (value === null || typeof value !== 'object') return out; + for (const [key, child] of Object.entries(value as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + if (child !== null && typeof child === 'object') Object.assign(out, flatten(child, path)); + else if (typeof child === 'string') out[path] = child; + } + return out; +} + +/** + * Rebuilds nested JSON, walking `shape` (the English file) so the translated + * file keeps the same key order — the two read side by side in a diff. + */ +function nestLike(shape: unknown, flat: FlatMessages, prefix = ''): unknown { + const out: Record = {}; + for (const [key, child] of Object.entries(shape as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + if (child !== null && typeof child === 'object') out[key] = nestLike(child, flat, path); + else if (path in flat) out[key] = flat[path]; + } + return out; +} + +function hash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +// ------------------------------------------------------------------ discovery + +/** Every `/i18n/locales` directory under app/slices, at any nesting. */ +function findSlices(dir: string, out: Slice[] = []): Slice[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === 'node_modules') continue; + const path = join(dir, entry.name); + const localesDir = join(path, 'i18n', 'locales'); + if (existsSync(localesDir) && statSync(localesDir).isDirectory()) { + out.push({ name: relative(SLICES_DIR, path).replace(/\\/g, '/'), localesDir }); + continue; + } + findSlices(path, out); + } + return out; +} + +// ------------------------------------------------------------------- planning + +type Work = { + slice: Slice; + locale: string; + source: FlatMessages; + target: FlatMessages; + /** Keys the target is missing entirely. */ + missing: string[]; + /** Keys whose English text changed since they were translated. */ + stale: string[]; + /** Translated by hand, never recorded — adopted as-is, not re-translated. */ + unrecorded: string[]; + /** Keys the target has but English no longer does. */ + orphaned: string[]; +}; + +function plan(manifest: Manifest): Work[] { + const targets = LOCALES.filter((l) => l.code !== SOURCE_LOCALE); + const work: Work[] = []; + + for (const slice of findSlices(SLICES_DIR)) { + const sourceRaw = readJson>( + join(slice.localesDir, `${SOURCE_LOCALE}.json`), + {}, + ); + const source = flatten(sourceRaw); + + for (const { code, file } of targets) { + const target = flatten(readJson>(join(slice.localesDir, file), {})); + const known = manifest.hashes[`${slice.name}:${code}`] ?? {}; + + const present = Object.keys(source).filter((k) => k in target); + + work.push({ + slice, + locale: code, + source, + target, + missing: Object.keys(source).filter((k) => !(k in target)), + // A recorded hash that no longer matches means the English text was + // edited — retranslate. No recorded hash at all means somebody wrote + // this translation by hand, so adopt it instead of overwriting their + // wording with a fresh machine translation. + stale: present.filter((k) => known[k] !== undefined && known[k] !== hash(source[k]!)), + unrecorded: present.filter((k) => known[k] === undefined), + orphaned: Object.keys(target).filter((k) => !(k in source)), + }); + } + } + return work; +} + +// ---------------------------------------------------------------- translation + +function readApiKey(): string { + const fromEnv = process.env.CLAUDE_API_KEY ?? process.env.ANTHROPIC_API_KEY; + if (fromEnv) return fromEnv; + + if (existsSync(ENV_FILE)) { + // .env.project is CRLF on Windows checkouts — trim or the key carries a \r + // and every request comes back 401. + for (const line of readFileSync(ENV_FILE, 'utf8').split(/\r?\n/)) { + const match = /^CLAUDE_API_KEY=(.*)$/.exec(line.trim()); + if (match?.[1]) return match[1].trim(); + } + } + + throw new Error( + 'No CLAUDE_API_KEY. Add it to .env.project (gitignored) or export it, then re-run.', + ); +} + +const SYSTEM_PROMPT = [ + 'You translate UI strings for Ranch, a platform for deploying AI agents on Kubernetes.', + '', + 'Rules:', + '- Return ONLY a JSON object mapping each key you were given to its translation.', + '- Translate the value, never the key.', + '- Keep placeholders exactly as they appear: {name}, {count}, @:some.key, and any HTML.', + '- Do not translate product or component names: Ranch, Bridle, Paddock, Kubernetes, Argo Workflows, Docker.', + '- Match the register of a modern product UI: short, direct, no marketing filler.', + '- Keep the ending punctuation and letter case style of the source string.', + '- Button and menu labels stay short enough to fit the same control.', +].join('\n'); + +async function translate( + locale: string, + slice: string, + entries: FlatMessages, +): Promise { + const { default: Anthropic } = await import('@anthropic-ai/sdk'); + const client = new Anthropic({ apiKey: readApiKey() }); + + const request = [ + `Target language: ${locale}`, + `Context: these strings belong to the "${slice}" area of the app.`, + '', + JSON.stringify(entries, null, 2), + ].join('\n'); + + for (let attempt = 1; attempt <= 2; attempt++) { + const response = await client.messages.create({ + model: MODEL, + max_tokens: 16000, + output_config: { effort: 'low' }, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: request }], + }); + + const text = response.content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text) + .join('') + .trim() + .replace(/^```(?:json)?\s*|\s*```$/g, ''); + + try { + const parsed = JSON.parse(text) as Record; + const missing = Object.keys(entries).filter((k) => typeof parsed[k] !== 'string'); + if (missing.length) throw new Error(`missing keys in reply: ${missing.join(', ')}`); + return parsed as FlatMessages; + } catch (error) { + if (attempt === 2) throw new Error(`${slice} → ${locale}: ${(error as Error).message}`); + } + } + + throw new Error('unreachable'); +} + +// ---------------------------------------------------------------------- modes + +function report(work: Work[]): number { + let problems = 0; + + for (const item of work) { + const lines: string[] = []; + if (item.missing.length) lines.push(` missing (${item.missing.length}): ${item.missing.join(', ')}`); + if (item.stale.length) lines.push(` stale (${item.stale.length}): ${item.stale.join(', ')}`); + if (item.unrecorded.length) + lines.push(` unrecorded (${item.unrecorded.length}): ${item.unrecorded.join(', ')}`); + if (item.orphaned.length) lines.push(` orphaned (${item.orphaned.length}): ${item.orphaned.join(', ')}`); + if (!lines.length) continue; + + problems += + item.missing.length + item.stale.length + item.unrecorded.length + item.orphaned.length; + console.error(`${item.slice.name} → ${item.locale}`); + for (const line of lines) console.error(line); + } + + if (problems) { + console.error(`\n${problems} problem(s). Run: bun run i18n:sync`); + return 1; + } + console.log(`i18n: ${work.length} slice/locale pair(s) in sync.`); + return 0; +} + +async function sync(work: Work[], manifest: Manifest): Promise { + let translated = 0; + let adopted = 0; + + for (const item of work) { + const todo = [...item.missing, ...item.stale]; + const key = `${item.slice.name}:${item.locale}`; + const merged: FlatMessages = { ...item.target }; + for (const orphan of item.orphaned) delete merged[orphan]; + + if (todo.length) { + const payload = Object.fromEntries(todo.map((k) => [k, item.source[k]!])); + console.log(`${key}: translating ${todo.length} key(s)…`); + Object.assign(merged, await translate(item.locale, item.slice.name, payload)); + translated += todo.length; + } + if (item.orphaned.length) console.log(`${key}: dropped ${item.orphaned.length} orphaned key(s)`); + if (item.unrecorded.length) { + console.log(`${key}: adopting ${item.unrecorded.length} hand-written key(s)`); + adopted += item.unrecorded.length; + } + + if (todo.length || item.orphaned.length) { + const shape = readJson>( + join(item.slice.localesDir, `${SOURCE_LOCALE}.json`), + {}, + ); + const file = LOCALES.find((l) => l.code === item.locale)!.file; + writeJson(join(item.slice.localesDir, file), nestLike(shape, merged)); + } + + // Record only what the target file actually holds, so a key that failed to + // translate stays reported as missing instead of being silently blessed. + manifest.hashes[key] = Object.fromEntries( + Object.keys(item.source) + .filter((k) => k in merged) + .sort() + .map((k) => [k, hash(item.source[k]!)]), + ); + } + + writeJson(MANIFEST_PATH, { + version: manifest.version, + hashes: Object.fromEntries(Object.entries(manifest.hashes).sort(([a], [b]) => a.localeCompare(b))), + }); + + const summary = [ + translated ? `translated ${translated} key(s)` : '', + adopted ? `adopted ${adopted} key(s)` : '', + ].filter(Boolean); + console.log(summary.length ? `i18n: ${summary.join(', ')}.` : 'i18n: nothing to do.'); +} + +// ----------------------------------------------------------------------- main + +const checkOnly = process.argv.includes('--check'); +const manifest = readJson(MANIFEST_PATH, { version: 1, hashes: {} }); +const work = plan(manifest); + +if (checkOnly) { + process.exit(report(work)); +} else { + await sync(work, manifest); +}