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.
This commit is contained in:
@@ -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.
|
- [ ] 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.
|
- [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.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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`.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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 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.
|
- 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.
|
- 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
|
## 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
|
### 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 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
|
## Phase 9: Cleanup
|
||||||
|
|
||||||
|
|||||||
@@ -30,35 +30,10 @@ export default defineNuxtConfig({
|
|||||||
meta: [
|
meta: [
|
||||||
{ charset: 'utf-8' },
|
{ charset: 'utf-8' },
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
|
||||||
{
|
{ name: 'theme-color', content: '#6ccf32' }
|
||||||
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` }
|
|
||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' },
|
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
|
||||||
{ rel: 'canonical', href: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/pokemon` }
|
|
||||||
],
|
],
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineNuxtPlugin(() => {
|
|||||||
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
||||||
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
||||||
|
const structuredDataJson = computed(() => JSON.stringify(activeSeo.value.structuredData).replace(/</g, '\\u003C'));
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: activeSeo.value.title,
|
title: activeSeo.value.title,
|
||||||
@@ -35,7 +36,7 @@ export default defineNuxtPlugin(() => {
|
|||||||
key: 'pokopia-structured-data',
|
key: 'pokopia-structured-data',
|
||||||
id: 'pokopia-structured-data',
|
id: 'pokopia-structured-data',
|
||||||
type: 'application/ld+json',
|
type: 'application/ld+json',
|
||||||
children: JSON.stringify(activeSeo.value.structuredData)
|
innerHTML: structuredDataJson.value
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
|
||||||
const sitemapPaths = [
|
const sitemapPaths = [
|
||||||
|
'/',
|
||||||
'/pokemon',
|
'/pokemon',
|
||||||
'/event-pokemon',
|
'/event-pokemon',
|
||||||
'/habitats',
|
'/habitats',
|
||||||
@@ -11,7 +12,11 @@ const sitemapPaths = [
|
|||||||
'/recipes',
|
'/recipes',
|
||||||
'/dish',
|
'/dish',
|
||||||
'/checklist',
|
'/checklist',
|
||||||
'/life'
|
'/life',
|
||||||
|
'/project-updates',
|
||||||
|
'/privacy-policy',
|
||||||
|
'/terms-of-service',
|
||||||
|
'/disclaimers'
|
||||||
];
|
];
|
||||||
|
|
||||||
const robotsDisallowPaths = [
|
const robotsDisallowPaths = [
|
||||||
|
|||||||
@@ -149,13 +149,20 @@ export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?
|
|||||||
typeof routeSeo?.canonicalPath === 'function'
|
typeof routeSeo?.canonicalPath === 'function'
|
||||||
? routeSeo.canonicalPath(route)
|
? routeSeo.canonicalPath(route)
|
||||||
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
|
: 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 {
|
return {
|
||||||
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
|
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
|
||||||
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
|
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
|
||||||
canonicalPath,
|
canonicalPath,
|
||||||
image: routeSeo?.image,
|
image: routeSeo?.image,
|
||||||
noindex: routeSeo?.noindex
|
noindex: routeSeo?.noindex === true || requiresPrivateAccess
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user