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)); }