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('');
+ if (start === -1) return [];
+ const end = source.lastIndexOf('');
+ // 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 + ''.length;
+ for (let tag; (tag = TAG.exec(template));) {
+ const attrs = tag[1].replace(/"[^"]*"|'[^']*'/g, blank);
+ const offset = tag.index + tag[0].length - 1 - tag[1].length;
+ for (const match of attrs.matchAll(
+ /(?<=\s)(?::|v-bind:)?(data-i18n[\w-]*)(?=[\s=/]|$)/g,
+ ))
+ found.push({
+ where: `${file}:${lineAt(source, offset + match.index)}`,
+ text: `${match[1]}: use t() in a Vue template`,
+ });
+ }
+ return found;
+}
+
+/** Markup checked for unmarked text: the page and its views. */
function markupFiles() {
return trackedFiles('index.html', 'src/ui/views/*.vue');
}
diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs
index caaca46fa..56548c1da 100644
--- a/src/firstRunExperience.test.mjs
+++ b/src/firstRunExperience.test.mjs
@@ -814,7 +814,7 @@ test('markup, startup ordering and accessibility remain pinned', async () => {
// passing edit — changing it needs the owner, not a nicer-sounding rewrite.
assert.ok(
html.includes(
- '
It feels like a forbidden cockpit' +
+ '
It feels like a forbidden cockpit' +
'—then you realize the sources are public and the data is real.
',
),
'the owner-authored first-run line must ship exactly as written',
@@ -903,8 +903,8 @@ test('the card closes without a keyboard: the ESC hint is a button, kept on phon
footer,
/