Skip to content

feat(app): i18n strategy — app-only en+ru with a synced translation pipeline (CLEAN-33) - #30

Merged
maksymhryzodub-prog merged 5 commits into
mainfrom
feat/CLEAN-33-i18n-strategy
Aug 19, 2026
Merged

feat(app): i18n strategy — app-only en+ru with a synced translation pipeline (CLEAN-33)#30
maksymhryzodub-prog merged 5 commits into
mainfrom
feat/CLEAN-33-i18n-strategy

Conversation

@maksymhryzodub-prog

Copy link
Copy Markdown
Contributor

Implements CLEAN-33: https://dreamvention.atlassian.net/browse/CLEAN-33

The ticket read as "the i18n module is wired up, only the locale list is missing". The code said otherwise: across both consoles only 5 of 307 .vue files call t(), the locale files hold 70 keys total, six of them are empty {}, and no console renders a switcher. The real work is extracting hardcoded strings, not translating them — so this starts with a decision about scope.

Full reasoning: docs/superpowers/specs/2026-08-19-i18n-strategy-design.md.

Decisions

  • Only app/ gets localized. admin/ stays English by choice, not by deferral — it is an internal operator tool, and localizing it would mean touching ~270 files to serve people who read English anyway.
  • en (default) + ru, no_prefix strategy — app is a SPA, so /ru/ routes would add surface with no SEO payoff. detectBrowserLanguage already handles the first render; the new switcher lets a user override it.
  • One LOCALES constant every slice registers, instead of repeating the locale list per slice.
  • Translations are generated locally and verified in CI, rather than autotranslated by a bot in the PR — no LLM key in repository secrets, no bot commits, and the check still makes it impossible to forget.
  • Out of scope, deliberately: admin/, the language the agent answers in (that's a prompt/agent setting, not UI i18n), and API error text.

What's in here

  • 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 results back in en.json's key order.
  • app/i18n.sync.json — hash of the English value at translation time. Without it only new keys would ever be noticed and an edited English string would keep its old translation forever.
  • Adopt rule: a key the manifest never recorded is adopted, not retranslated — a hand-written or hand-corrected translation is never overwritten.
  • bun run i18n:check in CI — key sets and hashes only, no network and no API key, so it runs on forks too.
  • Locale switcher in the app shell + fallbackLocale, so a not-yet-extracted key renders English instead of a raw key path.
  • Extraction: bridle (2 strings were left) and common in full — shell, landing hero, demo card, landing page. common came along because the switcher lives there and a half-translated first screen reads worse than an English one.

Verification

  • nuxt typecheck and nuxt build clean; the Russian strings appear in the built client chunks.
  • i18n:check exercised against all three drift modes — new key, edited English source, key removed from en.json — each exits non-zero with the offending keys listed.
  • The translate path ran for real: ru.json and the manifest were deleted and rebuilt by the script (71 keys). A second run reports nothing to do, and a hand-correction on top of the output survives the next sync.

Follow-up

Remaining slices, one PR each, in the order a user meets them: agentchatusertemplate. chat and user have no i18n block yet; they get one when their turn comes.

🤖 Generated with Claude Code

maksymhryzodub-prog and others added 5 commits August 20, 2026 00:07
…(CLEAN-33)

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) <noreply@anthropic.com>
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 <kbd> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…nding (CLEAN-33)

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 <script setup>
entirely, and the layout keeps useI18n only for locale/locales/setLocale,
which the switcher genuinely needs in script.

Templates are the only place this applies — a string built in script
still needs the composable.

nuxt typecheck and nuxt build clean, ru strings still in the client
chunks, i18n:check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the rule the remaining slices will need: copy a component computes
— validation messages, state-dependent headings, pending button labels —
lives in script as a key, never as text, and the template renders it
with $t. Calling t() in script would drag useI18n back into every
component and, worse, leave user-visible copy inside branching logic
where an extraction sweep never finds it.

Also records that templates use the injected $t and that useI18n stays
only where a component needs the locale itself (the switcher).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maksymhryzodub-prog
maksymhryzodub-prog force-pushed the feat/CLEAN-33-i18n-strategy branch from aa788ec to 27a6083 Compare August 19, 2026 21:07
@maksymhryzodub-prog
maksymhryzodub-prog merged commit 1a36d52 into main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant