mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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
@@ -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 { applicationPinia } from '../vueRuntime.js';
|
||||
import { applicationI18n } from '../i18n.js';
|
||||
import WelcomeView from './WelcomeView.vue';
|
||||
import ContextView from './ContextView.vue';
|
||||
import LayerPanelsView from './LayerPanelsView.vue';
|
||||
@@ -57,7 +58,9 @@ export function mountApplicationViews(
|
||||
if (mounted.has(host)) continue;
|
||||
if (!Object.hasOwn(APPLICATION_VIEWS, 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);
|
||||
mounted.set(host, app);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { applicationI18n } from './i18n.js';
|
||||
|
||||
let pagePinia = null;
|
||||
|
||||
@@ -15,7 +16,7 @@ export function applicationPinia() {
|
||||
|
||||
/**
|
||||
* 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]
|
||||
* @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}}
|
||||
*/
|
||||
@@ -32,7 +33,7 @@ export function createUiRuntime({ pinia = createPinia() } = {}) {
|
||||
*/
|
||||
mount(component, host, props = {}) {
|
||||
const app = createApp(component, props);
|
||||
app.use(pinia);
|
||||
app.use(pinia).use(applicationI18n());
|
||||
app.mount(host);
|
||||
apps.add(app);
|
||||
return () => {
|
||||
|
||||
Reference in new issue
Block a user