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; 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; }; export type ResolvedSeoConfig = { title: string; description: string; canonicalUrl: string; imageUrl: string; robots: string; locale: string; openGraphType: 'website' | 'article'; structuredData: Record; }; 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; 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(/ 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') } } }; }