Skip to main content

Frontend Multi-language via Curated Catalogs (vue-i18n), Not Runtime Machine Translation

Context

Both Vue 3 SPAs (frontends/user, frontends/admin) ship with zero i18n: page chrome is hardcoded English while the shared error-message catalog (src/services/messages/zh-CN.ts) is hardcoded Chinese with DEFAULT_LOCALE = 'zh-CN' — the rendered UI is mixed-language today. The docs site already runs a dual-locale convention (Docusaurus defaultLocale: 'en' + a zh-CN mirror tree, same-PR dual writes). We evaluated automatic-translation widgets — primarily translate.js (xnx3/translate) — against curated message catalogs before investing in an extraction pass over all 27 views.

Key findings on translate.js (v4, MIT, actively maintained): it walks the DOM after load and sends page text to the author's proprietary cloud (api.translate.zvo.cn et al.) for machine translation; the free channel has a daily character cap; translation happens after first paint (FOUC by design); the Vue adapters have open unresolved issues (#54, #94); and it rewrites input value attributes — a UX hazard on credential forms.

Decision

Adopt curated message catalogs with vue-i18n v11 (Composition API, legacy: false, globalInjection: true) as the single i18n mechanism for both frontends. Locale set: en (default) + zh-CN now, extensible to a tier-2 set (zh-TW, ja, de, es, fr, pt-BR, ru) later. Runtime machine-translation widgets are rejected for the product UI.

Rationale: fulla is a security-sensitive identity provider — consent/scope/authorization terminology must be curated, page content must not egress to third-party translation clouds, self-hosted/air-gapped deployments (the IdP norm) must work offline, and rendered text must be deterministic and reviewable in PRs. translate.js is MIT-licensed and could technically be embedded, but its data flow alone disqualifies it for this product. vue-i18n is the de-facto Vue standard (MIT, ~3.9M downloads/week), needs no extra build tooling at our catalog size (~10–14 KB brotli runtime), and matches the existing per-locale resource-file design of the error catalog.

Consequences and Current State

  • One language switcher per app drives page chrome and newly surfaced error messages: getErrorMessage(code) resolves in the active UI locale at the moment the error triggers. An already-rendered message is a snapshot — switching locale afterwards does not re-translate text on screen (this behavior is locked by e2e; a fully reactive rewrite that stores error codes and re-resolves at render is a tracked follow-up option). DEFAULT_LOCALE becomes the fallback table (en).
  • Locale is detected from localStorage['fulla-locale'] (mirrors the fulla-theme pattern) → navigator.languagesen, applied synchronously before mount (no flash), and synced to <html lang>.
  • Catalogs are dual-written (en + zh-CN) in the same PR, enforced by key-parity unit tests; the mirrored-services and components/ui byte-sync gates keep both apps identical where shared.
  • Machine translation remains permissible in the translation workflow (pre-translation with human review when adding a locale), never in the runtime.
  • One-time cost: extraction of all user-visible strings across 27 views plus e2e assertions that pinned Chinese error text move to the English catalog (default locale); both languages are then covered by a dedicated i18n.spec.ts per app.