From 08430536c2355b590ae4c53bf246cd1147095fb2 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Wed, 19 Aug 2026 14:45:31 +0300 Subject: [PATCH 1/5] =?UTF-8?q?docs(spec):=20i18n=20strategy=20ADR=20?= =?UTF-8?q?=E2=80=94=20app-only,=20en+ru,=20synced=20translations=20(CLEAN?= =?UTF-8?q?-33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The infra reads as "translations exist, locale list missing", but only 5 of 307 .vue files across both consoles call t() and the locale files hold 70 keys total. The real work is extracting hardcoded strings, not translating them, so the ADR decides scope before tooling. Decisions: localize app/ only (admin stays English by choice, not by deferral); en + ru with no_prefix; one shared LOCALES constant instead of per-slice duplication; translations generated by a local i18n:sync script against claude-opus-5 and enforced by a network-free i18n:check step in CI. Agent answer language and API error text are explicitly out of scope. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-19-i18n-strategy-design.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-i18n-strategy-design.md 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 0000000..6132d45 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md @@ -0,0 +1,143 @@ +# 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.mjs`) 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 sorted. + +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. + +`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. + +## 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.mjs` + `i18n:sync` / `i18n:check` scripts. +3. The `i18n:check` step in `.github/workflows/ci.yaml`. +4. Locale switcher in the app shell. +5. `bridle` extracted end to end — the chat is the screen customers actually + live in — plus `ru` for the 23 keys that already existed. + +Remaining slices, one PR each, in the order the user meets them: +`common` (landing + shell) → `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, + and when an existing English value is edited without re-syncing. +- CI runs `i18n:check` without any new repository secret. +- No `admin` slice gains a `ru.json`. From fdc9a9097175c932952fb9fdd940343097408ad2 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Wed, 19 Aug 2026 14:59:16 +0300 Subject: [PATCH 2/5] feat(app): en+ru locale pipeline with CI parity check (CLEAN-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ADR: one LOCALES constant every app slice registers, a sync script that translates only what drifted, and a network-free CI check so translations can't silently fall behind. scripts/i18n-sync.ts diffs each slice's en.json against ru.json and sends only missing or stale keys to claude-opus-5, one request per slice, writing the result back in en.json's key order. Staleness comes from app/i18n.sync.json (hash of the English value when it was translated) — without it an edited English string would keep its old translation forever. A key the manifest never recorded is adopted rather than retranslated, so a hand-written or hand-corrected translation is not overwritten on the next run. --check compares key sets and hashes only: no network, no API key, safe on forks. Extraction covers bridle (2 strings left) and common in full — shell, landing hero, demo card and landing page — since common is where the switcher lives and a half-translated first screen reads worse than an English one. The chat input hint keeps its two caps as i18n-t slots so the sentence stays one translatable string. fallbackLocale makes any not-yet-extracted key render English instead of a raw path. Russian for this pass was written by hand and adopted by the script: the CLAUDE_API_KEY in .env.project is rejected by the API (401, verified against /v1/messages directly), so the translate path itself has not run yet. Everything else is verified: nuxt typecheck and nuxt build are clean, the ru strings appear in the built client chunks, and i18n:check was exercised against all three drift modes (missing, stale, orphaned). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 5 + app/i18n.sync.json | 84 +++++ app/slices/agent/i18n/locales/ru.json | 8 + app/slices/agent/nuxt.config.ts | 4 +- .../bridle/components/bridleChat/Input.vue | 21 +- .../bridle/components/bridleChat/Provider.vue | 2 +- app/slices/bridle/i18n/locales/en.json | 2 + app/slices/bridle/i18n/locales/ru.json | 14 + app/slices/bridle/nuxt.config.ts | 4 +- .../components/landingHero/AgentCard.vue | 12 +- .../components/landingHero/Provider.vue | 29 +- .../common/components/layout/Provider.vue | 41 ++- app/slices/common/i18n/locales/en.json | 72 +++- app/slices/common/i18n/locales/ru.json | 71 ++++ app/slices/common/nuxt.config.ts | 4 +- app/slices/common/pages/index.vue | 94 ++--- app/slices/setup/i18n/i18n/i18n.config.ts | 14 +- app/slices/setup/i18n/locales.ts | 25 ++ app/slices/template/i18n/locales/ru.json | 6 + app/slices/template/nuxt.config.ts | 4 +- bun.lock | 11 +- .../specs/2026-08-19-i18n-strategy-design.md | 35 +- package.json | 3 + scripts/i18n-sync.ts | 325 ++++++++++++++++++ 24 files changed, 774 insertions(+), 116 deletions(-) create mode 100644 app/i18n.sync.json create mode 100644 app/slices/agent/i18n/locales/ru.json create mode 100644 app/slices/bridle/i18n/locales/ru.json create mode 100644 app/slices/common/i18n/locales/ru.json create mode 100644 app/slices/setup/i18n/locales.ts create mode 100644 app/slices/template/i18n/locales/ru.json create mode 100644 scripts/i18n-sync.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6703df2..ff46251 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 0000000..ba9dd67 --- /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 0000000..cd7fd4a --- /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 f81dffa..1075d3d 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/Input.vue b/app/slices/bridle/components/bridleChat/Input.vue index 4ece308..59dcd15 100644 --- a/app/slices/bridle/components/bridleChat/Input.vue +++ b/app/slices/bridle/components/bridleChat/Input.vue @@ -66,12 +66,21 @@ watch(draft, () => nextTick(autoResize)); /> -

- Enter - to send, - Shift+Enter - for newline -

+ + + + + diff --git a/app/slices/bridle/components/bridleChat/Provider.vue b/app/slices/bridle/components/bridleChat/Provider.vue index 13ecf7f..87f3a49 100644 --- a/app/slices/bridle/components/bridleChat/Provider.vue +++ b/app/slices/bridle/components/bridleChat/Provider.vue @@ -107,7 +107,7 @@ const agentInitial = computed(() => { {{ title ?? t('chat.agent') }}

- Say hello — your message will go straight to the agent runtime. + {{ t('chat.starter_hint') }}

diff --git a/app/slices/bridle/i18n/locales/en.json b/app/slices/bridle/i18n/locales/en.json index 48ed03a..6a1ebb6 100644 --- a/app/slices/bridle/i18n/locales/en.json +++ b/app/slices/bridle/i18n/locales/en.json @@ -2,7 +2,9 @@ "chat": { "empty_title": "Pick an agent", "empty_hint": "Select one of your agents on the left to start chatting.", + "starter_hint": "Say hello — your message will go straight to the agent runtime.", "placeholder": "Message your agent…", + "input_hint": "{enter} to send, {shiftEnter} for newline", "send": "Send", "sending": "Thinking…", "error": "Failed to reach agent", diff --git a/app/slices/bridle/i18n/locales/ru.json b/app/slices/bridle/i18n/locales/ru.json new file mode 100644 index 0000000..5437682 --- /dev/null +++ b/app/slices/bridle/i18n/locales/ru.json @@ -0,0 +1,14 @@ +{ + "chat": { + "empty_title": "Выберите агента", + "empty_hint": "Выберите одного из своих агентов слева, чтобы начать разговор.", + "starter_hint": "Поздоровайтесь — сообщение уйдёт прямо в рантайм агента.", + "placeholder": "Напишите агенту…", + "input_hint": "{enter} — отправить, {shiftEnter} — новая строка", + "send": "Отправить", + "sending": "Думает…", + "error": "Не удалось связаться с агентом", + "you": "Вы", + "agent": "Агент" + } +} diff --git a/app/slices/bridle/nuxt.config.ts b/app/slices/bridle/nuxt.config.ts index cfc2d7d..55a2d8d 100644 --- a/app/slices/bridle/nuxt.config.ts +++ b/app/slices/bridle/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/common/components/landingHero/AgentCard.vue b/app/slices/common/components/landingHero/AgentCard.vue index b6ffd00..464641f 100644 --- a/app/slices/common/components/landingHero/AgentCard.vue +++ b/app/slices/common/components/landingHero/AgentCard.vue @@ -35,14 +35,14 @@
- Hi! I'm ready to help you ship. + {{ t('demo.agent_greeting') }}
- Deploy a new worker to staging. + {{ t('demo.user_request') }}
- Submitting workflow… + {{ t('demo.agent_working') }}
-
CPU
+
{{ t('demo.cpu') }}
{{ agent.resources?.cpu ?? '—' }}
-
Memory
+
{{ t('demo.memory') }}
{{ agent.resources?.memory ?? '—' }}
@@ -82,6 +82,8 @@ import type { IAgentData } from '#agent/stores/agent'; const props = defineProps<{ agent: IAgentData }>(); +const { t } = useI18n(); + const initials = computed(() => props.agent.name .split(/\s+/) diff --git a/app/slices/common/components/landingHero/Provider.vue b/app/slices/common/components/landingHero/Provider.vue index 9b2f791..7a3dfb7 100644 --- a/app/slices/common/components/landingHero/Provider.vue +++ b/app/slices/common/components/landingHero/Provider.vue @@ -10,19 +10,17 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs text-muted-foreground mb-6" > - Live on Kubernetes · Powered by Argo Workflows + {{ t('hero.badge') }}

- Deploy AI agents. + {{ t('hero.title') }}
- Talk to them. + {{ t('hero.title_accent') }}

- Ranch is your agent deployment platform. Spin up containerized - AI workers on a managed k3s cluster and chat with them in real - time — no DevOps required. + {{ t('hero.lede') }}

@@ -30,37 +28,43 @@ to="/agents" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground px-5 py-3 text-sm font-medium hover:opacity-90 transition" > - Open full dashboard → + {{ t('hero.cta_dashboard') }} - Deploy an agent + {{ t('hero.cta_deploy') }} - Sign in to deploy + {{ t('hero.cta_sign_in') }}
-
Agents
+
+ {{ t('hero.stat_agents') }} +
{{ agentStore.publicAgents.length }}
-
Running
+
+ {{ t('hero.stat_running') }} +
{{ runningCount }}
-
Uptime
+
+ {{ t('hero.stat_uptime') }} +
99.9%
@@ -92,6 +96,7 @@ import type { IAgentData } from '#agent/stores/agent'; const agentStore = useAgentStore(); const authStore = useAuthStore(); +const { t } = useI18n(); await useAsyncData('landing-agents', () => agentStore.fetchPublic()); diff --git a/app/slices/common/components/layout/Provider.vue b/app/slices/common/components/layout/Provider.vue index 8950ac8..9ce1759 100644 --- a/app/slices/common/components/layout/Provider.vue +++ b/app/slices/common/components/layout/Provider.vue @@ -10,7 +10,7 @@ >
- Ranch + {{ t('app_name') }}
+ + +
- Sign in + {{ t('auth.sign_in') }} - Sign up + {{ t('auth.sign_up') }}
@@ -87,13 +105,13 @@
-
© {{ year }} Ranch — built on CleanSlice.
+
{{ t('footer.note', { year }) }}
- Agents + {{ t('nav.agents') }} - Templates + {{ t('nav.templates') }}
@@ -104,8 +122,13 @@ diff --git a/app/slices/setup/i18n/i18n/i18n.config.ts b/app/slices/setup/i18n/i18n/i18n.config.ts index a8e1ec5..0a921a9 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 0000000..eef12a6 --- /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 0000000..9120696 --- /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 4adc9df..da81b22 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 256a484..aab06eb 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 index 6132d45..6a534cc 100644 --- a/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md +++ b/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md @@ -70,15 +70,24 @@ Slices with nothing extracted yet carry an empty `ru.json` (`{}`); slices with n ### 4. Translations are generated by a local script, verified in CI. -`bun run i18n:sync` (`scripts/i18n-sync.mjs`) walks `app/slices/*/i18n/locales/`, +`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 sorted. +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. @@ -120,16 +129,20 @@ because a translator — human or model — reorders words. 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.mjs` + `i18n:sync` / `i18n:check` scripts. +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. -5. `bridle` extracted end to end — the chat is the screen customers actually - live in — plus `ru` for the 23 keys that already existed. +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: -`common` (landing + shell) → `agent` → `chat` → `user` → `template`. Slices -`chat` and `user` have no `i18n` block at all yet; they get one when their turn -comes. +`agent` → `chat` → `user` → `template`. Slices `chat` and `user` have no `i18n` +block at all yet; they get one when their turn comes. ## Acceptance criteria @@ -138,6 +151,8 @@ comes. - `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, - and when an existing English value is edited without re-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 3c4f3ec..b465745 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 0000000..5d32502 --- /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); +} From 823158a0894ecd99264947cbe0573e694d1fa851 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Wed, 19 Aug 2026 15:09:34 +0300 Subject: [PATCH 3/5] chore(app): regenerate ru locales through i18n:sync (CLEAN-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit shipped Russian written by hand because the API key in .env.project was rejected. With a working key the translate path has now actually run: ru.json and the manifest were deleted and rebuilt by `bun run i18n:sync` — 71 keys, one request per slice. Only wording moved (bridle, common); agent and template came back identical to the hand-written pass, and every {year} / {number} / {enter} / {shiftEnter} placeholder survived. One manual fix on top: "хабу bridle" → "хабу Bridle", the model lowercased a product name. Verified after regeneration: a second sync reports "nothing to do" (idempotent), the hand-corrected string is not overwritten (the adopt rule works on a real edit, not just in theory), and i18n:check is green. Co-Authored-By: Claude Opus 5 (1M context) --- app/slices/bridle/i18n/locales/ru.json | 6 ++-- app/slices/common/i18n/locales/ru.json | 46 +++++++++++++------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/slices/bridle/i18n/locales/ru.json b/app/slices/bridle/i18n/locales/ru.json index 5437682..75dec5f 100644 --- a/app/slices/bridle/i18n/locales/ru.json +++ b/app/slices/bridle/i18n/locales/ru.json @@ -1,9 +1,9 @@ { "chat": { "empty_title": "Выберите агента", - "empty_hint": "Выберите одного из своих агентов слева, чтобы начать разговор.", - "starter_hint": "Поздоровайтесь — сообщение уйдёт прямо в рантайм агента.", - "placeholder": "Напишите агенту…", + "empty_hint": "Выберите одного из своих агентов слева, чтобы начать общение.", + "starter_hint": "Поздоровайтесь — сообщение сразу уйдёт в среду выполнения агента.", + "placeholder": "Сообщение агенту…", "input_hint": "{enter} — отправить, {shiftEnter} — новая строка", "send": "Отправить", "sending": "Думает…", diff --git a/app/slices/common/i18n/locales/ru.json b/app/slices/common/i18n/locales/ru.json index 3095c34..4227f5f 100644 --- a/app/slices/common/i18n/locales/ru.json +++ b/app/slices/common/i18n/locales/ru.json @@ -14,14 +14,14 @@ "label": "Язык" }, "footer": { - "note": "© {year} Ranch — сделано на CleanSlice." + "note": "© {year} Ranch — создано на CleanSlice." }, "hero": { "badge": "Работает на Kubernetes · На базе Argo Workflows", "title": "Разворачивайте AI-агентов.", - "title_accent": "Говорите с ними.", - "lede": "Ranch — ваша платформа для развёртывания агентов. Поднимайте контейнерных AI-воркеров на управляемом кластере k3s и общайтесь с ними в реальном времени — без DevOps.", - "cta_dashboard": "Открыть дашборд →", + "title_accent": "Общайтесь с ними.", + "lede": "Ranch — платформа для развёртывания агентов. Запускайте контейнерных AI-воркеров в управляемом кластере k3s и общайтесь с ними в реальном времени — без DevOps.", + "cta_dashboard": "Открыть панель →", "cta_deploy": "Развернуть агента", "cta_sign_in": "Войдите, чтобы развернуть", "stat_agents": "Агенты", @@ -30,42 +30,42 @@ }, "demo": { "agent_greeting": "Привет! Готов помочь с релизом.", - "user_request": "Разверни нового воркера на staging.", + "user_request": "Разверни новый воркер в staging.", "agent_working": "Отправляю workflow…", "cpu": "CPU", "memory": "Память" }, "landing": { "what_eyebrow": "Что делает Ranch", - "what_title": "Инфраструктура для AI-агентов на Kubernetes", - "what_lede": "От образа до работающего пода и живого чата — Ranch берёт на себя инфраструктуру, а вы занимаетесь тем, что агенты должны делать на самом деле.", + "what_title": "Инфраструктура для AI-агентов на базе Kubernetes", + "what_lede": "От образа до работающего пода и живого чата — Ranch берёт на себя всю обвязку, а вы решаете, чем займутся ваши агенты.", "how_eyebrow": "Как это работает", - "how_title": "От шаблона до разговора за три шага", + "how_title": "От шаблона до диалога за три шага", "step": "Шаг {number}", "cta_title": "Ваше стадо ждёт.", - "cta_lede": "Заходите в дашборд, выбирайте агента и начинайте разговор.", - "cta_button": "Открыть дашборд →" + "cta_lede": "Откройте панель, выберите агента и начните диалог.", + "cta_button": "Открыть панель →" }, "features": { "templates_title": "Шаблоны агентов в один клик", - "templates_body": "Выберите Docker-образ, задайте CPU и память — Ranch отправит Argo Workflow, который развернёт под с вашим агентом.", + "templates_body": "Выберите образ Docker, задайте CPU и память — Ranch отправит Argo Workflow, который развернёт под агента.", "chat_title": "Живой чат по WebSocket", - "chat_body": "Каждый запущенный агент доступен через Socket.IO-шлюз с JWT-авторизацией и потоковыми ответами.", + "chat_body": "Каждый запущенный агент доступен через шлюз Socket.IO с JWT-авторизацией и потоковыми ответами.", "status_title": "Статус и жизненный цикл", - "status_body": "Разворачивается, работает, упал, остановлен — Ranch отслеживает фазу workflow и показывает её в интерфейсе.", - "restart_title": "Перезапуск без передеплоя", - "restart_body": "Отмените workflow, смените тег образа и перезапустите — запись агента останется нетронутой.", - "logs_title": "Стриминг логов", - "logs_body": "Читайте логи агента прямо из пода, не переключаясь в kubectl.", - "admin_title": "Мультитенантная админка", - "admin_body": "Отдельный интерфейс для шаблонов, пользователей, квот и всего флота агентов в кластере." + "status_body": "Развёртывание, работа, сбой, остановка — Ranch отслеживает фазу workflow и показывает её в интерфейсе.", + "restart_title": "Перезапуск без переразвёртывания", + "restart_body": "Отмените workflow, смените тег образа и перезапустите — запись агента останется прежней.", + "logs_title": "Потоковые логи", + "logs_body": "Читайте логи агента прямо из пода, не переключаясь на kubectl.", + "admin_title": "Мультитенантное администрирование", + "admin_body": "Панель администратора для управления шаблонами, пользователями, квотами и всем парком в кластере." }, "steps": { "template_title": "Выберите шаблон", - "template_body": "Берите готовые образы агентов или регистрируйте свои. В шаблоне уже заданы ресурсы и конфигурация по умолчанию.", + "template_body": "Просмотрите готовые образы агентов или добавьте свой. В шаблонах заданы ресурсы и конфигурация по умолчанию.", "deploy_title": "Разверните агента", - "deploy_body": "Ranch отправит Argo Workflow в ваш кластер k3s, а в дашборд вернётся поток статусов.", - "chat_title": "Начните разговор", - "chat_body": "Агент подключается к хабу Bridle. Откройте чат, отправьте сообщение и смотрите, как ответ приходит потоком." + "deploy_body": "Ranch отправляет Argo Workflow в ваш кластер k3s. Статус приходит в панель в реальном времени.", + "chat_title": "Начните диалог", + "chat_body": "Агент подключается к хабу Bridle. Откройте чат, отправьте сообщение и следите за потоковым ответом." } } From 644d596c23bfaeeb2b6ee5537cd82f7cd8ec419b Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Wed, 19 Aug 2026 16:13:16 +0300 Subject: [PATCH 4/5] refactor(app): use the injected $t in templates instead of a local binding (CLEAN-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vue-i18n injects $t into every component (globalInjection defaults to true and nothing disables it here), so `const { t } = useI18n()` was per-component ceremony for something already available in the template. Seven components drop the binding; Empty.vue loses its diff --git a/app/slices/bridle/components/bridleChat/Input.vue b/app/slices/bridle/components/bridleChat/Input.vue index 59dcd15..d51768a 100644 --- a/app/slices/bridle/components/bridleChat/Input.vue +++ b/app/slices/bridle/components/bridleChat/Input.vue @@ -1,7 +1,6 @@