Files
pokopiawiki.tootaio.com/frontend/src/seo.ts
xiaomai 4a7309027a feat(threads): add SEO metadata, sitemap, and structured data
Include /threads in sitemap and set canonical paths
Generate DiscussionForumPosting structured data for thread details
Add dynamic SEO updates for thread navigation and server-side rendering
2026-05-07 13:46:08 +08:00

274 lines
9.0 KiB
TypeScript

import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { getCurrentLocale } from './i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
const siteName = 'Pokopia Wiki';
const defaultCanonicalPath = '/';
const defaultImagePath = '/seo/pokopia-hero.jpg';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
let runtimeSiteUrl: string | null = null;
type TranslationValues = Record<string, string | number>;
export type Translator = (key: string, values?: TranslationValues) => string;
export type RouteSeoConfig = {
title?: string;
titleKey?: string;
description?: string;
descriptionKey?: string;
canonicalPath?: string | ((route: RouteLocationNormalizedLoaded) => string);
image?: string;
noindex?: boolean;
};
export type SeoConfig = {
title?: string;
description?: string;
canonicalPath?: string;
image?: string | null;
noindex?: boolean;
openGraphType?: 'website' | 'article';
structuredData?: Record<string, unknown>;
};
export type ResolvedSeoConfig = {
title: string;
description: string;
canonicalUrl: string;
imageUrl: string;
robots: string;
locale: string;
openGraphType: 'website' | 'article';
structuredData: Record<string, unknown>;
};
export type ThreadSeoSummary = {
id: number;
title: string;
languageCode: string;
tags: Array<{ name: string }>;
messageCount: number;
createdAt: string;
lastActiveAt: string;
author: { displayName: string } | null;
};
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
let activeTranslator: Translator | null = null;
let currentSeo: ResolvedSeoConfig | null = null;
const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>();
export function setSeoTranslator(translator: Translator): void {
activeTranslator = translator;
}
export function setConfiguredSiteUrl(value: unknown): void {
if (typeof value === 'string' && value.trim() !== '') {
runtimeSiteUrl = normalizeSiteUrl(value);
}
}
function configuredSiteUrl(): string {
if (runtimeSiteUrl) {
return runtimeSiteUrl;
}
if (typeof window !== 'undefined' && window.location.origin) {
return normalizeSiteUrl(window.location.origin);
}
return fallbackSiteUrl;
}
function normalizeSiteUrl(value: string): string {
return value.trim().replace(/\/+$/, '') || fallbackSiteUrl;
}
function normalizePath(value: string | undefined): string {
const path = value?.trim() || defaultCanonicalPath;
return path.startsWith('/') ? path : `/${path}`;
}
export function absoluteUrl(value: string): string {
try {
return new URL(value, `${configuredSiteUrl()}/`).toString();
} catch {
return `${configuredSiteUrl()}${normalizePath(value)}`;
}
}
function metaTitle(title?: string): string {
const cleanTitle = title?.trim();
if (!cleanTitle || cleanTitle === siteName) {
return siteName;
}
return `${cleanTitle} | ${siteName}`;
}
function metaDescription(description?: string): string {
return description?.trim() || translateSeo('seo.siteDescription');
}
function builtInTranslate(key: string, values: TranslationValues = {}): string {
let message: SystemWordingTree[string] | undefined = messages[defaultLocale];
for (const part of key.split('.')) {
message = typeof message === 'object' && message !== null ? message[part] : undefined;
}
if (typeof message !== 'string') {
return key;
}
return Object.entries(values).reduce((nextMessage, [name, value]) => nextMessage.replaceAll(`{${name}}`, String(value)), message);
}
function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string {
return translator ? translator(key, values) : builtInTranslate(key, values);
}
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 robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale();
const openGraphType = config.openGraphType ?? 'website';
return {
title,
description,
canonicalUrl,
imageUrl,
robots,
locale,
openGraphType,
structuredData: config.structuredData ?? {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description,
url: canonicalUrl,
isPartOf: {
'@type': 'WebSite',
name: siteName,
url: absoluteUrl('/')
}
}
};
}
export function resolvedSeoHead(seo: ResolvedSeoConfig) {
return {
title: seo.title,
htmlAttrs: {
lang: seo.locale
},
meta: [
{ key: 'description', name: 'description', content: seo.description },
{ key: 'robots', name: 'robots', content: seo.robots },
{ key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' },
{ key: 'twitter-title', name: 'twitter:title', content: seo.title },
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
{ key: 'og-site-name', property: 'og:site_name', content: siteName },
{ key: 'og-type', property: 'og:type', content: seo.openGraphType },
{ key: 'og-title', property: 'og:title', content: seo.title },
{ key: 'og-description', property: 'og:description', content: seo.description },
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
{ key: 'og-image', property: 'og:image', content: seo.imageUrl },
{ key: 'og-locale', property: 'og:locale', content: seo.locale === 'en' ? 'en_US' : seo.locale.replace('-', '_') }
],
link: [{ key: 'canonical', rel: 'canonical', href: seo.canonicalUrl }],
script: [
{
key: 'pokopia-structured-data',
id: 'pokopia-structured-data',
type: 'application/ld+json',
innerHTML: JSON.stringify(seo.structuredData).replace(/</g, '\\u003C')
}
]
};
}
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath =
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 === true || requiresPrivateAccess
};
}
export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig {
return resolveSeo(routeSeoConfig(route, translator));
}
export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {
seoListeners.add(callback);
callback(currentSeo ?? resolveSeo());
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));
}
export function threadSeoConfig(thread: ThreadSeoSummary, translator: Translator): SeoConfig {
const title = thread.title.trim() || translator('pages.threads.title');
const canonicalPath = `/threads/${thread.id}`;
const keywords = thread.tags.map((tag) => tag.name.trim()).filter(Boolean).join(', ');
const description = translator('seo.threadDetailDescription', { title, count: thread.messageCount });
return {
title: `${title} - ${translator('pages.threads.title')}`,
description,
canonicalPath,
openGraphType: 'article',
structuredData: {
'@context': 'https://schema.org',
'@type': 'DiscussionForumPosting',
headline: title,
description,
url: absoluteUrl(canonicalPath),
datePublished: thread.createdAt,
dateModified: thread.lastActiveAt,
inLanguage: thread.languageCode,
keywords: keywords || undefined,
author: thread.author ? { '@type': 'Person', name: thread.author.displayName } : undefined,
interactionStatistic: {
'@type': 'InteractionCounter',
interactionType: { '@type': 'CommentAction' },
userInteractionCount: thread.messageCount
},
isPartOf: {
'@type': 'WebPage',
name: translator('pages.threads.title'),
url: absoluteUrl('/threads')
}
}
};
}