From f7986ca5200033e44a1f4205c93b0f13a0388106 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 11:01:19 +0800 Subject: [PATCH] feat(seo): centralize route metadata and expand sitemap coverage Remove static fallback tags from Nuxt config to prevent duplication. Auto-apply noindex to authenticated and permissioned routes. Add home, project updates, and legal pages to sitemap. Properly escape JSON-LD structured data. --- SSR_MIGRATION_TASKLIST.md | 5 +++++ frontend/nuxt.config.ts | 29 ++--------------------------- frontend/plugins/02-seo.ts | 3 ++- frontend/server/utils/seo-files.ts | 7 ++++++- frontend/src/seo.ts | 9 ++++++++- 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index 3011847..7944c7d 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -94,9 +94,12 @@ Keep this file aligned with implementation progress while the SSR migration is i - [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages. - [x] Pokemon and Event Pokemon list routes SSR-load shared options and the first public list page. - [ ] For each SSR-enabled public route, render title, description, canonical URL, robots value, Open Graph, Twitter card, and structured data from public business data and system wording only. + - [x] Route-level SEO output now owns dynamic title, description, canonical, robots, Open Graph, Twitter card, and valid inline JSON-LD without duplicate static Nuxt head metadata. - [ ] For detail pages, use entity names, public images, localized public fields, and canonical detail URLs after public API data loads server-side. - [ ] Preserve `noindex` on auth, admin, new, edit, and in-development routes. + - [x] SEO resolver defaults authenticated, verified, and permissioned routes to `noindex`, while existing route metadata continues to mark auth, edit/create modal, and in-development pages as `noindex`. - [ ] Keep `robots.txt` and `sitemap.xml` generated from the same stable public route set documented in `DESIGN.md`. + - [x] Sitemap includes Home, public index sections, Project Updates, and legal pages; robots keeps auth, admin, edit/create, and in-development routes disallowed. - [ ] Avoid serializing private auth state, raw permissions, internal audit payloads, or unneeded API payload fields into Nuxt payloads. - [ ] Confirm localized reads follow the fallback order in `DESIGN.md`: requested locale, default-language translation, base field. @@ -105,6 +108,7 @@ Keep this file aligned with implementation progress while the SSR migration is i - Pokemon and Event Pokemon list routes now SSR-load the shared options payload and first public list page through `useAsyncData`; filter changes, infinite loading, and route-backed create modals continue to use the existing client behavior. - Pokemon list SSR API failures are contained to null initial data so rendered HTML falls back to the existing skeleton/empty behavior without exposing backend stack traces, raw errors, or internal fields. - Public Pokemon list SSR data does not request `api.me()` or forward cookies; create actions remain client-hydrated from the current user after mount. +- The static fallback SEO tags in Nuxt config were reduced to non-route-specific defaults so route-level SSR SEO is the single source for canonical, robots, social metadata, and JSON-LD. ## Phase 6: Browser-Only UI Isolation @@ -148,6 +152,7 @@ Keep this file aligned with implementation progress while the SSR migration is i ### Phase 8 Validation Notes - 2026-05-06: After SSR auth cookie forwarding and Pokemon/Event Pokemon first-page SSR data, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. The current `lint` script runs `nuxt typecheck`. +- 2026-05-06: After SEO foundation updates, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. Local built-server smoke on port `20116` verified `/pokemon` route-level canonical/meta/JSON-LD, `sitemap.xml`, and `robots.txt`. ## Phase 9: Cleanup diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 9194229..0e65c6c 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -30,35 +30,10 @@ export default defineNuxtConfig({ meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1.0' }, - { - name: 'description', - content: - 'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.' - }, - { name: 'robots', content: 'index, follow' }, - { name: 'theme-color', content: '#6ccf32' }, - { property: 'og:site_name', content: 'Pokopia Wiki' }, - { property: 'og:type', content: 'website' }, - { property: 'og:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' }, - { - property: 'og:description', - content: - 'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.' - }, - { property: 'og:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` }, - { property: 'og:locale', content: 'en_US' }, - { name: 'twitter:card', content: 'summary_large_image' }, - { name: 'twitter:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' }, - { - name: 'twitter:description', - content: - 'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.' - }, - { name: 'twitter:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` } + { name: 'theme-color', content: '#6ccf32' } ], link: [ - { rel: 'icon', href: '/favicon.ico', sizes: '32x32' }, - { rel: 'canonical', href: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/pokemon` } + { rel: 'icon', href: '/favicon.ico', sizes: '32x32' } ], script: [ { diff --git a/frontend/plugins/02-seo.ts b/frontend/plugins/02-seo.ts index 749f5ac..e886f93 100644 --- a/frontend/plugins/02-seo.ts +++ b/frontend/plugins/02-seo.ts @@ -8,6 +8,7 @@ export default defineNuxtPlugin(() => { const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record) => string } }).global.t; const dynamicSeo = ref(null); const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t)); + const structuredDataJson = computed(() => JSON.stringify(activeSeo.value.structuredData).replace(/ ({ title: activeSeo.value.title, @@ -35,7 +36,7 @@ export default defineNuxtPlugin(() => { key: 'pokopia-structured-data', id: 'pokopia-structured-data', type: 'application/ld+json', - children: JSON.stringify(activeSeo.value.structuredData) + innerHTML: structuredDataJson.value } ] })); diff --git a/frontend/server/utils/seo-files.ts b/frontend/server/utils/seo-files.ts index 56770af..6b954df 100644 --- a/frontend/server/utils/seo-files.ts +++ b/frontend/server/utils/seo-files.ts @@ -1,6 +1,7 @@ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; const sitemapPaths = [ + '/', '/pokemon', '/event-pokemon', '/habitats', @@ -11,7 +12,11 @@ const sitemapPaths = [ '/recipes', '/dish', '/checklist', - '/life' + '/life', + '/project-updates', + '/privacy-policy', + '/terms-of-service', + '/disclaimers' ]; const robotsDisallowPaths = [ diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index 016e12d..9f9eb70 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -149,13 +149,20 @@ export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator? typeof routeSeo?.canonicalPath === 'function' ? routeSeo.canonicalPath(route) : routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath; + const requiresPrivateAccess = route.matched.some( + (record) => + record.meta.requiresAuth === true || + record.meta.requiresVerified === true || + typeof record.meta.requiredPermission === 'string' || + (Array.isArray(record.meta.requiredAnyPermission) && record.meta.requiredAnyPermission.length > 0) + ); return { 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 + noindex: routeSeo?.noindex === true || requiresPrivateAccess }; }