From ba20bc13d8ea2195748ff81d7887b4bb6ddccc39 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 15:38:23 -0800 Subject: [PATCH] i18n: translate the application views with t() as they render Every data-i18n, data-i18n-title, data-i18n-aria-label and data-i18n-placeholder marker in the Vue templates becomes a t() call with the key the boot pass used to look up, contexts included. The three labels the shell rewrote at boot (Visual Presets, Location and the standby description) render through t() too, and that rewrite is gone. With the locale in vue-i18n, view text now follows a locale change, and markup under v-if or v-for no longer comes back in English when it re-renders. Rendered text and attributes are unchanged in every locale. The boot pass still translates static page markup such as the loading screen and a host's own markup, and npm run i18n rejects data-i18n* in a Vue template. --- CONTRIBUTING.md | 2 +- changelog.d/0000-vue-and-pinia.md | 6 + docs/I18N.md | 24 ++-- docs/VUE-MIGRATION.md | 13 ++- scripts/i18n.mjs | 42 ++++++- src/firstRunExperience.test.mjs | 6 +- src/i18n/catalog.test.mjs | 27 +++++ src/mapStackChips.test.mjs | 2 +- src/standalone/application.js | 7 +- src/ui/applicationShell.js | 51 --------- src/ui/cyberTheme.test.mjs | 4 +- src/ui/views/CockpitView.vue | 50 ++++---- src/ui/views/CommandDockView.vue | 42 +++---- src/ui/views/ContextView.vue | 24 ++-- src/ui/views/DisplayControlsView.vue | 3 +- src/ui/views/LayerPanelsView.vue | 32 +++--- src/ui/views/ProviderSettingsView.vue | 12 +- src/ui/views/SceneChromeView.vue | 6 +- src/ui/views/WelcomeView.vue | 22 ++-- src/ui/views/panels/CctvPanel.vue | 38 +++--- src/ui/views/panels/CockpitBriefing.vue | 32 +++--- src/ui/views/panels/CockpitContext.vue | 24 ++-- src/ui/views/panels/CockpitRoute.vue | 9 +- .../views/panels/CockpitUtilityControls.vue | 18 +-- src/ui/views/panels/DisplayPanel.vue | 108 +++++++++--------- src/ui/views/panels/GeoImport.vue | 9 +- src/ui/views/panels/GlobeActions.vue | 10 +- src/ui/views/panels/GlobeCompass.vue | 2 +- src/ui/views/panels/MissionRoster.vue | 8 +- src/ui/views/panels/RadioPanel.vue | 24 ++-- src/ui/views/panels/RadioTuner.vue | 4 +- src/ui/views/panels/SdrCard.vue | 40 +++---- src/ui/views/panels/StreetLevelPanel.vue | 20 ++-- 33 files changed, 377 insertions(+), 344 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46ac2cc1b..7f759ab4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,7 +126,7 @@ The highest-leverage places to jump in: - ES modules, **2-space indent, single quotes, semicolons.** - JSDoc on exported/public functions. - Match the surrounding code — comment density, naming, and idiom. -- User-visible text goes through `t()` or `tn()`, with `msg()` marking English that is stored and translated later. Static page markup that predates the views keeps its `data-i18n*` attributes; components use `t()`. `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md). +- User-visible text goes through `t()` or `tn()`, with `msg()` marking English that is stored and translated later. Components call `t()` in their templates, so their text follows the locale; `data-i18n*` attributes are only for static page markup such as the loading screen, and `npm run i18n` rejects them in a template. `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md). - Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required. ## Building UI diff --git a/changelog.d/0000-vue-and-pinia.md b/changelog.d/0000-vue-and-pinia.md index 58996cd54..2de4a80e3 100644 --- a/changelog.d/0000-vue-and-pinia.md +++ b/changelog.d/0000-vue-and-pinia.md @@ -1,3 +1,9 @@ +- The application views translate their text with `t()` as they render, + so it follows the active locale; they no longer carry `data-i18n*` + attributes, and `npm run i18n` rejects those in a Vue template. The boot + pass (`applyDocumentTranslations`) still translates static page markup + such as the loading screen and a host's own markup (samehkhamis, #0000). + - The page's locale lives in a vue-i18n instance installed in every application view, and `t()`, `tn()` and the Intl formatters read it from there, so a view that translates while rendering renders again when the diff --git a/docs/I18N.md b/docs/I18N.md index 3145d5ae4..3e2fb61d1 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -31,13 +31,23 @@ Vue views call the same `t()` and `tn()`. The page's vue-i18n instance (`src/ui/ ## Markup +Vue views translate in the template, so their text renders in the active locale: + +```vue +{{ t('Normal') }} + + +``` + +Static page markup that no view renders (the loading screen in `index.html`, a host's own markup) opts in with attributes instead, and `npm run i18n` rejects them in a Vue template: + ```html Normal ``` -`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading. +`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. `data-i18n-context="radio tag"` gives the element's text a context, the catalog entry `radio tag|All`, as `t('All', { context: 'radio tag' })` does in code. Attribute markup is translated once at boot. Switching language still reloads the page: views would follow a new locale, but much of their text is published already translated by code that does not. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading. ## Rules @@ -68,16 +78,16 @@ An empty translation is reported, and so is one left as the English, plural form `npm run i18n` and `npm test` fail on UI text that is not marked: -- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. +- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. In a Vue template, static text or a static `title`, `aria-label` or `placeholder` (rather than `t()`), and any `data-i18n*` attribute. - **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`. Fix it by marking the text. When the text must stay as it is, say so where it lives: -| Case | How | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML | -| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element | -| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: ` on the line or the line above | +| Case | How | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML | +| Page markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element (not in a Vue template) | +| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: ` on the line or the line above | Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page. diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 9acdc205f..4ff22d7c3 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -153,10 +153,9 @@ a feature supplying only its accent. Next each view takes over its wiring: markup an owner builds with `createElement` becomes component markup that renders from a Pinia store the owner publishes to, and user actions come back to the owner through - the store, so the owner keeps every decision. Static markup that the - translation pass rewrites (`data-i18n*`) stays outside `v-if` and - `v-for`, because re-rendering it would bring back the English; component - text uses `t()` instead. Shared builders become components first and + the store, so the owner keeps every decision. Component text uses + `t()`, which follows the locale as the view renders; the views carry no + `data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and keep their imperative entry points until their owners are components: the rail cards, card blocks and timeline (`src/ui/rail/`) render through Vue's synchronous `render()` behind `createRailCards()`, @@ -344,8 +343,10 @@ a feature supplying only its accent. now requires the pattern for new UI. The page-level markup tests read `renderApplicationHtml()`, the real views rendered on the server, and new tests mount components; three tests still read a `.vue` file's source - (`cyberTheme`, `panelStorageDocs`, `geoFileImport`). The attribute translation pass goes with the move to - vue-i18n. An explicit API for hosts to register panels and rail items is + (`cyberTheme`, `panelStorageDocs`, `geoFileImport`). The views translate with `t()` + while rendering, and vue-i18n holds the locale they read; the attribute + translation pass is left for static page markup (the loading screen and a + host's own markup). An explicit API for hosts to register panels and rail items is deferred until a host adds one: no host does today (the hosted application's account interface is its own markup), and the first such panel, the hosted timeline, will shape it. diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index d8d131fff..c424666a2 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -156,7 +156,7 @@ const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']); /** * User-visible markup text without data-i18n*. Skipped, with everything * inside: translate="no" (text that stays as is, as in HTML), - * data-i18n-runtime (code sets this text through t()), and icons + * data-i18n-runtime (page markup whose text code sets through t()), and icons * (aria-hidden, Material Symbols). */ export function unmarkedMarkup(file, html) { @@ -404,8 +404,13 @@ export function unmarkedScript(file, original) { */ export function findUnmarked() { const found = []; - for (const file of markupFiles()) + for (const file of markupFiles()) { found.push(...unmarkedMarkup(file, readMarkup(file))); + } + for (const file of trackedFiles('src/**/*.vue')) + found.push( + ...markedTemplate(file, readFileSync(path.join(ROOT, file), 'utf8')), + ); for (const file of sourceFiles().filter( (name) => !NOT_UI.some((pattern) => pattern.test(name)), )) @@ -421,7 +426,38 @@ export function findUnmarked() { }); } -/** Static markup opted in with data-i18n*: the page and its views. */ +/** + * data-i18n* in a Vue template: views translate with t() as they render, so + * their text follows the locale; the attributes are for static page markup. + * Reads the whole single-file component, so lines are the file's own. + */ +export function markedTemplate(file, source) { + const start = source.indexOf(''); + // Comments and attribute values are blanked, keeping every offset. + const blank = (text) => text.replace(/[^\n]/g, ' '); + const template = source + .slice(0, end === -1 ? undefined : end) + .replace(//g, blank); + const found = []; + const TAG = /<[a-zA-Z][\w-]*((?:"[^"]*"|'[^']*'|[^>"'])*)>/g; + TAG.lastIndex = start + '