diff --git a/frontend/src/RomM.vue b/frontend/src/RomM.vue index b5cd6b37b0..82f25cee22 100644 --- a/frontend/src/RomM.vue +++ b/frontend/src/RomM.vue @@ -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 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(); diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts index 1c9013f968..50f35a5298 100644 --- a/frontend/src/locales/index.ts +++ b/frontend/src/locales/index.ts @@ -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 }; - } = {}; - 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; - }; - messages[locale][namespace] = localeModule.default; - } - }); - return messages; +type LocaleModule = { default: Record }; + +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("./*/**/*.json"); + +const modulesByLocale = new Map< + string, + Map Promise> +>(); +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; @@ -36,4 +38,56 @@ const i18n = createI18n({ }, }); +const pendingLocales = new Map>(); + +// 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 { + 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> = {}; + + 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; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 9fda57c5a3..2ec5bd50ee 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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"; @@ -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. diff --git a/frontend/src/plugins/router.test.ts b/frontend/src/plugins/router.test.ts new file mode 100644 index 0000000000..bd6a694067 --- /dev/null +++ b/frontend/src/plugins/router.test.ts @@ -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); + } + }); +}); diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index 1fe6facee8..8ea4d03413 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -1,10 +1,12 @@ import { storeToRefs } from "pinia"; +import { watch } from "vue"; import { createRouter, createWebHistory, type NavigationGuardWithThis, + type RouteLocationNormalized, } from "vue-router"; -import i18n from "@/locales"; +import i18n, { loadLocale } from "@/locales"; import { startViewTransition } from "@/plugins/transition"; import romApi from "@/services/api/rom"; import storeAuth from "@/stores/auth"; @@ -81,7 +83,7 @@ const routes = [ path: "", name: ROUTES.SETUP, meta: { - title: i18n.global.t("login.setup-wizard"), + title: "login.setup-wizard", }, components: { default: () => import("@/views/Auth/Setup.vue"), @@ -101,7 +103,7 @@ const routes = [ path: "", name: ROUTES.LOGIN, meta: { - title: i18n.global.t("login.login"), + title: "login.login", }, components: { default: () => import("@/views/Auth/Login.vue"), @@ -121,7 +123,7 @@ const routes = [ path: "", name: ROUTES.RESET_PASSWORD, meta: { - title: i18n.global.t("login.reset-password"), + title: "login.reset-password", }, components: { default: () => import("@/views/Auth/ResetPassword.vue"), @@ -141,7 +143,7 @@ const routes = [ path: "", name: ROUTES.REGISTER, meta: { - title: i18n.global.t("login.register"), + title: "login.register", }, components: { default: () => import("@/views/Auth/Register.vue"), @@ -153,9 +155,6 @@ const routes = [ { path: "/", name: ROUTES.MAIN, - meta: { - title: "RomM", - }, // Named views let v1 and v2 coexist at the same URL. The v2 layout owns // its own so child routes with a `v2` component // render inside the v2 shell. @@ -168,7 +167,7 @@ const routes = [ path: "", name: ROUTES.HOME, meta: { - title: i18n.global.t("settings.home"), + title: "settings.home", }, components: { default: () => import("@/views/Home.vue"), @@ -179,7 +178,7 @@ const routes = [ path: "search", name: ROUTES.SEARCH, meta: { - title: i18n.global.t("common.search"), + title: "common.search", }, components: { default: () => import("@/views/Gallery/Search.vue"), @@ -294,7 +293,7 @@ const routes = [ path: "scan", name: ROUTES.SCAN, meta: { - title: i18n.global.t("scan.scan"), + title: "scan.scan", bare: true, }, components: { @@ -306,7 +305,7 @@ const routes = [ path: "upload", name: ROUTES.UPLOAD, meta: { - title: i18n.global.t("common.upload-roms", "Upload ROMs"), + title: "common.upload-roms", }, components: { // v1 has no Upload view (the dialog was its only entry @@ -320,7 +319,7 @@ const routes = [ path: "activity", name: ROUTES.ACTIVITY, meta: { - title: i18n.global.t("activity.active-sessions"), + title: "activity.active-sessions", bare: true, }, components: { @@ -343,7 +342,7 @@ const routes = [ path: "user-interface", name: ROUTES.USER_INTERFACE, meta: { - title: i18n.global.t("common.user-interface"), + title: "common.user-interface", bare: true, }, components: { @@ -355,7 +354,7 @@ const routes = [ path: "library-management", name: ROUTES.LIBRARY_MANAGEMENT, meta: { - title: i18n.global.t("common.library-management"), + title: "common.library-management", bare: true, }, components: { @@ -367,7 +366,7 @@ const routes = [ path: "scan-settings", name: ROUTES.SCAN_SETTINGS, meta: { - title: i18n.global.t("settings.scan-settings"), + title: "settings.scan-settings", bare: true, }, components: { @@ -379,7 +378,7 @@ const routes = [ path: "metadata-sources", name: ROUTES.METADATA_SOURCES, meta: { - title: i18n.global.t("scan.metadata-sources"), + title: "scan.metadata-sources", bare: true, }, components: { @@ -391,7 +390,7 @@ const routes = [ path: "client-api-tokens", name: ROUTES.CLIENT_API_TOKENS, meta: { - title: i18n.global.t("settings.client-api-tokens"), + title: "settings.client-api-tokens", bare: true, }, components: { @@ -403,7 +402,7 @@ const routes = [ path: "administration", name: ROUTES.ADMINISTRATION, meta: { - title: i18n.global.t("common.administration"), + title: "common.administration", bare: true, }, components: { @@ -415,7 +414,7 @@ const routes = [ path: "server-stats", name: ROUTES.SERVER_STATS, meta: { - title: i18n.global.t("common.server-stats"), + title: "common.server-stats", bare: true, }, components: { @@ -427,7 +426,7 @@ const routes = [ path: "logs", name: ROUTES.LOGS, meta: { - title: i18n.global.t("common.logs"), + title: "common.logs", bare: true, // The log panel fills the viewport and scrolls internally // instead of growing the document — see SettingsLayout `fill`. @@ -446,7 +445,7 @@ const routes = [ // settings-adjacent tool rather than a standalone view. path: "controller-debug", name: ROUTES.CONTROLLER_DEBUG, - meta: { title: "Controller debug", bare: true }, + meta: { title: "settings.controller-debug", bare: true }, components: { // v1 has no equivalent; redirect to home if a v1 user // somehow lands here. @@ -461,7 +460,7 @@ const routes = [ // it redirects this URL home; v2 renders PlatformsIndex.vue. path: "platforms", name: ROUTES.PLATFORMS_INDEX, - meta: { title: i18n.global.t("common.platforms") }, + meta: { title: "common.platforms" }, components: { default: () => import("@/views/Home.vue"), v2: v2For(ROUTES.PLATFORMS_INDEX), @@ -470,7 +469,7 @@ const routes = [ { path: "collections", name: ROUTES.COLLECTIONS_INDEX, - meta: { title: i18n.global.t("common.collections") }, + meta: { title: "common.collections" }, components: { default: () => import("@/views/Home.vue"), v2: v2For(ROUTES.COLLECTIONS_INDEX), @@ -608,6 +607,15 @@ function checkRoutePermissions(route: string, user: User | null): boolean { ); } +// `meta.title` holds an i18n key, translated per navigation rather than when +// the route table is built. Messages load asynchronously and aren't there yet +// at module-eval time. +function applyRouteTitle(route: RouteLocationNormalized) { + document.title = route.meta.title + ? i18n.global.t(route.meta.title as string) + : "RomM"; +} + router.beforeEach(async (to, _from, next) => { const heartbeat = storeHeartbeat(); const auth = storeAuth(); @@ -621,9 +629,7 @@ router.beforeEach(async (to, _from, next) => { // allows; the offline notice explains it and the connection layer // re-routes correctly once the backend answers again. if (!heartbeat.connected) { - document.title = to.meta.title - ? i18n.global.t(to.meta.title as string) - : "RomM"; + applyRouteTitle(to); return next(); } @@ -669,11 +675,7 @@ router.beforeEach(async (to, _from, next) => { return next({ name: ROUTES.NOT_FOUND }); } - if (to.meta.title) { - document.title = i18n.global.t(to.meta.title as string); - } else { - document.title = "RomM"; - } + applyRouteTitle(to); next(); } catch (error) { console.error("Navigation guard error:", error); @@ -682,6 +684,15 @@ router.beforeEach(async (to, _from, next) => { } }); +// The stored language is applied when the app mounts, after the first +// navigation has already resolved the title in the default locale. Routes +// whose view owns its title (usePageTitle) carry no `meta.title` and keep it. +watch(i18n.global.locale, async (locale) => { + await loadLocale(locale); + const route = router.currentRoute.value; + if (route.meta.title) applyRouteTitle(route); +}); + router.beforeResolve(async (to, from) => { // Query/hash-only changes (same path — e.g. the v2 GameDetails `?tab=` // param) aren't a real view change. Running a view transition would diff --git a/frontend/src/v2/components/shared/LanguageSelector.vue b/frontend/src/v2/components/shared/LanguageSelector.vue index 56911ecd53..3ee23036c9 100644 --- a/frontend/src/v2/components/shared/LanguageSelector.vue +++ b/frontend/src/v2/components/shared/LanguageSelector.vue @@ -21,7 +21,9 @@ interface Props { } withDefaults(defineProps(), { prefixLabel: false }); -const { t, locale } = useI18n(); +// Global scope is explicit because this write switches the whole app: +// an block in this SFC would otherwise flip it to component-local. +const { t, locale } = useI18n({ useScope: "global" }); const languageStore = storeLanguage(); const { languages, selectedLanguage } = storeToRefs(languageStore); const { locale: localeStorage } = useUISettings();