Files
pokopiawiki.tootaio.com/frontend/src/i18n.ts
xiaomai cf1eb6965e refactor(i18n): isolate Vue I18n instances per request for SSR
Replace global I18n singleton with a factory function
Inject request-specific I18n instances into Nuxt app and SEO metadata
Prevent cross-request locale state pollution during server-side rendering
2026-05-06 10:10:07 +08:00

177 lines
5.1 KiB
TypeScript

import { createI18n } from 'vue-i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
export { defaultLocale } from '../../system-wordings';
let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001';
const localeStorageKey = 'pokopia_locale';
const localeChangeEvent = 'pokopia-locale-change';
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
const loadedWordingLocales = new Set<string>();
const pendingWordingLoads = new Map<string, Promise<void>>();
type SystemWordingsResponse = {
locale: string;
messages: SystemWordingTree;
};
export type MessageKey = keyof typeof messages.en;
export function createPokopiaI18n(initialLocale = readStoredLocale()) {
return createI18n({
legacy: false,
globalInjection: true,
locale: initialLocale || defaultLocale,
fallbackLocale: defaultLocale,
messages
});
}
type PokopiaI18n = ReturnType<typeof createPokopiaI18n>;
let activeI18n: PokopiaI18n | null = null;
export function setActiveI18n(instance: PokopiaI18n): void {
activeI18n = instance;
}
export function setSystemWordingsApiBaseUrl(value: unknown): void {
setSystemWordingsApiBaseUrls({ browser: value, server: value });
}
export function setSystemWordingsApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
const serverBaseUrl = normalizeApiBaseUrl(value.server);
if (browserBaseUrl) {
browserApiBaseUrl = browserBaseUrl;
}
if (serverBaseUrl) {
serverApiBaseUrl = serverBaseUrl;
}
}
function normalizeApiBaseUrl(value: unknown): string | null {
if (typeof value === 'string' && value.trim() !== '') {
return value.trim().replace(/\/+$/, '');
}
return null;
}
function activeApiBaseUrl(): string {
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
}
export function readStoredLocale(): string {
if (typeof localStorage === 'undefined') {
return defaultLocale;
}
const storedLocale = localStorage.getItem(localeStorageKey);
return storedLocale && storedLocale.trim() !== '' ? storedLocale : defaultLocale;
}
function globalLocaleRef() {
return activeI18n?.global.locale as unknown as { value: string } | undefined;
}
export function getCurrentLocale(): string {
return globalLocaleRef()?.value || defaultLocale;
}
function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree {
return typeof value === 'object' && value !== null;
}
function mergeMessageTrees(...trees: Array<SystemWordingTree | undefined>): SystemWordingTree {
const merged: SystemWordingTree = {};
for (const tree of trees) {
if (!tree) {
continue;
}
for (const [key, value] of Object.entries(tree)) {
const current = merged[key];
merged[key] = isMessageTree(value) && isMessageTree(current) ? mergeMessageTrees(current, value) : value;
}
}
return merged;
}
function builtInMessagesFor(locale: string): SystemWordingTree {
return mergeMessageTrees(messages[defaultLocale], messages[locale]);
}
export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise<void> {
if (!activeI18n) {
return;
}
const targetI18n = activeI18n;
const targetLocale = locale || defaultLocale;
if (!force && loadedWordingLocales.has(targetLocale)) {
return;
}
const pendingLoad = pendingWordingLoads.get(targetLocale);
if (pendingLoad) {
await pendingLoad;
return;
}
const loadPromise = (async () => {
try {
const response = await fetch(`${activeApiBaseUrl()}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
if (!response.ok) {
throw new Error(`System wordings failed (${response.status})`);
}
const data = (await response.json()) as SystemWordingsResponse;
targetI18n.global.setLocaleMessage(
targetLocale,
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
);
loadedWordingLocales.add(targetLocale);
} catch {
targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
} finally {
pendingWordingLoads.delete(targetLocale);
}
})();
pendingWordingLoads.set(targetLocale, loadPromise);
await loadPromise;
}
export function setCurrentLocale(locale: string): void {
const nextLocale = locale || defaultLocale;
const localeRef = globalLocaleRef();
if (localeRef) {
localeRef.value = nextLocale;
}
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale;
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(localeStorageKey, nextLocale);
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event(localeChangeEvent));
}
}
export function onLocaleChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
window.addEventListener(localeChangeEvent, callback);
return () => window.removeEventListener(localeChangeEvent, callback);
}