Table of Contents

Internationalization

The web client ships three locales — en, zh-Hant and zh-Hans — over one message shape, and it owns no route: the mechanism is a vue-i18n instance created in the i18n module, a boot file that picks the locale before the application mounts, a single switch function every locale change goes through, and two Node scripts that gate the production build. English is not one locale among three; it is the schema the other two are written against and the text every unlocalized path falls back to. The user-facing gesture that triggers a switch is documented under Language Selection SubMenu, and the vocabulary the translations are held to under Translation Remarks.

The Three Bundles

Each locale is a folder under the i18n directory holding one file per namespace plus an index file that re-exports them as a single object. The three folders carry identical file lists — the same twenty-two namespaces, from apiErrors and auth through tree and widgets — because a namespace's keys are added to all three locales in the same change. Keys are named namespace.area.element in lowerCamelCase and are split by the UI region they serve rather than by the source file that reads them.

English is the schema, mechanically and not by convention. wwwroot-src/src/i18n/schema.ts exports MessageSchema = typeof en, and each Chinese locale's index file annotates its own message object with that type, so a key missing from a Chinese namespace is a type error at the annotation rather than a silent English fallback at runtime. schema.d.ts augments vue-i18n's DefineLocaleMessage with the same shape, which registers it as the global message schema for call sites. Type checking is not part of the production build, however: wwwroot-src/package.json runs vue-tsc under a separate lint script, and build runs the two i18n scripts described below instead.

The instance is created with legacy: false, an initial locale of en and fallbackLocale: 'en'. The resolution chain is therefore current locale → English → the key string itself, so a key missing from a Chinese bundle renders English with no runtime signal, and a key missing everywhere renders as its own dotted name.

Quasar's own component texts are a parallel bundle with different code names, mapped in the same module: en to Quasar's en-US pack, zh-Hant to zh-TW, zh-Hans to zh-CN. All three packs are static imports rather than a dynamic import built from the locale code, because a template-literal import on a bare package specifier is not statically analysable by the bundler.

Applying a Locale

applyLocale(code) in the i18n module is the only place a locale change happens. It first maps its argument through normalizeLocale, falling back to en, and then does six things in one call:

  • sets the vue-i18n instance's locale ref;
  • calls Quasar's Lang.set() with the mapped language pack;
  • writes document.documentElement.lang, which is what selects the CJK glyph variant;
  • rebuilds the exported collator, an Intl.Collator for the new locale with base sensitivity and numeric ordering;
  • writes the locale into localStorage under hinc.lang, inside a try/catch because storage can be unavailable;
  • calls the re-title hook the router registered, so a browser tab parked on a page without navigating still gets its title in the new language.

normalizeLocale maps any language tag — the server's, the browser's, or a stored one — onto a supported locale or null. An exact match wins; otherwise a tag beginning en becomes en, and a tag beginning zh becomes zh-Hant when it carries the hant script subtag or a TW, HK or MO region, and zh-Hans otherwise. Anything else yields null. applyLocale and the boot file's browser-language fallback each turn that null into en; the boot file's read of the stored value instead treats it as a cache miss and asks the server, so an unsupported stored code takes the cold-start path rather than pinning English.

Only two modules call applyLocale: the boot file, and the app-state store's language action. The store updates its own languageCode optimistically, POSTs the new value, adopts the current and available the server echoes back, and only then applies the locale — so the UI text flips after the server has accepted the value, and a failed POST rolls the store reference back without any visible language change.

Boot

wwwroot-src/quasar.config.ts lists three boot files in the order auth, i18n, routine-toast. The i18n file is placed after auth because auth patches the global fetch for the 401 login gate, and the language request the i18n file makes must go through that patch on builds where login is enabled.

The boot file installs the plugin and then reads the hinc.lang value:

  • Cache hit. The cached locale is applied immediately, so the first painted frame is already translated, and a reconcile against the server is fired without being awaited. The server value is the source of truth, so a disagreement — the language was changed from another browser — repaints shortly after mount.
  • Cache miss. The file returns a promise racing the language request against a four-second timeout, and mount waits on it. A dead backend rejects quickly; a hung one is capped by the timeout rather than blanking the application. Either failure falls back to the normalized browser language, and then to English.

Every locale step is individually guarded, because a boot file that throws does not degrade to English — the client entry logs the error and never mounts. The one unguarded step is installing the plugin itself, without which nothing in the application can render a translated string at all. The worst case the guards preserve is an application that mounts in English.

The shipped wwwroot-src/index.html is static and carries lang="en", so the very first painted frame always reports English on the root element; applyLocale corrects it in the same tick as mount.

What Re-Renders

A locale change is a write to one reactive ref, so everything that reads a translation inside a render or a computed re-evaluates on its own. Five things follow it in practice:

  • Component text. Every t() call in a template or computed.
  • Quasar's built-in texts, through the language pack the same call sets.
  • The tab title. Route records carry an i18n key in meta.title; the router resolves it against the active locale and composes the title from it. It re-resolves both after a navigation and from the hook applyLocale calls.
  • Control-Tree node labels, described below.
  • Engine session messages, which are re-resolved because the localizing helper reads the locale ref, so rows built inside a computed rebuild on a switch.

File Explorer sorting is the one thing that does not follow on its own. The shared collator is a live let binding rather than a reactive one: applyLocale rebuilds it for the new locale, but no render tracks it and FileExplorer.vue watches no locale, so a listing already on screen keeps its pre-switch order until the next folder load or sort-spec change re-runs the comparison. Because the binding is live, a caller must invoke collator.compare(...) in place; capturing the instance or extracting its compare would pin the pre-switch locale for good. explorerSort.ts is its only consumer, and no bare localeCompare — whose undefined locale argument means the browser locale — is called anywhere in the client.

Numbers deliberately do not follow the locale. Intl.NumberFormat is used nowhere in the client, and the only toLocale* calls are two triangle counts and two timestamps, each passed the app locale explicitly rather than defaulting to the browser's. NC and machine-tool quantities go through no locale-aware number formatting at all, so a coordinate never picks up a comma decimal separator from a locale that uses one.

Control-Tree Node Labels

A Control-Tree node carries both a label and an optional labelKey, and nodeDisplayLabel(n) in itemTypes.ts returns the translation of labelKey — with the node's labelParams interpolated — whenever one is set, and the verbatim label otherwise. nodeDisplayInfo applies the same rule to a group stem's intro text through infoKey over info. The translator function is read inside the call rather than captured at build time, so the computed that maps nodes onto the rendered tree tracks the locale ref and the whole tree's role labels change on a switch.

The two fields overlap on purpose. label is required on the node type and labelKey is optional, so a fixed role label is written both ways: the key that actually renders, and the verbatim English sitting beside it as the fallback nodeDisplayLabel returns when no key is set. label is also the only storage for text that can never be a key at all — server-composed mission titles, file-backed geometry paths, and engine type names. It is the first of those two jobs, not the second, that makes the verbatim English on node labels the largest single class in the census allowlist: fixed role labels the census can see but the screen never shows, classified rather than removed because the node type requires the field.

Mission command nodes are the one family that never takes the key path. A node's label is the title the server composes for the command — the kind name in the request language, with the command's own text in brackets when there is one — and Missions/MissionController.cs fills that title in for every entry: from the command's own composition where it implements the engine's title contract — whose rule keeps the kind name at the front and never drops it — and from the kind's display name, resolved in the request culture, where it does not. Because a title is therefore always present, the node's labelKey is always dropped and the client's kind-name fallback never fires. Since the composition happens on the server per request, the tree host watches the locale and re-reads the command entries, rewriting those labels in place; it deliberately does not rebuild the branch, which would remount an open command editor mid-edit. The kind keys are still built as tree.mission.kind.${commandType}, and they do render — AddCommandDialog.vue prefers one over the server's label for every kind that has one — while the key lint resolves the template shape by wildcard rather than reporting an orphan.

The Server Half

The language preference is server-held. GET /api/preference/language answers { success, current, available }, where current is the persisted UserConfig.LanguageCode — default en — and available is the server's own supported list, en, zh-Hans, zh-Hant. POST rejects an unlisted code with 400 and otherwise writes the value and saves the user configuration, which round-trips through the XML user config file. The localStorage key holds only a paint-time hint and loses every disagreement with this value.

Some response text is composed on the server and never passes through a bundle. PresentCatalogService resolves the effective language per request in this order: an explicit ?lang= query, then the request's Accept-Language header, then the persisted preference, then English. Because the header sits above the saved preference, a browser set to a different language than the application would win by default — so the client sends ?lang= explicitly, from a currentLang() helper returning the app locale, on the endpoints whose text is localized server-side: the selected-step info, the execution strip chart, its step-property picker and its colour guide, the mission command entries, catalog and field descriptors, and the step-present key list. The mission controller resolves the same chain into a per-request UI culture and injects it into the command text source, so the culture rides on the request rather than on the thread.

Two further server-originated string classes are localized on the client instead:

  • Coded API errors. Ten error codes are mapped onto apiErrors.* keys; a failed response carrying one of them renders the bundle value with the server's interpolation arguments. English is passed straight through to the server's own message on purpose, because that message is the English rendering and keeps per-site nuance the generic bundle value flattens.
  • Engine notifications. Session messages arrive with a structured id and English text. The client swaps in an engineMessages.<id> bundle entry, but only when the wire text agrees with the English bundle entry for that id: a templated message must match the canonical English template exactly before its arguments are re-interpolated, and an untemplated one is swapped only when the bundle entry has no interpolation holes. Any mismatch — an id emitted with different templates at different sites, or an engine built against a different bundle — falls back to the wire English rather than rendering wrong text. The English engineMessages values are therefore load-bearing code, not a description of the Chinese ones.

The step-present labels have a third home again: they ship beside the executable as catalog.{lang}.json files that overlay translations on top of the live English attribute data. English never comes from those files, and a missing file, a missing key or a parse failure simply means English.

The Glossary and the Build Gate

Two Node scripts stand between the bundles and a shipped release. wwwroot-src/package.json defines lint:i18n as census.mjs followed by lint-glossary.mjs, and build as lint:i18n followed by quasar build — so either script failing stops the production build. The development server runs neither.

lint-glossary.mjs bundles each locale's index through esbuild, flattens the three message trees into key/value maps, and applies four checks, every one of them a hard failure:

  1. Key isomorphism. The three flattened key sets must be identical, compared in both directions, so an extra key in a Chinese bundle fails as loudly as a missing one.
  2. Bidirectional key integrity. Every bundle-shaped string literal under the source tree — excluding the bundles themselves — must resolve to an English key, and every English key must be referenced from somewhere. A literal naming a namespace prefix counts as a reference to everything beneath it; a template literal with an interpolation hole is expanded into a wildcard, which is how tree.mission.kind.${kind} is matched; and @:linked.key references inside bundle values count as well. A double-quoted literal in a Vue template is treated as a key only when its attribute is one of the four the check knows to be key-valued, so an expression attribute whose local variable happens to share a namespace name is not mistaken for one. The orphan half is what makes the check bidirectional, and its allow-list is currently empty.
  3. Shape preservation. Never-translate tokens present in an English value must survive verbatim in both Chinese values — product and brand names, file formats and unit symbols by literal match, and NC codes, controller parameter names and function-key shortcuts by pattern. Placeholder sets must match in both directions, so a Chinese value can neither lose a placeholder nor invent one.
  4. Per-locale vocabulary. An adjudicated forbidden-term table per locale, each entry naming its replacement and its reason, plus a check for a Chinese value identical to its English source that contains no CJK at all. Both carry narrow exemptions: named keys where a term is used in a different sense, and a small identity set for values that legitimately are the identifier.

The tables the third and fourth checks read are written at the top of the script itself. glossary.yaml, the large harvested term list that sits beside the bundles, is generated data that the application never imports and the lint never reads: it is where a reading is settled before it is written into a bundle, not an input to the gate.

census.mjs guards the opposite direction — text that never became a key. It scans the same tree for user-visible English string literals reached through display-named template attributes, display-named object properties, notification and dialog shorthands, and prose text nodes; subtracts a classified allowlist of concrete text-and-file pairs; and compares what is left, per file, against a baseline. The gate fails only when a file's residual count rises, so cleanup can land incrementally — but the baseline is currently empty, which allows zero residual strings in any file and makes the effective rule that a newly hard-coded display string fails the build unless it is added to the allowlist with a reason. Allowlist entries whose file field is a glob or a brace list are classification notes rather than matchers; only concrete paths participate.

Strings Outside the Bundles

Three classes of user-visible English render in every locale — two because an interpolation hole hides them from the census, which discards any literal containing one, and one because the allowlist deliberately keeps it:

  • The shared numeric field's validation text. NumericInput.vue imports nothing from vue-i18n and builds three messages as template literals directly: an invalid-number message quoting what was typed, a “must be ≥” message naming the field's minimum, and a “must be ≤” message naming its maximum. All three are produced on blur — the field commits on blur or on Enter — and every panel that embeds the widget shows them in English. A widgets namespace exists for exactly this kind of shared control text; this component does not use it.
  • The geometry and topology editors' error prefixes. Those editors emit their failure context as a template literal that leads with the type name and the operation, so the leading phrase is English whatever the locale. The allowlist records the family as a ledger note rather than as matched entries.
  • The API-error default. The generic fallback message in the HTTP helper is allowlisted deliberately, on the grounds that it sits in the same stream as the server's own English messages.

A fourth class is untranslated by design rather than by escape: the dynamic label text on a Control-Tree node — a file-backed path, an engine type name, and the user's own words inside a server-composed mission title — and the identity strings the allowlist classifies alongside it, which are backend type names rendered inside a translated sentence, axis and unit column headers, and brand names. The role-label half of that same allowlist class is a different case again: stored English that never reaches the screen at all, because a labelKey always resolves ahead of it.

Source Code Path

See HiNC App Anatomy for git repository links.

Web Application

HiNC-2025-webservice (Quasar CLI SPA):

  • wwwroot-src/src/i18n/index.ts — the vue-i18n instance and its fallback chain, SUPPORTED_LOCALES, normalizeLocale, applyLocale, the Quasar language-pack map, the shared collator, the storage key and the re-title registration.
  • wwwroot-src/src/i18n/schema.ts — the English bundle as MessageSchema.
  • wwwroot-src/src/i18n/schema.d.ts — the ambient augmentation that registers that shape as vue-i18n's global message schema.
  • wwwroot-src/src/i18n/en/index.ts — the English bundle: the twenty-two namespaces assembled into one object.
  • wwwroot-src/src/i18n/zh-Hant/index.ts and wwwroot-src/src/i18n/zh-Hans/index.ts — the same assembly, each annotated with MessageSchema.
  • wwwroot-src/src/i18n/en/engineMessages.ts — the engine-notification renderings keyed by structured id; its English values are the eligibility gate for every swap.
  • wwwroot-src/src/i18n/en/routes.ts — the strings the routes' meta.title keys resolve to.
  • wwwroot-src/src/i18n/en/widgets.ts — the shared-widget namespace.
  • wwwroot-src/src/i18n/README.md — the invariants written beside the bundles: the fallback rule, the never-translate list, the number and sorting rules and the vocabulary table.
  • wwwroot-src/src/i18n/glossary.yaml — generated term data, imported by nothing.
  • wwwroot-src/src/boot/i18n.ts — the plugin installation, the cached-locale fast path with its unawaited reconcile, the awaited race on a cache miss, and the guards around every locale step.
  • wwwroot-src/quasar.config.ts — the boot list whose order places i18n after the authentication fetch patch.
  • wwwroot-src/index.html — the static shell whose lang attribute is corrected at mount.
  • wwwroot-src/package.jsonbuild as the i18n scripts followed by quasar build, and vue-tsc as a separate script.
  • scripts/lint-glossary.mjs — the four bundle checks and the adjudication tables they read.
  • scripts/census.mjs — the raw display-string extraction, the allowlist subtraction and the per-file ratchet.
  • scripts/i18n-allowlist.json — the classified exemptions, each with its reason.
  • scripts/i18n-baseline.json — the per-file residual ceiling the census compares against.
  • wwwroot-src/src/stores/appState.ts — the language reference, the available-code list, and the action that POSTs before applying the locale and rolls back on failure.
  • wwwroot-src/src/api/preference.ts — the typed wrappers over the language endpoints and the ?lang=-bearing step-present key request.
  • wwwroot-src/src/api/http.tscurrentLang(), the coded-error map and the app-locale rendering that skips English.
  • wwwroot-src/src/api/sessionMessages.ts — the engine-notification localizer, its template-equality gate and the repeat-fold wrapper.
  • wwwroot-src/src/components/controlTree/itemTypes.ts — the node's label, labelKey and labelParams fields and the nodeDisplayLabel and nodeDisplayInfo resolvers.
  • wwwroot-src/src/components/controlTree/useControlTreeHost.ts — the rendered-tree computed that calls the resolver, and the locale watcher that re-pulls the server-composed mission command titles without rebuilding the branch.
  • wwwroot-src/src/components/controlTree/missionItemTypes.ts — the kind display names and their tree.mission.kind.* key twin, and the rule that drops the key whenever the entry carries a title.
  • wwwroot-src/src/components/widgets/NumericInput.vue — the shared numeric field: blur-and-Enter commit, and the three hard-coded validation messages.
  • wwwroot-src/src/components/AppMenuBar.vue — the language submenu, its hard-coded self-name map and the action it calls.
  • wwwroot-src/src/components/explorerSort.ts — the only consumer of the shared collator, calling it in place so a switch is picked up.
  • wwwroot-src/src/router/index.ts — the key-valued meta.title resolution and the hook the locale switch calls to re-title a parked tab.
  • Environments/PreferenceController.cs — the language endpoints, the supported-code list and the 400 on an unlisted code.
  • Environments/UserConfig.cs — the persisted language code, its English default and its XML round-trip.
  • Environments/PresentCatalogService.cs — the per-request language resolution chain, the tag normalizer and the shipped catalog overlay.
  • Missions/MissionController.cs — the per-request UI culture taken from the same chain and injected into command title composition.

See Also

  • Language Selection SubMenu — the menu gesture that drives this mechanism, and the only place a user changes the locale
  • Translation Remarks — the terminology contract the bundles are written to, and the readings the vocabulary lint enforces