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
This commit is contained in:
2026-05-06 09:59:38 +08:00
parent fd1f3ef636
commit 337a6bda1f
6 changed files with 129 additions and 96 deletions

View File

@@ -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'
}
]
}

View File

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

View File

@@ -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<ResolvedSeoConfig | null>(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);
});

View File

@@ -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<string, string | number>;
@@ -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<string, unknown>;
};
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<HTMLMetaElement>(`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<HTMLLinkElement>('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));
}