feat(i18n): add Vietnamese, with a dependency-free message catalog - #232
Open
quickbeard wants to merge 1 commit into
Open
feat(i18n): add Vietnamese, with a dependency-free message catalog#232quickbeard wants to merge 1 commit into
quickbeard wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andindex.tsx's console output.Try it:
It also auto-detects, so a Vietnamese machine needs no configuration.
Design decisions worth reviewing
Completeness is enforced by the type system.
en.tsis the source of truth;vi.tsis typedRecord<MessageKey, string>, so a key added to one and not the other failspnpm typecheck. That annotation is the guarantee — and it's why the catalogs are.tsmodules rather than JSON. It fired for real twice during this work.Resolution is lazy and memoized on first
t(), deliberately not aninitLocale(). ESM imports evaluate beforeindex.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 callst()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--langflag: argv would reintroduce it, and a stray--langwould have to be stripped beforeparsePullArgs(which errors on unknown flags) and before the passthroughdefault:case forwards argv to thecodevagent.Precedence is
CODEV_LANG→LC_ALL→LC_MESSAGES→LANG→Intl→en, each spelling normalized independently — a plain??chain over raw values would let an exported-but-emptyLC_ALLmask a goodLANG, the same traplib/proxy.tsdocuments forHTTP_PROXY/http_proxy.Scope is UI only, and the boundary is load-bearing rather than deferral. All
src/lib/diagnostic prose stays English becauseupload.ts#isRefreshableErrormatches onmsg.includes("Missing supabase_")and/failed \((\d{3})\)/— English message text produced byconst.tsand thebackend.ts/auth.tsthrowers. 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
TaskListencoded English conjugation inverb: {infinitive, present, past}, substituted into English word order ("Failed to " + infinitive + " " + label). Nothing outside English conjugates that way. Now aTaskVerbid selecting complete per-state messages.resetLocaleCache()in a test.ToolSelect,AuthMethod,AdminLogin,ManualCredentials,DoctorAppandSkillPushAppnow hold message keys and callt()during render.CheckList's hard-codedLABEL_WIDTH = 15and both credential forms'String.padEndnow derive from the active locale and render as Ink<Box width>— Yoga measures display width,padEndcounts UTF-16 code units.Confirm(" and "/", and "/", ") andformatToolListnow goes throughIntl.ListFormat. English output is byte-identical, so existing callers and tests are untouched.formatListPartsexists soConfirmcan keep the commands cyan while the separators render plain.tools.map((t) => …)params — they shadow the importedt, compile fine, and are a live trap for the next edit.Testing
vitest.config.tspinsenv: { CODEV_LANG: "en" }. Hundreds of existing assertions match English literals, and without the pin a developer whose machine isLANG=vi_VNwould get a red suite for no reason.tests/lib/i18n.test.ts— precedence,vi_VN.UTF-8normalization, 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 — andbuild && node dist/index.js --version). Manually verified: VI/EN help, OS auto-detect viaLANG=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, thedoctor.group.*titles, and thehelp.bodyblock.🤖 Generated with Claude Code