Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/RomM.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ const BackendStatusBanner = defineAsyncComponent(
() => import("@/v2/components/AppShell/BackendStatusBanner.vue"),
);

const { locale } = useI18n();
// Global scope is explicit because this write switches the whole app:
// an <i18n> block in this SFC would otherwise flip it to component-local.
const { locale } = useI18n({ useScope: "global" });
const languageStore = storeLanguage();
const consoleStore = storeConsole();
const vuetifyTheme = useTheme();
Expand Down
100 changes: 77 additions & 23 deletions frontend/src/locales/index.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,34 @@
import { watch } from "vue";
import { createI18n } from "vue-i18n";

function loadLocales() {
const locales = import.meta.glob("./*/**/*.json");
const messages: {
[key: string]: { [namespace: string]: Record<string, string> };
} = {};
Object.keys(locales).forEach(async (key) => {
const matched = key.match(/\.\/([A-Za-z0-9-_]+)\/([A-Za-z0-9-_]+)\.json$/i);
if (matched && matched.length > 2) {
const locale = matched[1];
const namespace = matched[2];
if (!messages[locale]) {
messages[locale] = {};
}
const localeModule = (await locales[key]()) as {
default: Record<string, string>;
};
messages[locale][namespace] = localeModule.default;
}
});
return messages;
type LocaleModule = { default: Record<string, string> };

const FALLBACK_LOCALE = "en_US";

// Written by the language selector via `useLocalStorage`, so it holds the
// raw locale name.
const STORED_LOCALE_KEY = "settings.locale";

const localeModules = import.meta.glob<LocaleModule>("./*/**/*.json");

const modulesByLocale = new Map<
string,
Map<string, () => Promise<LocaleModule>>
>();
for (const [path, load] of Object.entries(localeModules)) {
const matched = path.match(/\.\/([A-Za-z0-9-_]+)\/([A-Za-z0-9-_]+)\.json$/i);
if (!matched) continue;

const [, locale, namespace] = matched;
if (!modulesByLocale.has(locale)) modulesByLocale.set(locale, new Map());
modulesByLocale.get(locale)?.set(namespace, load);
}

const i18n = createI18n({
legacy: false,
locale: "en_US",
fallbackLocale: "en_US",
messages: loadLocales(),
locale: FALLBACK_LOCALE,
fallbackLocale: FALLBACK_LOCALE,
messages: {},
pluralRules: {
cs_CZ(choice: number) {
if (choice === 0) return 0;
Expand All @@ -36,4 +38,56 @@ const i18n = createI18n({
},
});

const pendingLocales = new Map<string, Promise<void>>();

// Fetches every namespace of a language and registers them as one message
// bundle. Memoized, so repeated calls (a language toggled back and forth)
// reuse the first load.
export function loadLocale(locale: string): Promise<void> {
const pending = pendingLocales.get(locale);
if (pending) return pending;

const namespaces = modulesByLocale.get(locale);
if (!namespaces) return Promise.resolve();

const loading = (async () => {
const messages: Record<string, Record<string, string>> = {};

await Promise.all(
[...namespaces].map(async ([namespace, load]) => {
try {
messages[namespace] = (await load()).default;
} catch (error) {
// A namespace that fails to load (a stale chunk after a redeploy)
// must not hold up the app: the rest of the UI still translates,
// and the missing keys fall back to their key names.
console.error(
`Error loading ${locale}/${namespace} messages:`,
error,
);
}
}),
);

i18n.global.setLocaleMessage(locale, messages);
})();

pendingLocales.set(locale, loading);
return loading;
}

// Namespaces are separate chunks, so messages only land a tick or two after
// this module evaluates. Bootstrap awaits the two bundles the first paint can
// need before installing the router: the navigation guard translates the
// route title on the very first navigation, and without messages it would
// write the raw key to the tab. Other languages load when switched to.
export const localesReady = Promise.all([
loadLocale(FALLBACK_LOCALE),
loadLocale(localStorage.getItem(STORED_LOCALE_KEY) ?? FALLBACK_LOCALE),
]);

watch(i18n.global.locale, (locale) => {
void loadLocale(locale);
});

export default i18n;
6 changes: 5 additions & 1 deletion frontend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createApp } from "vue";
import App from "@/RomM.vue";
import "@/console/index.css";
import { localesReady } from "@/locales";
import { registerPlugins } from "@/plugins";
import router from "@/plugins/router";
import storeAuth from "@/stores/auth";
Expand Down Expand Up @@ -61,7 +62,10 @@ async function initializeApp() {
// Registrar vuetify + pinia + i18n + emitter
registerPlugins(app);

await initializeData();
// Locale messages gate the router alongside the initial data: the guard
// resolves the route title as soon as the router is installed, and with
// messages still in flight it would set the raw key as the tab title.
await Promise.all([initializeData(), localesReady]);

// Route-level lazy imports fail outside vite's preload helper, so stale
// chunks during navigation surface here instead of as vite:preloadError.
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/plugins/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeAll, describe, expect, it } from "vitest";
import i18n, { localesReady } from "@/locales";
import router from "@/plugins/router";

describe("route titles", () => {
beforeAll(async () => {
setActivePinia(createPinia());
await localesReady;
});

it("stores i18n keys that resolve against the locale messages", () => {
const titles = router
.getRoutes()
.map((route) => route.meta.title)
.filter((title): title is string => typeof title === "string");

expect(titles.length).toBeGreaterThan(0);

for (const title of titles) {
// Route definitions must hold the key, not an eagerly translated
// string: messages load after the route table is built.
expect(title).toMatch(/^[a-z0-9-]+\.[a-z0-9-]+$/);
expect(i18n.global.t(title)).not.toBe(title);
}
});
});
Loading
Loading