Skip to content

feat(i18n): add Vietnamese, with a dependency-free message catalog - #232

Open
quickbeard wants to merge 1 commit into
mainfrom
feat/i18n-vietnamese
Open

feat(i18n): add Vietnamese, with a dependency-free message catalog#232
quickbeard wants to merge 1 commit into
mainfrom
feat/i18n-vietnamese

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Why

CoDev Hub's users are largely internal Viettel engineers, but every user-facing string was hard-coded English, inlined at its point of use across ~30 Ink files. There was no i18n layer at all — no catalog, no locale resolution, no dependency.

This adds one and ships Vietnamese as the first non-English locale.

What

src/lib/i18n.ts + src/lib/locales/{en,vi}.ts — 227 keys, no new dependency. Converts ~20 components, all 11 app roots, help.ts, and index.tsx's console output.

Try it:

CODEV_LANG=vi codevhub --help

It also auto-detects, so a Vietnamese machine needs no configuration.

Design decisions worth reviewing

Completeness is enforced by the type system. en.ts is the source of truth; vi.ts is typed Record<MessageKey, string>, so a key added to one and not the other fails pnpm typecheck. That annotation is the guarantee — and it's why the catalogs are .ts modules rather than JSON. It fired for real twice during this work.

Resolution is lazy and memoized on first t(), deliberately not an initLocale(). ESM imports evaluate before index.tsx's body runs, so any module-level string would read the locale before an explicit init could set it — the Node-version gate fires before argv is even destructured and still calls t() safely. Every input is an environment variable, fixed before the process starts, so there's no ordering hazard. This is also why there's no --lang flag: argv would reintroduce it, and a stray --lang would have to be stripped before parsePullArgs (which errors on unknown flags) and before the passthrough default: case forwards argv to the codev agent.

Precedence is CODEV_LANGLC_ALLLC_MESSAGESLANGIntlen, each spelling normalized independently — a plain ?? chain over raw values would let an exported-but-empty LC_ALL mask a good LANG, the same trap lib/proxy.ts documents for HTTP_PROXY/http_proxy.

Scope is UI only, and the boundary is load-bearing rather than deferral. All src/lib/ diagnostic prose stays English because upload.ts#isRefreshableError matches on msg.includes("Missing supabase_") and /failed \((\d{3})\)/ — English message text produced by const.ts and the backend.ts/auth.ts throwers. Translating lib prose would silently break the upload retry. That coupling needs typed error codes first; keeping lib out of this round means we never touch it.

The visible consequence is real and intended: under CODEV_LANG=vi, codevhub doctor's findings and the interactive-terminal refusal still print English.

Structural changes the translation forced

  • TaskList encoded English conjugation in verb: {infinitive, present, past}, substituted into English word order ("Failed to " + infinitive + " " + label). Nothing outside English conjugates that way. Now a TaskVerb id selecting complete per-state messages.
  • Module-level label constants freeze at import — correct at runtime (the locale never changes mid-process) but unreachable by resetLocaleCache() in a test. ToolSelect, AuthMethod, AdminLogin, ManualCredentials, DoctorApp and SkillPushApp now hold message keys and call t() during render.
  • CheckList's hard-coded LABEL_WIDTH = 15 and both credential forms' String.padEnd now derive from the active locale and render as Ink <Box width> — Yoga measures display width, padEnd counts UTF-16 code units.
  • Hand-written English list grammar in Confirm (" and " / ", and " / ", ") and formatToolList now goes through Intl.ListFormat. English output is byte-identical, so existing callers and tests are untouched. formatListParts exists so Confirm can keep the commands cyan while the separators render plain.
  • Renamed several tools.map((t) => …) params — they shadow the imported t, compile fine, and are a live trap for the next edit.

Testing

vitest.config.ts pins env: { CODEV_LANG: "en" }. Hundreds of existing assertions match English literals, and without the pin a developer whose machine is LANG=vi_VN would get a red suite for no reason.

  • tests/lib/i18n.test.ts — precedence, vi_VN.UTF-8 normalization, the empty-var masking trap, unknown-locale fallback, interpolation, tCount, cache reset.
  • tests/lib/locales.test.ts — what types can't see: blank values, mismatched {placeholder} sets between locales, half-declared plurals.

All four gates pass (pnpm fix, typecheck, test — 1364 passed, 2 pre-existing skips — and build && node dist/index.js --version). Manually verified: VI/EN help, OS auto-detect via LANG=vi_VN.UTF-8, unknown-locale fallback, and the piped-stdin refusal exiting 1 without a React stack (AGENTS.md's standing rule for prompt work — no unit test can catch that class of bug).

I also rendered the Vietnamese frames to confirm the doctor field gutter and both credential forms align, and that brand names are untouched.

Please check

I wrote the Vietnamese translations myself — a native reviewer should check the terminology, particularly banner.tagline, the doctor.group.* titles, and the help.body block.

🤖 Generated with Claude Code

CoDev Hub's users are largely internal Viettel engineers, but every
user-facing string was hard-coded English inlined at its point of use.
There was no i18n layer at all.

Adds `src/lib/i18n.ts` plus `src/lib/locales/{en,vi}.ts` and converts the
Ink UI, `help.ts` and the dispatcher's console output. No new dependency.

`en.ts` is the source of truth; every other catalog is typed
`Record<MessageKey, string>`, so a key added to `en.ts` and not to `vi.ts`
is a `pnpm typecheck` failure. That annotation IS the completeness
guarantee, and it is why the catalogs are `.ts` modules rather than JSON.

Resolution is lazy and memoized on first `t()` — deliberately not an
`initLocale()`. ESM imports evaluate before `index.tsx`'s body runs, so
any module-level string would read the locale before an explicit init
could set it; the Node-version gate fires before argv is even
destructured and still calls `t()` safely. Every input is an environment
variable, fixed before the process starts, so there is no ordering hazard.
This is also why there is no `--lang` flag. Precedence is CODEV_LANG →
LC_ALL → LC_MESSAGES → LANG → Intl → en, each spelling normalized
independently so an exported-but-empty var cannot mask a good one.

Scope is the UI. All `src/lib/` diagnostic prose stays English, and that
boundary is load-bearing rather than deferral: `isRefreshableError`
matches on `"Missing supabase_"` and `/failed \((\d{3})\)/` — English text
produced by `const.ts` and the backend throwers — so translating lib prose
would silently break the upload retry. That coupling needs typed error
codes first.

Structural changes the translation forced:

- `TaskList` encoded English conjugation in `verb: {infinitive, present,
  past}`, substituted into English word order. Now a `TaskVerb` id
  selecting complete per-state messages.
- Module-level label constants freeze at import — correct at runtime but
  unreachable by `resetLocaleCache()` in a test. ToolSelect, AuthMethod,
  AdminLogin, ManualCredentials, DoctorApp and SkillPushApp now hold keys
  and call `t()` during render.
- `CheckList`'s hard-coded `LABEL_WIDTH = 15` and both credential forms'
  `String.padEnd` now derive from the active locale and render as Ink
  `<Box width>` — Yoga measures display width, padEnd counts UTF-16 code
  units.
- Hand-written English list grammar in `Confirm` and `formatToolList` now
  goes through `Intl.ListFormat`. English output is byte-identical.

`vitest.config.ts` pins `CODEV_LANG=en` so the existing English
assertions stay deterministic and a developer on a `vi_VN` machine does
not get a spurious red suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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