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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user