From 337a6bda1fd59d3749af64439c80554bae323d43 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 09:59:38 +0800 Subject: [PATCH] refactor(seo): migrate metadata handling to Nuxt useHead Remove direct document.head mutations to support SSR compatibility Implement observer pattern to sync SEO state with Nuxt universal plugin Update analytics script to use declarative injection in Nuxt config --- DESIGN.md | 2 +- SSR_MIGRATION_TASKLIST.md | 10 ++- frontend/nuxt.config.ts | 5 +- frontend/plugins/02-seo.client.ts | 14 ---- frontend/plugins/02-seo.ts | 59 +++++++++++++ frontend/src/seo.ts | 135 +++++++++++++----------------- 6 files changed, 129 insertions(+), 96 deletions(-) delete mode 100644 frontend/plugins/02-seo.client.ts create mode 100644 frontend/plugins/02-seo.ts diff --git a/DESIGN.md b/DESIGN.md index 23ebc91..805a68a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1041,7 +1041,7 @@ API 暴露边界: - 默认社交分享图 - 品牌 Logo 素材 - `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`。Nuxt 配置仍兼容读取旧的 `VITE_SITE_URL` 作为 fallback。 -- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon;客户端路由切换后根据当前路由更新页面 metadata。 +- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon;路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata,避免直接操作 `document.head`。 - 主要公开浏览入口可索引: - `/pokemon` - `/event-pokemon` diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index ae24e63..7a117af 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -62,11 +62,17 @@ 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. -- [ ] Move direct `document.head` SEO mutation to Nuxt `useHead` / `useSeoMeta` or another SSR-aware head strategy. -- [ ] Ensure route metadata remains the source for default SEO, required auth, required permission, editor modal behavior, and noindex rules. +- [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. - [ ] Keep UI business text localized through Vue I18n/system wordings; do not add implementation notes or debug text to the UI. +### Phase 4 SEO Notes + +- `frontend/src/seo.ts` now resolves SEO state without mutating `document.head` or `document.title`. +- `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 5: Server-Side Data And SEO - [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages. diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 3cfc1a2..c77ee0e 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -62,8 +62,9 @@ export default defineNuxtConfig({ ], script: [ { - innerHTML: - '(function(){const s=document.createElement("script");s.async=true;s.src="https://umami.tootaio.com/script.js";s.setAttribute("data-website-id","6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb");document.head.appendChild(s);})();' + async: true, + src: 'https://umami.tootaio.com/script.js', + 'data-website-id': '6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb' } ] } diff --git a/frontend/plugins/02-seo.client.ts b/frontend/plugins/02-seo.client.ts deleted file mode 100644 index 7f49963..0000000 --- a/frontend/plugins/02-seo.client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { onLocaleChange } from '../src/i18n'; -import { applyRouteSeo } from '../src/seo'; - -export default defineNuxtPlugin(() => { - const router = useRouter(); - - router.afterEach((to) => { - applyRouteSeo(to); - }); - - onLocaleChange(() => { - applyRouteSeo(router.currentRoute.value); - }); -}); diff --git a/frontend/plugins/02-seo.ts b/frontend/plugins/02-seo.ts new file mode 100644 index 0000000..005ac27 --- /dev/null +++ b/frontend/plugins/02-seo.ts @@ -0,0 +1,59 @@ +import { computed, ref } from 'vue'; +import { onLocaleChange } from '../src/i18n'; +import { applyRouteSeo, onSeoChange, resolveRouteSeo, type ResolvedSeoConfig } from '../src/seo'; + +export default defineNuxtPlugin(() => { + const router = useRouter(); + const dynamicSeo = ref(null); + const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value)); + + useHead(() => ({ + title: activeSeo.value.title, + htmlAttrs: { + lang: activeSeo.value.locale + }, + meta: [ + { key: 'description', name: 'description', content: activeSeo.value.description }, + { key: 'robots', name: 'robots', content: activeSeo.value.robots }, + { key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' }, + { key: 'twitter-title', name: 'twitter:title', content: activeSeo.value.title }, + { key: 'twitter-description', name: 'twitter:description', content: activeSeo.value.description }, + { key: 'twitter-image', name: 'twitter:image', content: activeSeo.value.imageUrl }, + { key: 'og-site-name', property: 'og:site_name', content: 'Pokopia Wiki' }, + { key: 'og-type', property: 'og:type', content: 'website' }, + { key: 'og-title', property: 'og:title', content: activeSeo.value.title }, + { key: 'og-description', property: 'og:description', content: activeSeo.value.description }, + { key: 'og-url', property: 'og:url', content: activeSeo.value.canonicalUrl }, + { key: 'og-image', property: 'og:image', content: activeSeo.value.imageUrl }, + { key: 'og-locale', property: 'og:locale', content: activeSeo.value.locale === 'en' ? 'en_US' : activeSeo.value.locale.replace('-', '_') } + ], + link: [{ key: 'canonical', rel: 'canonical', href: activeSeo.value.canonicalUrl }], + script: [ + { + key: 'pokopia-structured-data', + id: 'pokopia-structured-data', + type: 'application/ld+json', + children: JSON.stringify(activeSeo.value.structuredData) + } + ] + })); + + if (import.meta.server) { + return; + } + + onSeoChange((seo) => { + dynamicSeo.value = seo; + }); + onLocaleChange(() => { + dynamicSeo.value = null; + applyRouteSeo(router.currentRoute.value); + }); + + router.afterEach((to) => { + dynamicSeo.value = null; + applyRouteSeo(to); + }); + + applyRouteSeo(router.currentRoute.value); +}); diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index ac0079c..4591894 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -6,6 +6,8 @@ const defaultCanonicalPath = '/'; const defaultImagePath = '/seo/pokopia-hero.jpg'; const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; let runtimeSiteUrl: string | null = null; +let currentSeo = resolveSeo(); +const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>(); type TranslationValues = Record; @@ -27,6 +29,16 @@ export type SeoConfig = { noindex?: boolean; }; +export type ResolvedSeoConfig = { + title: string; + description: string; + canonicalUrl: string; + imageUrl: string; + robots: string; + locale: string; + structuredData: Record; +}; + const translate = i18n.global.t as (key: string, values?: TranslationValues) => string; export function setConfiguredSiteUrl(value: unknown): void { @@ -77,100 +89,69 @@ function metaDescription(description?: string): string { return description?.trim() || translate('seo.siteDescription'); } -function localeForOpenGraph(locale: string): string { - if (locale === 'en') { - return 'en_US'; - } - - return locale.replace('-', '_'); -} - -function setMeta(attribute: 'name' | 'property', key: string, content: string): void { - let element = document.head.querySelector(`meta[${attribute}="${key}"]`); - if (!element) { - element = document.createElement('meta'); - element.setAttribute(attribute, key); - document.head.appendChild(element); - } - element.setAttribute('content', content); -} - -function setCanonical(href: string): void { - let element = document.head.querySelector('link[rel="canonical"]'); - if (!element) { - element = document.createElement('link'); - element.setAttribute('rel', 'canonical'); - document.head.appendChild(element); - } - element.setAttribute('href', href); -} - -function setStructuredData(title: string, description: string, canonicalUrl: string): void { - let element = document.getElementById('pokopia-structured-data') as HTMLScriptElement | null; - if (!element) { - element = document.createElement('script'); - element.id = 'pokopia-structured-data'; - element.type = 'application/ld+json'; - document.head.appendChild(element); - } - - element.textContent = JSON.stringify({ - '@context': 'https://schema.org', - '@type': 'WebPage', - name: title, - description, - url: canonicalUrl, - isPartOf: { - '@type': 'WebSite', - name: siteName, - url: absoluteUrl('/') - } - }); -} - -export function applySeo(config: SeoConfig = {}): void { - if (typeof document === 'undefined') { - return; - } - +export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig { const title = metaTitle(config.title); const description = metaDescription(config.description); const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath)); const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath); - const noindex = config.noindex === true; - const robots = noindex ? 'noindex, nofollow' : 'index, follow'; + const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow'; const locale = getCurrentLocale(); - document.title = title; - setMeta('name', 'description', description); - setMeta('name', 'robots', robots); - setMeta('name', 'twitter:card', 'summary_large_image'); - setMeta('name', 'twitter:title', title); - setMeta('name', 'twitter:description', description); - setMeta('name', 'twitter:image', imageUrl); - setMeta('property', 'og:site_name', siteName); - setMeta('property', 'og:type', 'website'); - setMeta('property', 'og:title', title); - setMeta('property', 'og:description', description); - setMeta('property', 'og:url', canonicalUrl); - setMeta('property', 'og:image', imageUrl); - setMeta('property', 'og:locale', localeForOpenGraph(locale)); - setCanonical(canonicalUrl); - setStructuredData(title, description, canonicalUrl); + return { + title, + description, + canonicalUrl, + imageUrl, + robots, + locale, + structuredData: { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: title, + description, + url: canonicalUrl, + isPartOf: { + '@type': 'WebSite', + name: siteName, + url: absoluteUrl('/') + } + } + }; } -export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void { +export function routeSeoConfig(route: RouteLocationNormalizedLoaded): SeoConfig { const routeSeo = route.meta.seo as RouteSeoConfig | undefined; const canonicalPath = typeof routeSeo?.canonicalPath === 'function' ? routeSeo.canonicalPath(route) : routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath; - applySeo({ + return { title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title, description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description, canonicalPath, image: routeSeo?.image, noindex: routeSeo?.noindex - }); + }; +} + +export function resolveRouteSeo(route: RouteLocationNormalizedLoaded): ResolvedSeoConfig { + return resolveSeo(routeSeoConfig(route)); +} + +export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void { + seoListeners.add(callback); + callback(currentSeo); + return () => seoListeners.delete(callback); +} + +export function applySeo(config: SeoConfig = {}): void { + currentSeo = resolveSeo(config); + for (const listener of seoListeners) { + listener(currentSeo); + } +} + +export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void { + applySeo(routeSeoConfig(route)); }