Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
61 changes: 39 additions & 22 deletions frontend/src/locales/index.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,16 @@
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 NamespacedMessages = Record<string, Record<string, string>>;

const localeModules = import.meta.glob<{ default: Record<string, string> }>(
"./*/**/*.json",
);

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

// Each namespace is its own chunk, so messages only land a tick or two after
// this module evaluates. Bootstrap awaits this 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.
export const localesReady = (async () => {
const messages: Record<string, NamespacedMessages> = {};

await Promise.all(
Object.entries(localeModules).map(async ([path, load]) => {
Comment thread
sdornan marked this conversation as resolved.
Outdated
const matched = path.match(
/\.\/([A-Za-z0-9-_]+)\/([A-Za-z0-9-_]+)\.json$/i,
);
if (!matched) return;

const [, locale, namespace] = matched;
try {
const localeModule = await load();
messages[locale] ??= {};
messages[locale][namespace] = localeModule.default;
} catch (error) {
// A namespace that fails to load (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 messages for ${path}: `, error);
Comment thread
sdornan marked this conversation as resolved.
Outdated
}
}),
);

Object.entries(messages).forEach(([locale, localeMessages]) => {
i18n.global.setLocaleMessage(locale, localeMessages);
});
})();

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
31 changes: 31 additions & 0 deletions frontend/src/plugins/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeAll, describe, expect, it } from "vitest";
import i18n, { localesReady } from "@/locales";
import router from "@/plugins/router";

// Titles that are plain strings rather than i18n keys.
const LITERAL_TITLES = new Set(["RomM", "Controller debug"]);

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")
.filter((title) => !LITERAL_TITLES.has(title));

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);
}
});
});
68 changes: 41 additions & 27 deletions frontend/src/plugins/router.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { storeToRefs } from "pinia";
import { watch } from "vue";
import {
createRouter,
createWebHistory,
type NavigationGuardWithThis,
type RouteLocationNormalized,
} from "vue-router";
import i18n from "@/locales";
import { startViewTransition } from "@/plugins/transition";
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -168,7 +170,7 @@ const routes = [
path: "",
name: ROUTES.HOME,
meta: {
title: i18n.global.t("settings.home"),
title: "settings.home",
},
components: {
default: () => import("@/views/Home.vue"),
Expand All @@ -179,7 +181,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"),
Expand Down Expand Up @@ -294,7 +296,7 @@ const routes = [
path: "scan",
name: ROUTES.SCAN,
meta: {
title: i18n.global.t("scan.scan"),
title: "scan.scan",
bare: true,
},
components: {
Expand All @@ -306,7 +308,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
Expand All @@ -320,7 +322,7 @@ const routes = [
path: "activity",
name: ROUTES.ACTIVITY,
meta: {
title: i18n.global.t("activity.active-sessions"),
title: "activity.active-sessions",
bare: true,
},
components: {
Expand All @@ -343,7 +345,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: {
Expand All @@ -355,7 +357,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: {
Expand All @@ -367,7 +369,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: {
Expand All @@ -379,7 +381,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: {
Expand All @@ -391,7 +393,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: {
Expand All @@ -403,7 +405,7 @@ const routes = [
path: "administration",
name: ROUTES.ADMINISTRATION,
meta: {
title: i18n.global.t("common.administration"),
title: "common.administration",
bare: true,
},
components: {
Expand All @@ -415,7 +417,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: {
Expand All @@ -427,7 +429,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`.
Expand Down Expand Up @@ -461,7 +463,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),
Expand All @@ -470,7 +472,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),
Expand Down Expand Up @@ -608,6 +610,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";
}
Comment thread
sdornan marked this conversation as resolved.

router.beforeEach(async (to, _from, next) => {
const heartbeat = storeHeartbeat();
const auth = storeAuth();
Expand All @@ -621,9 +632,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();
}

Expand Down Expand Up @@ -669,11 +678,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);
Expand All @@ -682,6 +687,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, () => {
if (router.currentRoute.value.meta.title) {
applyRouteTitle(router.currentRoute.value);
}
});

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
Expand Down