mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
i18n: keep the page's locale in a vue-i18n instance
The application views install one vue-i18n instance that holds the active locale and the catalogs. src/i18n/index.js stays free of Vue: bindLocale() keeps its locale in the instance's ref, so t(), tn() and the Intl formatters read it from there and a view that translates while rendering renders again when the locale changes. A tag written straight to the ref is normalized. Message formatting, catalogs and call sites are unchanged, and switching language still reloads the page. The instance outlives the views: unmounting one no longer disposes it. The browser build defines vue-i18n's feature flags and drops its message compiler, which Core does not use.
This commit is contained in:
1 parent
22d4cf8ee7
commit
2c72a038d5
12 files changed
+330
-44
No files matched your search
@@ -211,6 +211,12 @@ export function createBrowserViteConfig({
|
|||||||
// framing + CSP hardening (one constant, no drift).
|
// framing + CSP hardening (one constant, no drift).
|
||||||
preview: { headers: BROWSER_HEADERS },
|
preview: { headers: BROWSER_HEADERS },
|
||||||
define: {
|
define: {
|
||||||
|
// vue-i18n holds the locale and catalogs; Core formats the messages
|
||||||
|
// (src/ui/i18n.js), so the build drops vue-i18n's message compiler.
|
||||||
|
__VUE_I18N_FULL_INSTALL__: 'false',
|
||||||
|
__VUE_I18N_LEGACY_API__: 'false',
|
||||||
|
__INTLIFY_PROD_DEVTOOLS__: 'false',
|
||||||
|
__INTLIFY_DROP_MESSAGE_COMPILER__: 'true',
|
||||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||||
// Mapillary client tokens are public by design (see SECURITY.md).
|
// Mapillary client tokens are public by design (see SECURITY.md).
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
- The page's locale lives in a vue-i18n instance installed in every
|
||||||
|
application view, and `t()`, `tn()` and the Intl formatters read it from
|
||||||
|
there, so a view that translates while rendering renders again when the
|
||||||
|
locale changes. `bindLocale` (`src/i18n/index.js`) keeps the locale in a
|
||||||
|
given ref; Core's Vite config builds vue-i18n without its message
|
||||||
|
compiler. Catalogs, messages and call sites are unchanged
|
||||||
|
(samehkhamis, #0000).
|
||||||
|
|
||||||
- Cockpit's Display popover shows the Display panel's own HUD, Detection,
|
- Cockpit's Display popover shows the Display panel's own HUD, Detection,
|
||||||
Parameters and 3D groups through Vue teleports: `CockpitDisplayPortal`
|
Parameters and 3D groups through Vue teleports: `CockpitDisplayPortal`
|
||||||
publishes the popover's slots (`publish`, with `afterRender` to lay out once
|
publishes the popover's slots (`publish`, with `afterRender` to lay out once
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ label.textContent = t(FEED_STATE_LABELS[state]);
|
|||||||
|
|
||||||
A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.)
|
A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.)
|
||||||
|
|
||||||
|
Vue views call the same `t()` and `tn()`. The page's vue-i18n instance (`src/ui/i18n.js`), installed in every view, holds the active locale, and `t()`, `tn()` and the formatters read it from there, so a view that translates while rendering renders again when the locale changes. Views don't use vue-i18n's own `t`: English is the key, `context` picks an entry and plural forms follow `Intl.PluralRules`, which only Core's functions do.
|
||||||
|
|
||||||
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
|
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
|
||||||
|
|
||||||
## Markup
|
## Markup
|
||||||
@@ -84,6 +86,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca
|
|||||||
| File | What it holds |
|
| File | What it holds |
|
||||||
| ------------------------------- | ------------------------------------------------------------------------------ |
|
| ------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
||||||
|
| `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/locales/<code>.json` | one catalog per language, keyed by English text |
|
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
|
||||||
|
|||||||
Generated
+84
-1
@@ -21,7 +21,8 @@
|
|||||||
"pbf": "^5.1.2",
|
"pbf": "^5.1.2",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"satellite.js": "^6.0.2",
|
"satellite.js": "^6.0.2",
|
||||||
"vue": "^3.5.44"
|
"vue": "^3.5.44",
|
||||||
|
"vue-i18n": "^11.4.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@happy-dom/global-registrator": "^20.14.6",
|
"@happy-dom/global-registrator": "^20.14.6",
|
||||||
@@ -1130,6 +1131,67 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "11.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.15.tgz",
|
||||||
|
"integrity": "sha512-yHNg7+M6VKQuj+KUO/CrtcBMm3iAspvfFe1H/eKI0TP0pn2T2srBF1gEb/TL3c8RA/BZZGOIySwtZD0i+9/hhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/devtools-types": "11.4.15",
|
||||||
|
"@intlify/message-compiler": "11.4.15",
|
||||||
|
"@intlify/shared": "11.4.15"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/devtools-types": {
|
||||||
|
"version": "11.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.15.tgz",
|
||||||
|
"integrity": "sha512-NFV9LsP720IkR6BHNfUYBSkd/abocWimcOWAS63D3spwczy8zDvXCf9WMyhCVQ7FcYkWyECZke18x8D6UNVBFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.4.15",
|
||||||
|
"@intlify/shared": "11.4.15"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "11.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.15.tgz",
|
||||||
|
"integrity": "sha512-dV6apms3MXAG3jZ/6bAq+IW2IKDGasmjA/Aoi0JVKCStVi6GbzrPruCVbU/Djn1n41w5EiM3KdnpTuo6I1qe5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "11.4.15",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "11.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.15.tgz",
|
||||||
|
"integrity": "sha512-jlTJkM4bMdr267e1iUE2RMNz1LyoYU6IPReKjnQM6RvipEBUVwmMHbeUckDcOmR+WeungEGjaNmSQJdQzJcOfQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
@@ -3700,6 +3762,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "11.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.15.tgz",
|
||||||
|
"integrity": "sha512-bOoNYoEd/ta4g2EelI9HKxPyMZvTDFoykTNYujDTOGoKDRy5ehKJg8WmMDU7hWqNM5efO7fEGNRuwrBXnwHyDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.4.15",
|
||||||
|
"@intlify/devtools-types": "11.4.15",
|
||||||
|
"@intlify/shared": "11.4.15",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-worker": {
|
"node_modules/web-worker": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -68,7 +68,8 @@
|
|||||||
"pbf": "^5.1.2",
|
"pbf": "^5.1.2",
|
||||||
"pinia": "^2.3.1",
|
"pinia": "^2.3.1",
|
||||||
"satellite.js": "^6.0.2",
|
"satellite.js": "^6.0.2",
|
||||||
"vue": "^3.5.44"
|
"vue": "^3.5.44",
|
||||||
|
"vue-i18n": "^11.4.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@happy-dom/global-registrator": "^20.14.6",
|
"@happy-dom/global-registrator": "^20.14.6",
|
||||||
|
|||||||
@@ -143,9 +143,10 @@
|
|||||||
"src/ui/views/panels/GlobeActions.vue",
|
"src/ui/views/panels/GlobeActions.vue",
|
||||||
"src/ui/views/panels/GlobeCompass.vue",
|
"src/ui/views/panels/GlobeCompass.vue",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/styleParameters.js"
|
"src/ui/styleParameters.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"exports": ["./application/viewer"],
|
"exports": ["./application/viewer"],
|
||||||
@@ -730,9 +731,10 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"cctv-controls": {
|
"cctv-controls": {
|
||||||
"exports": ["./ui/cctv"],
|
"exports": ["./ui/cctv"],
|
||||||
@@ -759,9 +761,10 @@
|
|||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/ui/vueRuntime.js",
|
"src/ui/vueRuntime.js",
|
||||||
"src/ui/stores/cctv.js",
|
"src/ui/stores/cctv.js",
|
||||||
"src/ui/stores/status.js"
|
"src/ui/stores/status.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"context-controls": {
|
"context-controls": {
|
||||||
"exports": ["./ui/context", "./ui/context/policy"],
|
"exports": ["./ui/context", "./ui/context/policy"],
|
||||||
@@ -862,9 +865,10 @@
|
|||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js",
|
"src/ui/unitLabels.js",
|
||||||
"src/ui/vueRuntime.js"
|
"src/ui/vueRuntime.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"ui-shell": {
|
"ui-shell": {
|
||||||
"exports": ["./ui/shell"],
|
"exports": ["./ui/shell"],
|
||||||
@@ -1083,9 +1087,10 @@
|
|||||||
"src/ui/kit/UiButton.vue",
|
"src/ui/kit/UiButton.vue",
|
||||||
"src/ui/kit/UiBadge.vue",
|
"src/ui/kit/UiBadge.vue",
|
||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/stores/recording.js"
|
"src/ui/stores/recording.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "vue", "pinia"]
|
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"ui-styles": {
|
"ui-styles": {
|
||||||
"exports": ["./ui/styles"],
|
"exports": ["./ui/styles"],
|
||||||
@@ -1215,9 +1220,10 @@
|
|||||||
"src/ui/stores/status.js",
|
"src/ui/stores/status.js",
|
||||||
"src/ui/toast.js",
|
"src/ui/toast.js",
|
||||||
"src/ui/stores/sceneInteractions.js",
|
"src/ui/stores/sceneInteractions.js",
|
||||||
"src/ui/stores/dataPacks.js"
|
"src/ui/stores/dataPacks.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "vue", "pinia"]
|
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"live-observations": {
|
"live-observations": {
|
||||||
"exports": ["./sources/live"],
|
"exports": ["./sources/live"],
|
||||||
@@ -2128,9 +2134,10 @@
|
|||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/ui/stores/voice.js",
|
"src/ui/stores/voice.js",
|
||||||
"src/ui/vueRuntime.js",
|
"src/ui/vueRuntime.js",
|
||||||
"src/ui/stores/pointerReticle.js"
|
"src/ui/stores/pointerReticle.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"application-components": {
|
"application-components": {
|
||||||
"exports": [
|
"exports": [
|
||||||
@@ -3098,7 +3105,8 @@
|
|||||||
"src/ui/kit/UiEmpty.vue",
|
"src/ui/kit/UiEmpty.vue",
|
||||||
"src/ui/kit/UiBadge.vue",
|
"src/ui/kit/UiBadge.vue",
|
||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/stores/recording.js"
|
"src/ui/stores/recording.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3111,7 +3119,8 @@
|
|||||||
"mapillary-js",
|
"mapillary-js",
|
||||||
"@jtarrio/webrtlsdr",
|
"@jtarrio/webrtlsdr",
|
||||||
"vue",
|
"vue",
|
||||||
"pinia"
|
"pinia",
|
||||||
|
"vue-i18n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"application-catalog": {
|
"application-catalog": {
|
||||||
@@ -3690,7 +3699,8 @@
|
|||||||
"src/ui/stores/windInspection.js",
|
"src/ui/stores/windInspection.js",
|
||||||
"src/layers/wind/inspectionMarker.js",
|
"src/layers/wind/inspectionMarker.js",
|
||||||
"src/ui/stores/bhoteKoshiPanel.js",
|
"src/ui/stores/bhoteKoshiPanel.js",
|
||||||
"src/hudReadouts.js"
|
"src/hudReadouts.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3703,7 +3713,8 @@
|
|||||||
"mapillary-js",
|
"mapillary-js",
|
||||||
"@jtarrio/webrtlsdr",
|
"@jtarrio/webrtlsdr",
|
||||||
"vue",
|
"vue",
|
||||||
"pinia"
|
"pinia",
|
||||||
|
"vue-i18n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"application-operations": {
|
"application-operations": {
|
||||||
@@ -3874,9 +3885,10 @@
|
|||||||
"src/ui/displayControls.js",
|
"src/ui/displayControls.js",
|
||||||
"src/visualStylePolicy.js",
|
"src/visualStylePolicy.js",
|
||||||
"src/ui/rail/fieldValue.js",
|
"src/ui/rail/fieldValue.js",
|
||||||
"src/ui/kit/UiSlider.js"
|
"src/ui/kit/UiSlider.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "vue", "pinia"]
|
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"ui-panel-chrome": {
|
"ui-panel-chrome": {
|
||||||
"exports": ["./ui/panel-chrome"],
|
"exports": ["./ui/panel-chrome"],
|
||||||
@@ -3910,9 +3922,10 @@
|
|||||||
"src/ui/panelAccordion.js",
|
"src/ui/panelAccordion.js",
|
||||||
"src/ui/vueRuntime.js",
|
"src/ui/vueRuntime.js",
|
||||||
"src/ui/stores/panelChrome.js",
|
"src/ui/stores/panelChrome.js",
|
||||||
"src/ui/rail/panelState.js"
|
"src/ui/rail/panelState.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"civil-flight-records": {
|
"civil-flight-records": {
|
||||||
"exports": ["./layers/flights/records"],
|
"exports": ["./layers/flights/records"],
|
||||||
@@ -4075,9 +4088,10 @@
|
|||||||
"src/voice/providerCapabilities.js",
|
"src/voice/providerCapabilities.js",
|
||||||
"src/ui/vueRuntime.js",
|
"src/ui/vueRuntime.js",
|
||||||
"src/ui/stores/voice.js",
|
"src/ui/stores/voice.js",
|
||||||
"src/ui/stores/pointerReticle.js"
|
"src/ui/stores/pointerReticle.js",
|
||||||
|
"src/ui/i18n.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"alpr-source": {
|
"alpr-source": {
|
||||||
"exports": ["./layers/alpr/source"],
|
"exports": ["./layers/alpr/source"],
|
||||||
|
|||||||
+36
-15
@@ -40,16 +40,37 @@ const CATALOGS = Object.freeze({
|
|||||||
'zh-Hant': zhHant,
|
'zh-Hant': zhHant,
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentLocale = DEFAULT_LOCALE;
|
// The page's locale. The UI binds it to its vue-i18n instance's
|
||||||
|
// (src/ui/i18n.js), so a Vue render that translates re-renders when it
|
||||||
|
// changes; until then, and in portable code, it is plain state.
|
||||||
|
let pageLocale = { value: DEFAULT_LOCALE };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the active locale in `locale`, a ref such as a vue-i18n composer's
|
||||||
|
* `locale`. It takes the current locale; switch with setLocale afterwards.
|
||||||
|
* @param {{value: string}} locale
|
||||||
|
*/
|
||||||
|
export function bindLocale(locale) {
|
||||||
|
locale.value = pageLocale.value;
|
||||||
|
pageLocale = locale;
|
||||||
|
}
|
||||||
|
|
||||||
/** The catalog for a locale; null for English and unknown codes. */
|
/** The catalog for a locale; null for English and unknown codes. */
|
||||||
export function getCatalog(locale) {
|
export function getCatalog(locale) {
|
||||||
return CATALOGS[normalizeLocale(locale)] ?? null;
|
return CATALOGS[normalizeLocale(locale)] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Active locale. */
|
let boundValue = DEFAULT_LOCALE;
|
||||||
|
let activeLocale = DEFAULT_LOCALE;
|
||||||
|
|
||||||
|
/** Active locale, normalized: a bound ref may be set to any language tag. */
|
||||||
export function getLocale() {
|
export function getLocale() {
|
||||||
return currentLocale;
|
const value = pageLocale.value;
|
||||||
|
if (value !== boundValue) {
|
||||||
|
boundValue = value;
|
||||||
|
activeLocale = normalizeLocale(value) || DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
return activeLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,18 +80,18 @@ export function getLocale() {
|
|||||||
*/
|
*/
|
||||||
export function setLocale(locale) {
|
export function setLocale(locale) {
|
||||||
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
|
||||||
if (normalized !== currentLocale) {
|
if (normalized !== pageLocale.value) {
|
||||||
currentLocale = normalized;
|
pageLocale.value = normalized;
|
||||||
formatterCache.clear();
|
formatterCache.clear();
|
||||||
}
|
}
|
||||||
return currentLocale;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Development-only warning; silent under node:test and in production. */
|
/** Development-only warning; silent under node:test and in production. */
|
||||||
function warnMissing(text) {
|
function warnMissing(text) {
|
||||||
if (import.meta.env?.DEV !== true) return;
|
if (import.meta.env?.DEV !== true) return;
|
||||||
console.warn(
|
console.warn(
|
||||||
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
|
`[i18n] no ${getLocale()} translation for ${JSON.stringify(text)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +110,7 @@ function interpolate(pattern, values) {
|
|||||||
const filled = (value) => typeof value === 'string' && value.trim() !== '';
|
const filled = (value) => typeof value === 'string' && value.trim() !== '';
|
||||||
|
|
||||||
function lookup(text) {
|
function lookup(text) {
|
||||||
const catalog = CATALOGS[currentLocale];
|
const catalog = CATALOGS[getLocale()];
|
||||||
if (!catalog) return undefined;
|
if (!catalog) return undefined;
|
||||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||||
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
|
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
|
||||||
@@ -124,7 +145,7 @@ export function t(text, values) {
|
|||||||
*/
|
*/
|
||||||
export function tn(count, one, other, values) {
|
export function tn(count, one, other, values) {
|
||||||
const entry = lookup(other);
|
const entry = lookup(other);
|
||||||
const category = pluralRules(currentLocale).select(count);
|
const category = pluralRules(getLocale()).select(count);
|
||||||
let pattern;
|
let pattern;
|
||||||
const forms = entry && typeof entry === 'object' ? entry : null;
|
const forms = entry && typeof entry === 'object' ? entry : null;
|
||||||
if (forms && (filled(forms[category]) || filled(forms.other))) {
|
if (forms && (filled(forms[category]) || filled(forms.other))) {
|
||||||
@@ -141,7 +162,7 @@ export function tn(count, one, other, values) {
|
|||||||
* missing-translation warning.
|
* missing-translation warning.
|
||||||
*/
|
*/
|
||||||
export function translateKnown(text) {
|
export function translateKnown(text) {
|
||||||
const catalog = CATALOGS[currentLocale];
|
const catalog = CATALOGS[getLocale()];
|
||||||
if (!catalog || typeof text !== 'string') return text;
|
if (!catalog || typeof text !== 'string') return text;
|
||||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||||
return filled(entry) ? entry : text;
|
return filled(entry) ? entry : text;
|
||||||
@@ -171,10 +192,10 @@ function pluralRules(locale) {
|
|||||||
const formatterCache = new Map();
|
const formatterCache = new Map();
|
||||||
|
|
||||||
function cachedFormatter(kind, Constructor, options) {
|
function cachedFormatter(kind, Constructor, options) {
|
||||||
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
|
const cacheKey = `${kind}|${getLocale()}|${options ? JSON.stringify(options) : ''}`;
|
||||||
let formatter = formatterCache.get(cacheKey);
|
let formatter = formatterCache.get(cacheKey);
|
||||||
if (!formatter) {
|
if (!formatter) {
|
||||||
formatter = new Constructor(currentLocale, options);
|
formatter = new Constructor(getLocale(), options);
|
||||||
formatterCache.set(cacheKey, formatter);
|
formatterCache.set(cacheKey, formatter);
|
||||||
}
|
}
|
||||||
return formatter;
|
return formatter;
|
||||||
@@ -210,7 +231,7 @@ export function formatUtcDayMonth(value) {
|
|||||||
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
|
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
|
||||||
: new Date(value).getTime();
|
: new Date(value).getTime();
|
||||||
if (!Number.isFinite(time)) return '';
|
if (!Number.isFinite(time)) return '';
|
||||||
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
|
if (getLocale() === 'en') return new Date(time).toISOString().slice(5, 10);
|
||||||
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
|
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
|
||||||
time,
|
time,
|
||||||
);
|
);
|
||||||
@@ -228,7 +249,7 @@ export function formatRelativeTime(value, unit = 'second', options) {
|
|||||||
}).format(value, unit);
|
}).format(value, unit);
|
||||||
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
|
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
|
||||||
// unit together (5分前), so the two must not differ on one screen.
|
// unit together (5分前), so the two must not differ on one screen.
|
||||||
return currentLocale === 'ja'
|
return getLocale() === 'ja'
|
||||||
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
|
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
|
||||||
: text;
|
: text;
|
||||||
}
|
}
|
||||||
@@ -263,7 +284,7 @@ const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
|
|||||||
*/
|
*/
|
||||||
export function applyDocumentTranslations(doc) {
|
export function applyDocumentTranslations(doc) {
|
||||||
if (typeof doc?.querySelectorAll !== 'function') return 0;
|
if (typeof doc?.querySelectorAll !== 'function') return 0;
|
||||||
if (!CATALOGS[currentLocale]) return 0;
|
if (!CATALOGS[getLocale()]) return 0;
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
|
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
|
||||||
for (const element of doc.querySelectorAll(`[${marker}]`)) {
|
for (const element of doc.querySelectorAll(`[${marker}]`)) {
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
|||||||
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
|
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
|
||||||
assert.deepEqual(config.preview.headers, config.server.headers);
|
assert.deepEqual(config.preview.headers, config.server.headers);
|
||||||
assert.deepEqual(config.define, {
|
assert.deepEqual(config.define, {
|
||||||
|
__VUE_I18N_FULL_INSTALL__: 'false',
|
||||||
|
__VUE_I18N_LEGACY_API__: 'false',
|
||||||
|
__INTLIFY_PROD_DEVTOOLS__: 'false',
|
||||||
|
__INTLIFY_DROP_MESSAGE_COMPILER__: 'true',
|
||||||
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
||||||
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
||||||
'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
|
'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import { bindLocale, getCatalog, getLocale } from '../i18n/index.js';
|
||||||
|
import { DEFAULT_LOCALE, LOCALES } from '../i18n/locale.js';
|
||||||
|
|
||||||
|
let pageI18n = null;
|
||||||
|
|
||||||
|
// Catalogs are flat and keyed by English text, which holds dots.
|
||||||
|
const resolveFlat = (messages, key) =>
|
||||||
|
Object.hasOwn(messages, key) ? messages[key] : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's vue-i18n instance, installed in every application view. It
|
||||||
|
* holds the active locale and the catalogs; Core's t(), tn() and the Intl
|
||||||
|
* formatters read the locale from it, so a view that calls them while
|
||||||
|
* rendering re-renders when the locale changes. Views translate through
|
||||||
|
* t()/tn() rather than vue-i18n's own t: English is the key, `context` picks
|
||||||
|
* an entry, and plural forms follow Intl.PluralRules (docs/I18N.md).
|
||||||
|
* @returns {import('vue-i18n').I18n}
|
||||||
|
*/
|
||||||
|
export function applicationI18n() {
|
||||||
|
if (pageI18n) return pageI18n;
|
||||||
|
const messages = {};
|
||||||
|
for (const locale of LOCALES)
|
||||||
|
if (locale !== DEFAULT_LOCALE) messages[locale] = getCatalog(locale);
|
||||||
|
pageI18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
globalInjection: false,
|
||||||
|
locale: getLocale(),
|
||||||
|
fallbackLocale: DEFAULT_LOCALE,
|
||||||
|
messages,
|
||||||
|
messageResolver: resolveFlat,
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
});
|
||||||
|
// vue-i18n disposes its instance when an app it is installed in unmounts;
|
||||||
|
// the page's outlives every view, so an unmounted panel keeps it.
|
||||||
|
const install = pageI18n.install;
|
||||||
|
pageI18n.install = (app, ...options) => {
|
||||||
|
const unmount = app.unmount;
|
||||||
|
const installed = install.call(pageI18n, app, ...options);
|
||||||
|
app.unmount = unmount;
|
||||||
|
return installed;
|
||||||
|
};
|
||||||
|
bindLocale(pageI18n.global.locale);
|
||||||
|
return pageI18n;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||||
|
|
||||||
|
let vue;
|
||||||
|
let i18n;
|
||||||
|
let applicationI18n;
|
||||||
|
let uninstall;
|
||||||
|
before(async () => {
|
||||||
|
uninstall = installVueDom();
|
||||||
|
vue = await import('vue');
|
||||||
|
i18n = await import('../i18n/index.js');
|
||||||
|
({ applicationI18n } = await import('./i18n.js'));
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
i18n.setLocale('en');
|
||||||
|
uninstall();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vue-i18n takes the locale set before it and holds the catalogs', () => {
|
||||||
|
i18n.setLocale('ru');
|
||||||
|
const { global } = applicationI18n();
|
||||||
|
assert.equal(applicationI18n(), applicationI18n());
|
||||||
|
assert.equal(global.locale.value, 'ru');
|
||||||
|
assert.equal(global.te('Settings'), true);
|
||||||
|
assert.equal(global.tm('Settings'), 'Настройки');
|
||||||
|
global.locale.value = 'de';
|
||||||
|
assert.equal(i18n.getLocale(), 'de');
|
||||||
|
global.locale.value = 'ru_RU';
|
||||||
|
assert.equal(i18n.getLocale(), 'ru');
|
||||||
|
assert.equal(i18n.t('Settings'), 'Настройки');
|
||||||
|
assert.equal(i18n.formatNumber(1234.5), '1\u00a0234,5');
|
||||||
|
global.locale.value = 'xx';
|
||||||
|
assert.equal(i18n.getLocale(), 'en');
|
||||||
|
i18n.setLocale('en');
|
||||||
|
assert.equal(global.locale.value, 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a view that translates re-renders when the locale changes', async () => {
|
||||||
|
const { createApp, h, nextTick } = vue;
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
const app = createApp({
|
||||||
|
render: () =>
|
||||||
|
h('p', [
|
||||||
|
i18n.t('Settings'),
|
||||||
|
' / ',
|
||||||
|
i18n.tn(
|
||||||
|
3,
|
||||||
|
'POWER UP · {count} KEY WAITING',
|
||||||
|
'POWER UP · {count} KEYS WAITING',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
}).use(applicationI18n());
|
||||||
|
app.mount(host);
|
||||||
|
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
|
||||||
|
i18n.setLocale('ru');
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(host.textContent, 'Настройки / ЗАПУСК · 3 КЛЮЧА В ОЖИДАНИИ');
|
||||||
|
i18n.setLocale('en');
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
|
||||||
|
app.unmount();
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmounting one view keeps the page instance for the others', async () => {
|
||||||
|
const { createApp, h, nextTick } = vue;
|
||||||
|
const page = applicationI18n();
|
||||||
|
let disposed = 0;
|
||||||
|
const dispose = page.dispose;
|
||||||
|
page.dispose = () => {
|
||||||
|
disposed += 1;
|
||||||
|
dispose.call(page);
|
||||||
|
};
|
||||||
|
const mountView = () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
const app = createApp({ render: () => h('p', i18n.t('Settings')) });
|
||||||
|
app.use(page).mount(host);
|
||||||
|
return { app, host };
|
||||||
|
};
|
||||||
|
const first = mountView();
|
||||||
|
const second = mountView();
|
||||||
|
first.app.unmount();
|
||||||
|
assert.equal(disposed, 0);
|
||||||
|
i18n.setLocale('ru');
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(second.host.textContent, 'Настройки');
|
||||||
|
const third = mountView();
|
||||||
|
assert.equal(third.host.textContent, 'Настройки');
|
||||||
|
i18n.setLocale('en');
|
||||||
|
second.app.unmount();
|
||||||
|
third.app.unmount();
|
||||||
|
page.dispose = dispose;
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import { applicationPinia } from '../vueRuntime.js';
|
import { applicationPinia } from '../vueRuntime.js';
|
||||||
|
import { applicationI18n } from '../i18n.js';
|
||||||
import WelcomeView from './WelcomeView.vue';
|
import WelcomeView from './WelcomeView.vue';
|
||||||
import ContextView from './ContextView.vue';
|
import ContextView from './ContextView.vue';
|
||||||
import LayerPanelsView from './LayerPanelsView.vue';
|
import LayerPanelsView from './LayerPanelsView.vue';
|
||||||
@@ -57,7 +58,9 @@ export function mountApplicationViews(
|
|||||||
if (mounted.has(host)) continue;
|
if (mounted.has(host)) continue;
|
||||||
if (!Object.hasOwn(APPLICATION_VIEWS, name))
|
if (!Object.hasOwn(APPLICATION_VIEWS, name))
|
||||||
throw new Error(`Unknown application view: ${name}`);
|
throw new Error(`Unknown application view: ${name}`);
|
||||||
const app = createApp(APPLICATION_VIEWS[name]).use(applicationPinia());
|
const app = createApp(APPLICATION_VIEWS[name])
|
||||||
|
.use(applicationPinia())
|
||||||
|
.use(applicationI18n());
|
||||||
app.mount(host);
|
app.mount(host);
|
||||||
mounted.set(host, app);
|
mounted.set(host, app);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
|
import { applicationI18n } from './i18n.js';
|
||||||
|
|
||||||
let pagePinia = null;
|
let pagePinia = null;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ export function applicationPinia() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Vue runtime: one Pinia instance shared by every view it mounts,
|
* Create a Vue runtime: one Pinia instance shared by every view it mounts,
|
||||||
* and the mounts themselves for teardown.
|
* the page's vue-i18n instance, and the mounts themselves for teardown.
|
||||||
* @param {{pinia?: import('pinia').Pinia}} [options]
|
* @param {{pinia?: import('pinia').Pinia}} [options]
|
||||||
* @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}}
|
* @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}}
|
||||||
*/
|
*/
|
||||||
@@ -32,7 +33,7 @@ export function createUiRuntime({ pinia = createPinia() } = {}) {
|
|||||||
*/
|
*/
|
||||||
mount(component, host, props = {}) {
|
mount(component, host, props = {}) {
|
||||||
const app = createApp(component, props);
|
const app = createApp(component, props);
|
||||||
app.use(pinia);
|
app.use(pinia).use(applicationI18n());
|
||||||
app.mount(host);
|
app.mount(host);
|
||||||
apps.add(app);
|
apps.add(app);
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in new issue
Block a user