mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 06:42:23 +00:00
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:
13 files changed
+258
-23
No files matched your search
@@ -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
@@ -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=<core package>/build/register-vue`.
|
||||
@@ -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 `<html lang>`/`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
|
||||
`<html dir="rtl">`: the title, style indicator, both panel stacks and their
|
||||
icon rails swap sides through logical properties (`inset-inline-*`,
|
||||
|
||||
@@ -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 '<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
|
||||
|
||||
- **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/<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 |
|
||||
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Vendored
+2
-1
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user