From 5f96009b678af9ebf002bf10391ead114ca3d035 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 22:01:18 -0800 Subject: [PATCH] feat(i18n): an ./i18n entry for an application with its own language choice startPageLocale, followPageLocale and switchLocale start, follow and switch the page's language as the standalone application and the globe picker do, for an application that offers the language elsewhere and passes localePicker: false. The standalone application uses them. --- docs/APPLICATION.md | 4 +- docs/CURRENT-STATE.md | 4 ++ docs/I18N.md | 25 ++++++++ package.json | 1 + scripts/format-scope.json | 2 + scripts/package-boundaries.json | 22 +++++++ src/app/controls.js | 3 +- src/i18n/page.js | 81 ++++++++++++++++++++++++ src/i18n/page.test.mjs | 106 ++++++++++++++++++++++++++++++++ src/mapStartup.test.mjs | 7 +-- src/standalone/application.js | 19 ++---- src/ui/applicationShell.js | 4 +- 12 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 src/i18n/page.js create mode 100644 src/i18n/page.test.mjs diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index d3ca6ffce..7c2206467 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -224,7 +224,9 @@ leaving out `command-dock` the voice control and its pointer reticle; leaving out `context` the Bhote Koshi reconstruction panel; and leaving out `intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD implementation publishes to the `gev-hud` store. An unknown -view name is an error. A host that does not initialize Core's locale passes +view name is an error. An application that offers its own language choice +starts and switches the language with the `./i18n` entry (`startPageLocale`, +`followPageLocale`, `switchLocale`; see [I18N.md](./I18N.md)) and passes `localePicker: false` to `createApplicationControls` instead of editing the markup. Node tests that import application modules register the component loader with `node --import=/build/register-vue`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f7f993b85..6073b5c84 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5080,6 +5080,10 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, * views re-render, code that published translated text words it again through `onLocaleChange`, and ``/`dir` and the page's `data-i18n` markup follow. Nothing is lost; a toast already on screen keeps its words. + An application that offers the language elsewhere (a menu) uses the + `./i18n` entry (`src/i18n/page.js`): `startPageLocale` at boot, + `followPageLocale` for the markup and `switchLocale`, which does what a + picker choice does, with `localePicker: false`. - **Right to left.** Arabic (`ar`) and Hebrew (`he`) are in `RTL_LOCALES`, so ``: the title, style indicator, both panel stacks and their icon rails swap sides through logical properties (`inset-inline-*`, diff --git a/docs/I18N.md b/docs/I18N.md index 0d57f14d5..bacfdc509 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -59,6 +59,30 @@ The language picker switches in place, without a reload. A view that calls `t()` A toast already on screen keeps its words. +## An application with its own language choice + +An application that composes Core and offers the language somewhere else (a menu, a settings page) imports the `./i18n` entry (`src/i18n/page.js`) and passes `localePicker: false` to `createApplicationControls`: + +```js +import { + LOCALES, + LOCALE_NAMES, + followPageLocale, + getLocale, + onLocaleChange, + startPageLocale, + switchLocale, +} from '/i18n'; + +startPageLocale(document); // before any view renders: ?lang=, stored choice, browser, English; translates data-i18n markup +const stopFollowing = followPageLocale(document); // /dir and data-i18n markup follow each switch + +for (const code of LOCALES) addMenuItem(LOCALE_NAMES[code], code === getLocale()); +menu.onChoose = (code) => switchLocale(code); // switched in place and stored (in ?lang when storage is blocked) +``` + +`switchLocale` does what a choice in the globe picker does, and returns `'unchanged'` for the active language. `onLocaleChange` tells the application's own menu to mark the new language. + ## Rules - **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`. @@ -109,6 +133,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca | `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/page.js` | the `./i18n` entry: start, follow and switch the page's language | | `src/i18n/locales/.json` | one catalog per language, keyed by English text | | `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose | | `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text | diff --git a/package.json b/package.json index 6c11697bc..c1476342d 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "./application": "./src/app/application.js", "./application/viewer": "./src/app/viewer.js", "./time": "./src/time/worldClock.js", + "./i18n": "./src/i18n/page.js", "./build/vite": { "node": "./build/vite.js" }, diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b580e5193..df01df506 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1342,6 +1342,8 @@ "src/data/layerState.js", "src/data/layerState.test.mjs", "src/data/spriteOrder.js", + "src/i18n/page.js", + "src/i18n/page.test.mjs", "src/i18n/locales/ar.json", "src/i18n/locales/de.json", "src/i18n/locales/es.json", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ae86026d5..cfc18f7dd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -175,6 +175,28 @@ ], "external": ["vue", "pinia", "vue-i18n"] }, + "i18n": { + "exports": ["./i18n"], + "modules": [ + "src/i18n/page.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/ar.json", + "src/i18n/locales/de.json", + "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", + "src/i18n/locales/he.json", + "src/i18n/locales/it.json", + "src/i18n/locales/ja.json", + "src/i18n/locales/pt.json", + "src/i18n/locales/ru.json", + "src/i18n/locales/sv.json", + "src/i18n/locales/uk.json", + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" + ], + "external": [] + }, "time": { "exports": ["./time"], "modules": ["src/time/worldClock.js"], diff --git a/src/app/controls.js b/src/app/controls.js index 99bc2555f..def69325d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -9,7 +9,8 @@ import { t } from '../i18n/index.js'; * Construct the existing controls and camera presentation. `initialShareCode` * opens a share code (the `` of `/share/`) at start-up, for a host * whose address does not carry one. `localePicker: false` removes the language - * picker, for a host that does not initialize Core's locale. + * picker, for a host that offers its own language choice (`switchLocale` from + * the `./i18n` entry). */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, diff --git a/src/i18n/page.js b/src/i18n/page.js new file mode 100644 index 000000000..5ecf13fe8 --- /dev/null +++ b/src/i18n/page.js @@ -0,0 +1,81 @@ +/** + * @module i18n/page + * @description The page's interface language, for an application that + * composes Core and offers its own language choice (a menu item, a settings + * page) instead of the globe picker. It starts the language the standalone + * application starts, keeps ``/`` and the page's + * `data-i18n` markup in step, and switches in place, stored for the next + * visit, exactly as the picker does. Guide: docs/I18N.md. + */ +import { + DEFAULT_LOCALE, + LOCALES, + LOCALE_NAMES, + applyDocumentLanguage, + isRtlLocale, + normalizeLocale, + resolveLocale, +} from './locale.js'; +import { + applyDocumentTranslations, + getLocale, + onLocaleChange, + persistLocale, + setLocale, +} from './index.js'; + +export { + DEFAULT_LOCALE, + LOCALES, + LOCALE_NAMES, + applyDocumentTranslations, + getLocale, + isRtlLocale, + onLocaleChange, +}; + +/** + * Start the page in its language: `?lang=`, then the stored choice, then the + * browser's languages, then English. Call it before any view renders, so + * every lookup and ``/`` agree from the first frame; the + * page's static `data-i18n` markup (a loading screen) is translated now. + * @param {Document} [doc] + * @param {Parameters[0]} [inputs] Location, storage + * and languages, for tests. + * @returns {string} The active locale. + */ +export function startPageLocale(doc = globalThis.document, inputs) { + const locale = setLocale(resolveLocale(inputs)); + applyDocumentLanguage(doc, locale); + applyDocumentTranslations(doc); + return locale; +} + +/** + * Keep ``/`` and the page's `data-i18n` markup in step + * with every language switch. + * @param {Document} [doc] + * @returns {() => void} Stops following. + */ +export function followPageLocale(doc = globalThis.document) { + return onLocaleChange(() => { + applyDocumentLanguage(doc, getLocale()); + applyDocumentTranslations(doc); + }); +} + +/** + * Switch the page to `locale` in place and keep it for the next visit. The + * active language, or an unknown code that would fall back to it, changes + * nothing. + * @param {string} locale One of LOCALES. + * @param {Parameters[1]} [options] Location, history + * and storage, for tests. + * @returns {'unchanged'|'switched'} + */ +export function switchLocale(locale, options) { + const next = normalizeLocale(locale) || DEFAULT_LOCALE; + if (next === getLocale()) return 'unchanged'; + setLocale(persistLocale(next, options)); + return 'switched'; +} diff --git a/src/i18n/page.test.mjs b/src/i18n/page.test.mjs new file mode 100644 index 000000000..626912daa --- /dev/null +++ b/src/i18n/page.test.mjs @@ -0,0 +1,106 @@ +// src/i18n/page.test.mjs — an application with its own language choice starts, +// follows and switches the page's language as the globe picker does. +import { afterEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { LOCALE_STORAGE_KEY } from './locale.js'; +import { + followPageLocale, + getLocale, + startPageLocale, + switchLocale, +} from './page.js'; +import { setLocale } from './index.js'; + +afterEach(() => setLocale('en')); + +function memoryStorage() { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => data.set(key, String(value)), + }; +} + +function page(elements = []) { + return { + documentElement: { lang: '', dir: '' }, + querySelectorAll: (selector) => + selector === '[data-i18n]' ? elements : [], + }; +} + +/** A data-i18n element, as applyDocumentTranslations reads one. */ +function marked(text) { + return { + textContent: text, + hasAttribute: (name) => name === 'data-i18n', + getAttribute: () => null, + }; +} + +test('the page starts in ?lang=, else the stored choice, with lang and dir set', () => { + const doc = page(); + const storage = memoryStorage(); + storage.setItem(LOCALE_STORAGE_KEY, 'de'); + assert.equal( + startPageLocale(doc, { + location: { search: '?lang=ar' }, + storage, + languages: ['ru'], + }), + 'ar', + ); + assert.equal(getLocale(), 'ar'); + assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' }); + assert.equal( + startPageLocale(doc, { location: { search: '' }, storage, languages: [] }), + 'de', + ); + assert.deepEqual(doc.documentElement, { lang: 'de', dir: 'ltr' }); +}); + +test("the page's data-i18n markup is in its language from the start", () => { + const close = marked('Close'); + startPageLocale(page([close]), { + location: { search: '?lang=de' }, + storage: memoryStorage(), + languages: [], + }); + assert.equal(close.textContent, 'Schließen'); +}); + +test('a switch is stored, drops ?lang, and the followed page takes it', () => { + const doc = page(); + const stop = followPageLocale(doc); + const storage = memoryStorage(); + const replaced = []; + const options = { + location: { href: 'https://example.test/?lang=fr#view' }, + history: { replaceState: (_s, _t, url) => replaced.push(url) }, + storage, + }; + assert.equal(switchLocale('he', options), 'switched'); + assert.equal(getLocale(), 'he'); + assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'he'); + assert.deepEqual(replaced, ['https://example.test/#view']); + assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' }); + stop(); + switchLocale('ja', options); + assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' }); +}); + +test('choosing the active language, or one that falls back to it, changes nothing', () => { + const storage = memoryStorage(); + const options = { + location: { href: 'https://example.test/' }, + history: { replaceState() {} }, + storage, + }; + assert.equal(switchLocale('en', options), 'unchanged'); + assert.equal(switchLocale('xx', options), 'unchanged'); + assert.equal(storage.data.size, 0); + assert.equal(switchLocale('pt-BR', options), 'switched'); + assert.equal(getLocale(), 'pt'); + assert.equal(switchLocale('pt', options), 'unchanged'); +}); diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index 9d438643d..ab50fa19b 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -64,12 +64,9 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) { document: { getElementById: () => ({ querySelector: () => ({}) }), }, - resolveLocale: () => 'en', - setLocale() {}, - getLocale: () => 'en', - onLocaleChange: () => () => {}, + startPageLocale: () => 'en', + followPageLocale: () => () => {}, mountApplicationViews() {}, - applyDocumentLanguage() {}, applyDocumentTranslations() {}, CITY_POIS: [], createApplication: (options) => options, diff --git a/src/standalone/application.js b/src/standalone/application.js index fe152fd9b..85948d8e1 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -6,13 +6,11 @@ import { createStandaloneScene } from './scene.js'; import { createStandaloneControls } from './controls.js'; import { createStandaloneData } from './data.js'; import { createStandaloneTools } from './tools.js'; -import { resolveLocale, applyDocumentLanguage } from '../i18n/locale.js'; import { applyDocumentTranslations, - getLocale, - onLocaleChange, - setLocale, -} from '../i18n/index.js'; + followPageLocale, + startPageLocale, +} from '../i18n/page.js'; import { mountApplicationViews } from '../ui/views/index.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; @@ -37,9 +35,7 @@ export function createStandaloneApplication({ // scripts execute after parsing, so the DOM is ready here: the views // translate as they render, and the page's static data-i18n* markup (the // loading screen) is translated here and after every language switch. - const activeLocale = resolveLocale(); - setLocale(activeLocale); - applyDocumentLanguage(document, activeLocale); + startPageLocale(document); // Views render the page's markup; wiring reads it. mountApplicationViews(document); applyDocumentTranslations(document); @@ -50,12 +46,7 @@ export function createStandaloneApplication({ return createApplication({ createScene: async (context) => { // A language switch applies in place; the page's own markup follows. - context.defer( - onLocaleChange(() => { - applyDocumentLanguage(document, getLocale()); - applyDocumentTranslations(document); - }), - ); + context.defer(followPageLocale(document)); placeSearch = createStandalonePlaceSearch({ // The bundled city and landmark data the offline name provider reads. // The search package takes it as plain data rather than importing it, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0adedd7a5..b6d77b912 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -96,7 +96,7 @@ export class StyleManager extends ShellFacade { * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {object} [options] * @param {boolean} [options.localePicker=true] - false removes the language - * picker, for a host that does not initialize Core's locale. + * picker, for a host that offers its own language choice. */ constructor( viewer, @@ -764,7 +764,7 @@ export class StyleManager extends ShellFacade { this._lifetime.own( onLocaleChange(() => nextTick(() => this._sizeDisplayLabelColumn())), ); - // A host that does not initialize Core's locale shows no picker. + // A host that offers its own language choice shows no picker. if (this._localePickerEnabled === false) return; this._publishLocalePicker(); }