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
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
|
||||
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
|
||||
- 前端当前语言保存在 `localStorage` 的 `pokopia_locale`。
|
||||
- Nuxt SSR 运行时每个 Nuxt app/request 创建独立 Vue I18n 实例,避免跨请求共享 locale 或系统文案状态;服务端默认使用 `en`,客户端 hydration 后按 `pokopia_locale` 恢复用户语言。
|
||||
- 后端默认语言为 `en`。
|
||||
- 语言配置存储在 `languages`:
|
||||
- `code`
|
||||
|
||||
@@ -61,7 +61,7 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
|
||||
- [ ] Change Nuxt config from `ssr: false` to `ssr: true` only after browser-only usage and auth strategy are ready.
|
||||
- [ ] Split plugins by runtime where needed: `.client.ts` for DOM/event/storage logic, `.server.ts` for SSR-only initialization, and universal plugins only for code safe in both contexts.
|
||||
- [ ] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users.
|
||||
- [x] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users.
|
||||
- [x] Move direct `document.head` SEO mutation to Nuxt `useHead` / `useSeoMeta` or another SSR-aware head strategy.
|
||||
- [x] Ensure route metadata remains the source for default SEO, required auth, required permission, editor modal behavior, and noindex rules.
|
||||
- [ ] Confirm route-backed modal pages still preserve underlying page context and avoid unwanted scroll jumps.
|
||||
@@ -73,6 +73,12 @@ Keep this file aligned with implementation progress while the SSR migration is i
|
||||
- `frontend/plugins/02-seo.ts` is a universal Nuxt plugin that binds route metadata and client-side detail overrides to `useHead`.
|
||||
- The Nuxt config analytics script is declarative and no longer injects a script with `document.head.appendChild`.
|
||||
|
||||
### Phase 4 I18n Notes
|
||||
|
||||
- `frontend/src/i18n.ts` now exports a Vue I18n factory instead of a module-level singleton.
|
||||
- `frontend/plugins/01-i18n.ts` creates and installs one I18n instance per Nuxt app/request; only the browser instance is registered for legacy helpers that need localStorage and locale-change events.
|
||||
- SEO route metadata translation uses the current Nuxt app's I18n translator instead of importing a shared global I18n instance.
|
||||
|
||||
## Phase 5: Server-Side Data And SEO
|
||||
|
||||
- [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { i18n } from '../src/i18n';
|
||||
import { createPokopiaI18n, setActiveI18n } from '../src/i18n';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const i18n = createPokopiaI18n();
|
||||
if (import.meta.client) {
|
||||
setActiveI18n(i18n);
|
||||
}
|
||||
|
||||
nuxtApp.vueApp.use(i18n);
|
||||
return {
|
||||
provide: {
|
||||
pokopiaI18n: i18n
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLocaleChange } from '../src/i18n';
|
||||
import { applyRouteSeo, onSeoChange, resolveRouteSeo, type ResolvedSeoConfig } from '../src/seo';
|
||||
import { applyRouteSeo, onSeoChange, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const router = useRouter();
|
||||
const nuxtApp = useNuxtApp();
|
||||
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
||||
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value));
|
||||
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
||||
|
||||
useHead(() => ({
|
||||
title: activeSeo.value.title,
|
||||
@@ -42,6 +44,7 @@ export default defineNuxtPlugin(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
setSeoTranslator(t);
|
||||
onSeoChange((seo) => {
|
||||
dynamicSeo.value = seo;
|
||||
});
|
||||
|
||||
@@ -18,13 +18,22 @@ type SystemWordingsResponse = {
|
||||
|
||||
export type MessageKey = keyof typeof messages.en;
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: readStoredLocale(),
|
||||
fallbackLocale: defaultLocale,
|
||||
messages
|
||||
});
|
||||
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 });
|
||||
@@ -54,7 +63,7 @@ function activeApiBaseUrl(): string {
|
||||
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
|
||||
}
|
||||
|
||||
function readStoredLocale(): string {
|
||||
export function readStoredLocale(): string {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return defaultLocale;
|
||||
}
|
||||
@@ -64,11 +73,11 @@ function readStoredLocale(): string {
|
||||
}
|
||||
|
||||
function globalLocaleRef() {
|
||||
return i18n.global.locale as unknown as { value: string };
|
||||
return activeI18n?.global.locale as unknown as { value: string } | undefined;
|
||||
}
|
||||
|
||||
export function getCurrentLocale(): string {
|
||||
return globalLocaleRef().value || defaultLocale;
|
||||
return globalLocaleRef()?.value || defaultLocale;
|
||||
}
|
||||
|
||||
function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree {
|
||||
@@ -97,6 +106,11 @@ function builtInMessagesFor(locale: string): SystemWordingTree {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -116,13 +130,13 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SystemWordingsResponse;
|
||||
i18n.global.setLocaleMessage(
|
||||
targetI18n.global.setLocaleMessage(
|
||||
targetLocale,
|
||||
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
|
||||
);
|
||||
loadedWordingLocales.add(targetLocale);
|
||||
} catch {
|
||||
i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
|
||||
targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
|
||||
} finally {
|
||||
pendingWordingLoads.delete(targetLocale);
|
||||
}
|
||||
@@ -134,7 +148,10 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
|
||||
|
||||
export function setCurrentLocale(locale: string): void {
|
||||
const nextLocale = locale || defaultLocale;
|
||||
globalLocaleRef().value = nextLocale;
|
||||
const localeRef = globalLocaleRef();
|
||||
if (localeRef) {
|
||||
localeRef.value = nextLocale;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = nextLocale;
|
||||
@@ -157,5 +174,3 @@ export function onLocaleChange(callback: () => void): () => void {
|
||||
window.addEventListener(localeChangeEvent, callback);
|
||||
return () => window.removeEventListener(localeChangeEvent, callback);
|
||||
}
|
||||
|
||||
setCurrentLocale(getCurrentLocale());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import { getCurrentLocale, i18n } from './i18n';
|
||||
import { getCurrentLocale } from './i18n';
|
||||
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
|
||||
|
||||
const siteName = 'Pokopia Wiki';
|
||||
const defaultCanonicalPath = '/';
|
||||
@@ -10,6 +11,7 @@ let currentSeo = resolveSeo();
|
||||
const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>();
|
||||
|
||||
type TranslationValues = Record<string, string | number>;
|
||||
type Translator = (key: string, values?: TranslationValues) => string;
|
||||
|
||||
export type RouteSeoConfig = {
|
||||
title?: string;
|
||||
@@ -39,7 +41,12 @@ export type ResolvedSeoConfig = {
|
||||
structuredData: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
|
||||
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
|
||||
let activeTranslator: Translator | null = null;
|
||||
|
||||
export function setSeoTranslator(translator: Translator): void {
|
||||
activeTranslator = translator;
|
||||
}
|
||||
|
||||
export function setConfiguredSiteUrl(value: unknown): void {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
@@ -86,7 +93,24 @@ function metaTitle(title?: string): string {
|
||||
}
|
||||
|
||||
function metaDescription(description?: string): string {
|
||||
return description?.trim() || translate('seo.siteDescription');
|
||||
return description?.trim() || translateSeo('seo.siteDescription');
|
||||
}
|
||||
|
||||
function builtInTranslate(key: string, values: TranslationValues = {}): string {
|
||||
let message: SystemWordingTree[string] | undefined = messages[defaultLocale];
|
||||
for (const part of key.split('.')) {
|
||||
message = typeof message === 'object' && message !== null ? message[part] : undefined;
|
||||
}
|
||||
|
||||
if (typeof message !== 'string') {
|
||||
return key;
|
||||
}
|
||||
|
||||
return Object.entries(values).reduce((nextMessage, [name, value]) => nextMessage.replaceAll(`{${name}}`, String(value)), message);
|
||||
}
|
||||
|
||||
function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string {
|
||||
return translator ? translator(key, values) : builtInTranslate(key, values);
|
||||
}
|
||||
|
||||
export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
|
||||
@@ -119,7 +143,7 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function routeSeoConfig(route: RouteLocationNormalizedLoaded): SeoConfig {
|
||||
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
|
||||
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
|
||||
const canonicalPath =
|
||||
typeof routeSeo?.canonicalPath === 'function'
|
||||
@@ -127,16 +151,16 @@ export function routeSeoConfig(route: RouteLocationNormalizedLoaded): SeoConfig
|
||||
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
|
||||
|
||||
return {
|
||||
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title,
|
||||
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description,
|
||||
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
|
||||
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
|
||||
canonicalPath,
|
||||
image: routeSeo?.image,
|
||||
noindex: routeSeo?.noindex
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRouteSeo(route: RouteLocationNormalizedLoaded): ResolvedSeoConfig {
|
||||
return resolveSeo(routeSeoConfig(route));
|
||||
export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig {
|
||||
return resolveSeo(routeSeoConfig(route, translator));
|
||||
}
|
||||
|
||||
export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {
|
||||
|
||||
Reference in New Issue
Block a user