diff --git a/changelog.d/1696-i18n-entry.md b/changelog.d/1696-i18n-entry.md new file mode 100644 index 000000000..7fc2b0a5a --- /dev/null +++ b/changelog.d/1696-i18n-entry.md @@ -0,0 +1,3 @@ +- Add an `./i18n` entry so an application with its own language menu can + start, follow and switch the interface language as the globe picker does + (samehkhamis, #1696). 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(); }