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
274 lines
9.0 KiB
TypeScript
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')
|
|
}
|
|
}
|
|
};
|
|
}
|