Merge pull request #1696 from bilawalsidhu/sameh/i18n-entry

feat(i18n): an ./i18n entry for an application with its own language choice
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-12 22:21:25 -08:00
commit 188f0ccd9b
13 files changed
+258 -23

No files matched your search

+3
View File
@@ -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).
+3 -1
View File
@@ -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 leaving out `context` the Bhote Koshi reconstruction panel; and leaving out
`intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD `intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD
implementation publishes to the `gev-hud` store. An unknown 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 `localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`. loader with `node --import=<core package>/build/register-vue`.
+4
View File
@@ -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 views re-render, code that published translated text words it again through
`onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup `onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup
follow. Nothing is lost; a toast already on screen keeps its words. 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 - **Right to left.** Arabic (`ar`) and Hebrew (`he`) are in `RTL_LOCALES`, so
`<html dir="rtl">`: the title, style indicator, both panel stacks and their `<html dir="rtl">`: the title, style indicator, both panel stacks and their
icon rails swap sides through logical properties (`inset-inline-*`, icon rails swap sides through logical properties (`inset-inline-*`,
+25
View File
@@ -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. 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 '<core package>/i18n';
startPageLocale(document); // before any view renders: ?lang=, stored choice, browser, English; translates data-i18n markup
const stopFollowing = followPageLocale(document); // <html lang>/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 ## Rules
- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`. - **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/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/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/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/<code>.json` | one catalog per language, keyed by English text | | `src/i18n/locales/<code>.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 | | `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 | | `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
+1
View File
@@ -99,6 +99,7 @@
"./application": "./src/app/application.js", "./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js", "./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js", "./time": "./src/time/worldClock.js",
"./i18n": "./src/i18n/page.js",
"./build/vite": { "./build/vite": {
"node": "./build/vite.js" "node": "./build/vite.js"
}, },
+2
View File
@@ -1342,6 +1342,8 @@
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerState.test.mjs", "src/data/layerState.test.mjs",
"src/data/spriteOrder.js", "src/data/spriteOrder.js",
"src/i18n/page.js",
"src/i18n/page.test.mjs",
"src/i18n/locales/ar.json", "src/i18n/locales/ar.json",
"src/i18n/locales/de.json", "src/i18n/locales/de.json",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
+22
View File
@@ -175,6 +175,28 @@
], ],
"external": ["vue", "pinia", "vue-i18n"] "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": { "time": {
"exports": ["./time"], "exports": ["./time"],
"modules": ["src/time/worldClock.js"], "modules": ["src/time/worldClock.js"],
+2 -1
View File
@@ -9,7 +9,8 @@ import { t } from '../i18n/index.js';
* Construct the existing controls and camera presentation. `initialShareCode` * Construct the existing controls and camera presentation. `initialShareCode`
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host * opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
* whose address does not carry one. `localePicker: false` removes the language * 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({ export function createApplicationControls({
scene: { viewer, mapStackController, operations }, scene: { viewer, mapStackController, operations },
+81
View File
@@ -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 `<html lang>`/`<html dir>` 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 `<html lang>`/`<html dir>` agree from the first frame; the
* page's static `data-i18n` markup (a loading screen) is translated now.
* @param {Document} [doc]
* @param {Parameters<typeof resolveLocale>[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 `<html lang>`/`<html dir>` 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<typeof persistLocale>[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';
}
+106
View File
@@ -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');
});
+2 -5
View File
@@ -64,12 +64,9 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
document: { document: {
getElementById: () => ({ querySelector: () => ({}) }), getElementById: () => ({ querySelector: () => ({}) }),
}, },
resolveLocale: () => 'en', startPageLocale: () => 'en',
setLocale() {}, followPageLocale: () => () => {},
getLocale: () => 'en',
onLocaleChange: () => () => {},
mountApplicationViews() {}, mountApplicationViews() {},
applyDocumentLanguage() {},
applyDocumentTranslations() {}, applyDocumentTranslations() {},
CITY_POIS: [], CITY_POIS: [],
createApplication: (options) => options, createApplication: (options) => options,
+5 -14
View File
@@ -6,13 +6,11 @@ import { createStandaloneScene } from './scene.js';
import { createStandaloneControls } from './controls.js'; import { createStandaloneControls } from './controls.js';
import { createStandaloneData } from './data.js'; import { createStandaloneData } from './data.js';
import { createStandaloneTools } from './tools.js'; import { createStandaloneTools } from './tools.js';
import { resolveLocale, applyDocumentLanguage } from '../i18n/locale.js';
import { import {
applyDocumentTranslations, applyDocumentTranslations,
getLocale, followPageLocale,
onLocaleChange, startPageLocale,
setLocale, } from '../i18n/page.js';
} from '../i18n/index.js';
import { mountApplicationViews } from '../ui/views/index.js'; import { mountApplicationViews } from '../ui/views/index.js';
import { createGoogleTokenSource } from '../maps/googleTokens.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 // 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 // translate as they render, and the page's static data-i18n* markup (the
// loading screen) is translated here and after every language switch. // loading screen) is translated here and after every language switch.
const activeLocale = resolveLocale(); startPageLocale(document);
setLocale(activeLocale);
applyDocumentLanguage(document, activeLocale);
// Views render the page's markup; wiring reads it. // Views render the page's markup; wiring reads it.
mountApplicationViews(document); mountApplicationViews(document);
applyDocumentTranslations(document); applyDocumentTranslations(document);
@@ -50,12 +46,7 @@ export function createStandaloneApplication({
return createApplication({ return createApplication({
createScene: async (context) => { createScene: async (context) => {
// A language switch applies in place; the page's own markup follows. // A language switch applies in place; the page's own markup follows.
context.defer( context.defer(followPageLocale(document));
onLocaleChange(() => {
applyDocumentLanguage(document, getLocale());
applyDocumentTranslations(document);
}),
);
placeSearch = createStandalonePlaceSearch({ placeSearch = createStandalonePlaceSearch({
// The bundled city and landmark data the offline name provider reads. // The bundled city and landmark data the offline name provider reads.
// The search package takes it as plain data rather than importing it, // The search package takes it as plain data rather than importing it,
+2 -2
View File
@@ -96,7 +96,7 @@ export class StyleManager extends ShellFacade {
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
* @param {object} [options] * @param {object} [options]
* @param {boolean} [options.localePicker=true] - false removes the language * @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( constructor(
viewer, viewer,
@@ -764,7 +764,7 @@ export class StyleManager extends ShellFacade {
this._lifetime.own( this._lifetime.own(
onLocaleChange(() => nextTick(() => this._sizeDisplayLabelColumn())), 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; if (this._localePickerEnabled === false) return;
this._publishLocalePicker(); this._publishLocalePicker();
} }