From 2c72a038d5dcd5e0b0e0043465295fa746e68a4a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 15:25:55 -0800 Subject: [PATCH] i18n: keep the page's locale in a vue-i18n instance The application views install one vue-i18n instance that holds the active locale and the catalogs. src/i18n/index.js stays free of Vue: bindLocale() keeps its locale in the instance's ref, so t(), tn() and the Intl formatters read it from there and a view that translates while rendering renders again when the locale changes. A tag written straight to the ref is normalized. Message formatting, catalogs and call sites are unchanged, and switching language still reloads the page. The instance outlives the views: unmounting one no longer disposes it. The browser build defines vue-i18n's feature flags and drops its message compiler, which Core does not use. --- build/vite.js | 6 ++ changelog.d/0000-vue-and-pinia.md | 8 +++ docs/I18N.md | 3 + package-lock.json | 85 ++++++++++++++++++++++++++- package.json | 3 +- scripts/package-boundaries.json | 62 ++++++++++++-------- src/i18n/index.js | 51 +++++++++++----- src/tooling/viteBuild.test.mjs | 4 ++ src/ui/i18n.js | 46 +++++++++++++++ src/ui/i18n.test.mjs | 96 +++++++++++++++++++++++++++++++ src/ui/views/index.js | 5 +- src/ui/vueRuntime.js | 5 +- 12 files changed, 330 insertions(+), 44 deletions(-) create mode 100644 src/ui/i18n.js create mode 100644 src/ui/i18n.test.mjs diff --git a/build/vite.js b/build/vite.js index 4421b8db3..fe90501dd 100644 --- a/build/vite.js +++ b/build/vite.js @@ -211,6 +211,12 @@ export function createBrowserViteConfig({ // framing + CSP hardening (one constant, no drift). preview: { headers: BROWSER_HEADERS }, define: { + // vue-i18n holds the locale and catalogs; Core formats the messages + // (src/ui/i18n.js), so the build drops vue-i18n's message compiler. + __VUE_I18N_FULL_INSTALL__: 'false', + __VUE_I18N_LEGACY_API__: 'false', + __INTLIFY_PROD_DEVTOOLS__: 'false', + __INTLIFY_DROP_MESSAGE_COMPILER__: 'true', 'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey), 'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken), // Mapillary client tokens are public by design (see SECURITY.md). diff --git a/changelog.d/0000-vue-and-pinia.md b/changelog.d/0000-vue-and-pinia.md index dff55bed0..58996cd54 100644 --- a/changelog.d/0000-vue-and-pinia.md +++ b/changelog.d/0000-vue-and-pinia.md @@ -1,3 +1,11 @@ +- 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 + locale changes. `bindLocale` (`src/i18n/index.js`) keeps the locale in a + given ref; Core's Vite config builds vue-i18n without its message + compiler. Catalogs, messages and call sites are unchanged + (samehkhamis, #0000). + - Cockpit's Display popover shows the Display panel's own HUD, Detection, Parameters and 3D groups through Vue teleports: `CockpitDisplayPortal` publishes the popover's slots (`publish`, with `afterRender` to lay out once diff --git a/docs/I18N.md b/docs/I18N.md index 105fd5fb3..3145d5ae4 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -25,6 +25,8 @@ label.textContent = t(FEED_STATE_LABELS[state]); A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.) +Vue views call the same `t()` and `tn()`. The page's vue-i18n instance (`src/ui/i18n.js`), installed in every view, holds the active locale, and `t()`, `tn()` and the formatters read it from there, so a view that translates while rendering renders again when the locale changes. Views don't use vue-i18n's own `t`: English is the key, `context` picks an entry and plural forms follow `Intl.PluralRules`, which only Core's functions do. + `msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English. ## Markup @@ -84,6 +86,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca | File | What it holds | | ------------------------------- | ------------------------------------------------------------------------------ | | `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language | +| `src/ui/i18n.js` | the page's vue-i18n instance: the active locale and the catalogs | | `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) | | `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot | | `src/i18n/locales/.json` | one catalog per language, keyed by English text | diff --git a/package-lock.json b/package-lock.json index 6430491c1..dd60d3383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "pbf": "^5.1.2", "pinia": "^2.3.1", "satellite.js": "^6.0.2", - "vue": "^3.5.44" + "vue": "^3.5.44", + "vue-i18n": "^11.4.15" }, "devDependencies": { "@happy-dom/global-registrator": "^20.14.6", @@ -1130,6 +1131,67 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@intlify/core-base": { + "version": "11.4.15", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.15.tgz", + "integrity": "sha512-yHNg7+M6VKQuj+KUO/CrtcBMm3iAspvfFe1H/eKI0TP0pn2T2srBF1gEb/TL3c8RA/BZZGOIySwtZD0i+9/hhw==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-types": "11.4.15", + "@intlify/message-compiler": "11.4.15", + "@intlify/shared": "11.4.15" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.4.15", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.15.tgz", + "integrity": "sha512-NFV9LsP720IkR6BHNfUYBSkd/abocWimcOWAS63D3spwczy8zDvXCf9WMyhCVQ7FcYkWyECZke18x8D6UNVBFw==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.15", + "@intlify/shared": "11.4.15" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.4.15", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.15.tgz", + "integrity": "sha512-dV6apms3MXAG3jZ/6bAq+IW2IKDGasmjA/Aoi0JVKCStVi6GbzrPruCVbU/Djn1n41w5EiM3KdnpTuo6I1qe5w==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.4.15", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.4.15", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.15.tgz", + "integrity": "sha512-jlTJkM4bMdr267e1iUE2RMNz1LyoYU6IPReKjnQM6RvipEBUVwmMHbeUckDcOmR+WeungEGjaNmSQJdQzJcOfQ==", + "license": "MIT", + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -3700,6 +3762,27 @@ } } }, + "node_modules/vue-i18n": { + "version": "11.4.15", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.15.tgz", + "integrity": "sha512-bOoNYoEd/ta4g2EelI9HKxPyMZvTDFoykTNYujDTOGoKDRy5ehKJg8WmMDU7hWqNM5efO7fEGNRuwrBXnwHyDg==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.15", + "@intlify/devtools-types": "11.4.15", + "@intlify/shared": "11.4.15", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", diff --git a/package.json b/package.json index a0ba4dccd..7dda05144 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "pbf": "^5.1.2", "pinia": "^2.3.1", "satellite.js": "^6.0.2", - "vue": "^3.5.44" + "vue": "^3.5.44", + "vue-i18n": "^11.4.15" }, "devDependencies": { "@happy-dom/global-registrator": "^20.14.6", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 71acdb733..dc53bb66b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -143,9 +143,10 @@ "src/ui/views/panels/GlobeActions.vue", "src/ui/views/panels/GlobeCompass.vue", "src/ui/stores/recording.js", - "src/ui/styleParameters.js" + "src/ui/styleParameters.js", + "src/ui/i18n.js" ], - "external": ["vue", "pinia"] + "external": ["vue", "pinia", "vue-i18n"] }, "viewer": { "exports": ["./application/viewer"], @@ -730,9 +731,10 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/ui/i18n.js" ], - "external": ["vue", "pinia"] + "external": ["vue", "pinia", "vue-i18n"] }, "cctv-controls": { "exports": ["./ui/cctv"], @@ -759,9 +761,10 @@ "src/ui/unitLabels.js", "src/ui/vueRuntime.js", "src/ui/stores/cctv.js", - "src/ui/stores/status.js" + "src/ui/stores/status.js", + "src/ui/i18n.js" ], - "external": ["vue", "pinia"] + "external": ["vue", "pinia", "vue-i18n"] }, "context-controls": { "exports": ["./ui/context", "./ui/context/policy"], @@ -862,9 +865,10 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", - "src/ui/vueRuntime.js" + "src/ui/vueRuntime.js", + "src/ui/i18n.js" ], - "external": ["vue", "pinia"] + "external": ["vue", "pinia", "vue-i18n"] }, "ui-shell": { "exports": ["./ui/shell"], @@ -1083,9 +1087,10 @@ "src/ui/kit/UiButton.vue", "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", - "src/ui/stores/recording.js" + "src/ui/stores/recording.js", + "src/ui/i18n.js" ], - "external": ["cesium", "vue", "pinia"] + "external": ["cesium", "vue", "pinia", "vue-i18n"] }, "ui-styles": { "exports": ["./ui/styles"], @@ -1215,9 +1220,10 @@ "src/ui/stores/status.js", "src/ui/toast.js", "src/ui/stores/sceneInteractions.js", - "src/ui/stores/dataPacks.js" + "src/ui/stores/dataPacks.js", + "src/ui/i18n.js" ], - "external": ["cesium", "vue", "pinia"] + "external": ["cesium", "vue", "pinia", "vue-i18n"] }, "live-observations": { "exports": ["./sources/live"], @@ -2128,9 +2134,10 @@ "src/i18n/marker.js", "src/ui/stores/voice.js", "src/ui/vueRuntime.js", - "src/ui/stores/pointerReticle.js" + "src/ui/stores/pointerReticle.js", + "src/ui/i18n.js" ], - "external": ["vue", "pinia"] + "external": ["vue", "pinia", "vue-i18n"] }, "application-components": { "exports": [ @@ -3098,7 +3105,8 @@ "src/ui/kit/UiEmpty.vue", "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", - "src/ui/stores/recording.js" + "src/ui/stores/recording.js", + "src/ui/i18n.js" ], "external": [ "hls.js", @@ -3111,7 +3119,8 @@ "mapillary-js", "@jtarrio/webrtlsdr", "vue", - "pinia" + "pinia", + "vue-i18n" ] }, "application-catalog": { @@ -3690,7 +3699,8 @@ "src/ui/stores/windInspection.js", "src/layers/wind/inspectionMarker.js", "src/ui/stores/bhoteKoshiPanel.js", - "src/hudReadouts.js" + "src/hudReadouts.js", + "src/ui/i18n.js" ], "external": [ "hls.js", @@ -3703,7 +3713,8 @@ "mapillary-js", "@jtarrio/webrtlsdr", "vue", - "pinia" + "pinia", + "vue-i18n" ] }, "application-operations": { @@ -3874,9 +3885,10 @@ "src/ui/displayControls.js", "src/visualStylePolicy.js", "src/ui/rail/fieldValue.js", - "src/ui/kit/UiSlider.js" + "src/ui/kit/UiSlider.js", + "src/ui/i18n.js" ], - "external": ["cesium", "vue", "pinia"] + "external": ["cesium", "vue", "pinia", "vue-i18n"] }, "ui-panel-chrome": { "exports": ["./ui/panel-chrome"], @@ -3910,9 +3922,10 @@ "src/ui/panelAccordion.js", "src/ui/vueRuntime.js", "src/ui/stores/panelChrome.js", - "src/ui/rail/panelState.js" + "src/ui/rail/panelState.js", + "src/ui/i18n.js" ], - "external": ["vue", "pinia"] + "external": ["vue", "pinia", "vue-i18n"] }, "civil-flight-records": { "exports": ["./layers/flights/records"], @@ -4075,9 +4088,10 @@ "src/voice/providerCapabilities.js", "src/ui/vueRuntime.js", "src/ui/stores/voice.js", - "src/ui/stores/pointerReticle.js" + "src/ui/stores/pointerReticle.js", + "src/ui/i18n.js" ], - "external": ["vue", "pinia"] + "external": ["vue", "pinia", "vue-i18n"] }, "alpr-source": { "exports": ["./layers/alpr/source"], diff --git a/src/i18n/index.js b/src/i18n/index.js index 63dbe6cf2..a5a140e6e 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -40,16 +40,37 @@ const CATALOGS = Object.freeze({ 'zh-Hant': zhHant, }); -let currentLocale = DEFAULT_LOCALE; +// The page's locale. The UI binds it to its vue-i18n instance's +// (src/ui/i18n.js), so a Vue render that translates re-renders when it +// changes; until then, and in portable code, it is plain state. +let pageLocale = { value: DEFAULT_LOCALE }; + +/** + * Keep the active locale in `locale`, a ref such as a vue-i18n composer's + * `locale`. It takes the current locale; switch with setLocale afterwards. + * @param {{value: string}} locale + */ +export function bindLocale(locale) { + locale.value = pageLocale.value; + pageLocale = locale; +} /** The catalog for a locale; null for English and unknown codes. */ export function getCatalog(locale) { return CATALOGS[normalizeLocale(locale)] ?? null; } -/** Active locale. */ +let boundValue = DEFAULT_LOCALE; +let activeLocale = DEFAULT_LOCALE; + +/** Active locale, normalized: a bound ref may be set to any language tag. */ export function getLocale() { - return currentLocale; + const value = pageLocale.value; + if (value !== boundValue) { + boundValue = value; + activeLocale = normalizeLocale(value) || DEFAULT_LOCALE; + } + return activeLocale; } /** @@ -59,18 +80,18 @@ export function getLocale() { */ export function setLocale(locale) { const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; - if (normalized !== currentLocale) { - currentLocale = normalized; + if (normalized !== pageLocale.value) { + pageLocale.value = normalized; formatterCache.clear(); } - return currentLocale; + return normalized; } /** Development-only warning; silent under node:test and in production. */ function warnMissing(text) { if (import.meta.env?.DEV !== true) return; console.warn( - `[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`, + `[i18n] no ${getLocale()} translation for ${JSON.stringify(text)}`, ); } @@ -89,7 +110,7 @@ function interpolate(pattern, values) { const filled = (value) => typeof value === 'string' && value.trim() !== ''; function lookup(text) { - const catalog = CATALOGS[currentLocale]; + const catalog = CATALOGS[getLocale()]; if (!catalog) return undefined; const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined; if (filled(entry) || (entry && typeof entry === 'object')) return entry; @@ -124,7 +145,7 @@ export function t(text, values) { */ export function tn(count, one, other, values) { const entry = lookup(other); - const category = pluralRules(currentLocale).select(count); + const category = pluralRules(getLocale()).select(count); let pattern; const forms = entry && typeof entry === 'object' ? entry : null; if (forms && (filled(forms[category]) || filled(forms.other))) { @@ -141,7 +162,7 @@ export function tn(count, one, other, values) { * missing-translation warning. */ export function translateKnown(text) { - const catalog = CATALOGS[currentLocale]; + const catalog = CATALOGS[getLocale()]; if (!catalog || typeof text !== 'string') return text; const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined; return filled(entry) ? entry : text; @@ -171,10 +192,10 @@ function pluralRules(locale) { const formatterCache = new Map(); function cachedFormatter(kind, Constructor, options) { - const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`; + const cacheKey = `${kind}|${getLocale()}|${options ? JSON.stringify(options) : ''}`; let formatter = formatterCache.get(cacheKey); if (!formatter) { - formatter = new Constructor(currentLocale, options); + formatter = new Constructor(getLocale(), options); formatterCache.set(cacheKey, formatter); } return formatter; @@ -210,7 +231,7 @@ export function formatUtcDayMonth(value) { ? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3])) : new Date(value).getTime(); if (!Number.isFinite(time)) return ''; - if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10); + if (getLocale() === 'en') return new Date(time).toISOString().slice(5, 10); return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format( time, ); @@ -228,7 +249,7 @@ export function formatRelativeTime(value, unit = 'second', options) { }).format(value, unit); // CLDR spaces Japanese (5 分前); the ja catalog writes the number and its // unit together (5分前), so the two must not differ on one screen. - return currentLocale === 'ja' + return getLocale() === 'ja' ? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '') : text; } @@ -263,7 +284,7 @@ const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([ */ export function applyDocumentTranslations(doc) { if (typeof doc?.querySelectorAll !== 'function') return 0; - if (!CATALOGS[currentLocale]) return 0; + if (!CATALOGS[getLocale()]) return 0; let applied = 0; for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) { for (const element of doc.querySelectorAll(`[${marker}]`)) { diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 58ab82b61..6e31251a5 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -65,6 +65,10 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'")); assert.deepEqual(config.preview.headers, config.server.headers); assert.deepEqual(config.define, { + __VUE_I18N_FULL_INSTALL__: 'false', + __VUE_I18N_LEGACY_API__: 'false', + __INTLIFY_PROD_DEVTOOLS__: 'false', + __INTLIFY_DROP_MESSAGE_COMPILER__: 'true', 'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"', 'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"', 'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""', diff --git a/src/ui/i18n.js b/src/ui/i18n.js new file mode 100644 index 000000000..f39752970 --- /dev/null +++ b/src/ui/i18n.js @@ -0,0 +1,46 @@ +import { createI18n } from 'vue-i18n'; +import { bindLocale, getCatalog, getLocale } from '../i18n/index.js'; +import { DEFAULT_LOCALE, LOCALES } from '../i18n/locale.js'; + +let pageI18n = null; + +// Catalogs are flat and keyed by English text, which holds dots. +const resolveFlat = (messages, key) => + Object.hasOwn(messages, key) ? messages[key] : null; + +/** + * The page's vue-i18n instance, installed in every application view. It + * holds the active locale and the catalogs; Core's t(), tn() and the Intl + * formatters read the locale from it, so a view that calls them while + * rendering re-renders when the locale changes. Views translate through + * t()/tn() rather than vue-i18n's own t: English is the key, `context` picks + * an entry, and plural forms follow Intl.PluralRules (docs/I18N.md). + * @returns {import('vue-i18n').I18n} + */ +export function applicationI18n() { + if (pageI18n) return pageI18n; + const messages = {}; + for (const locale of LOCALES) + if (locale !== DEFAULT_LOCALE) messages[locale] = getCatalog(locale); + pageI18n = createI18n({ + legacy: false, + globalInjection: false, + locale: getLocale(), + fallbackLocale: DEFAULT_LOCALE, + messages, + messageResolver: resolveFlat, + missingWarn: false, + fallbackWarn: false, + }); + // vue-i18n disposes its instance when an app it is installed in unmounts; + // the page's outlives every view, so an unmounted panel keeps it. + const install = pageI18n.install; + pageI18n.install = (app, ...options) => { + const unmount = app.unmount; + const installed = install.call(pageI18n, app, ...options); + app.unmount = unmount; + return installed; + }; + bindLocale(pageI18n.global.locale); + return pageI18n; +} diff --git a/src/ui/i18n.test.mjs b/src/ui/i18n.test.mjs new file mode 100644 index 000000000..e615df51d --- /dev/null +++ b/src/ui/i18n.test.mjs @@ -0,0 +1,96 @@ +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../testSupport/vueDom.mjs'; + +let vue; +let i18n; +let applicationI18n; +let uninstall; +before(async () => { + uninstall = installVueDom(); + vue = await import('vue'); + i18n = await import('../i18n/index.js'); + ({ applicationI18n } = await import('./i18n.js')); +}); +after(() => { + i18n.setLocale('en'); + uninstall(); +}); + +test('vue-i18n takes the locale set before it and holds the catalogs', () => { + i18n.setLocale('ru'); + const { global } = applicationI18n(); + assert.equal(applicationI18n(), applicationI18n()); + assert.equal(global.locale.value, 'ru'); + assert.equal(global.te('Settings'), true); + assert.equal(global.tm('Settings'), 'Настройки'); + global.locale.value = 'de'; + assert.equal(i18n.getLocale(), 'de'); + global.locale.value = 'ru_RU'; + assert.equal(i18n.getLocale(), 'ru'); + assert.equal(i18n.t('Settings'), 'Настройки'); + assert.equal(i18n.formatNumber(1234.5), '1\u00a0234,5'); + global.locale.value = 'xx'; + assert.equal(i18n.getLocale(), 'en'); + i18n.setLocale('en'); + assert.equal(global.locale.value, 'en'); +}); + +test('a view that translates re-renders when the locale changes', async () => { + const { createApp, h, nextTick } = vue; + const host = document.createElement('div'); + document.body.append(host); + const app = createApp({ + render: () => + h('p', [ + i18n.t('Settings'), + ' / ', + i18n.tn( + 3, + 'POWER UP · {count} KEY WAITING', + 'POWER UP · {count} KEYS WAITING', + ), + ]), + }).use(applicationI18n()); + app.mount(host); + assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING'); + i18n.setLocale('ru'); + await nextTick(); + assert.equal(host.textContent, 'Настройки / ЗАПУСК · 3 КЛЮЧА В ОЖИДАНИИ'); + i18n.setLocale('en'); + await nextTick(); + assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING'); + app.unmount(); + host.remove(); +}); + +test('unmounting one view keeps the page instance for the others', async () => { + const { createApp, h, nextTick } = vue; + const page = applicationI18n(); + let disposed = 0; + const dispose = page.dispose; + page.dispose = () => { + disposed += 1; + dispose.call(page); + }; + const mountView = () => { + const host = document.createElement('div'); + document.body.append(host); + const app = createApp({ render: () => h('p', i18n.t('Settings')) }); + app.use(page).mount(host); + return { app, host }; + }; + const first = mountView(); + const second = mountView(); + first.app.unmount(); + assert.equal(disposed, 0); + i18n.setLocale('ru'); + await nextTick(); + assert.equal(second.host.textContent, 'Настройки'); + const third = mountView(); + assert.equal(third.host.textContent, 'Настройки'); + i18n.setLocale('en'); + second.app.unmount(); + third.app.unmount(); + page.dispose = dispose; +}); diff --git a/src/ui/views/index.js b/src/ui/views/index.js index fa9d6f542..a85224912 100644 --- a/src/ui/views/index.js +++ b/src/ui/views/index.js @@ -1,5 +1,6 @@ import { createApp } from 'vue'; import { applicationPinia } from '../vueRuntime.js'; +import { applicationI18n } from '../i18n.js'; import WelcomeView from './WelcomeView.vue'; import ContextView from './ContextView.vue'; import LayerPanelsView from './LayerPanelsView.vue'; @@ -57,7 +58,9 @@ export function mountApplicationViews( if (mounted.has(host)) continue; if (!Object.hasOwn(APPLICATION_VIEWS, name)) throw new Error(`Unknown application view: ${name}`); - const app = createApp(APPLICATION_VIEWS[name]).use(applicationPinia()); + const app = createApp(APPLICATION_VIEWS[name]) + .use(applicationPinia()) + .use(applicationI18n()); app.mount(host); mounted.set(host, app); } diff --git a/src/ui/vueRuntime.js b/src/ui/vueRuntime.js index eb3950d9b..5a1764b38 100644 --- a/src/ui/vueRuntime.js +++ b/src/ui/vueRuntime.js @@ -1,5 +1,6 @@ import { createApp } from 'vue'; import { createPinia } from 'pinia'; +import { applicationI18n } from './i18n.js'; let pagePinia = null; @@ -15,7 +16,7 @@ export function applicationPinia() { /** * Create a Vue runtime: one Pinia instance shared by every view it mounts, - * and the mounts themselves for teardown. + * the page's vue-i18n instance, and the mounts themselves for teardown. * @param {{pinia?: import('pinia').Pinia}} [options] * @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}} */ @@ -32,7 +33,7 @@ export function createUiRuntime({ pinia = createPinia() } = {}) { */ mount(component, host, props = {}) { const app = createApp(component, props); - app.use(pinia); + app.use(pinia).use(applicationI18n()); app.mount(host); apps.add(app); return () => {