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 + '