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.
This commit is contained in:
Sameh Khamis committed 2026-11-12 22:07:13 -08:00
1 parent feab48397f
commit 5f96009b67
12 files changed
+255 -23

No files matched your search

+2 -1
View File
@@ -9,7 +9,8 @@ import { t } from '../i18n/index.js';
* Construct the existing controls and camera presentation. `initialShareCode`
* 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
* 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 },
+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: {
getElementById: () => ({ querySelector: () => ({}) }),
},
resolveLocale: () => 'en',
setLocale() {},
getLocale: () => 'en',
onLocaleChange: () => () => {},
startPageLocale: () => 'en',
followPageLocale: () => () => {},
mountApplicationViews() {},
applyDocumentLanguage() {},
applyDocumentTranslations() {},
CITY_POIS: [],
createApplication: (options) => options,
+5 -14
View File
@@ -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,
+2 -2
View File
@@ -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();
}