export interface PageMeta { title: string; description?: string; siteName?: string; url?: string; canonicalUrl?: string; image?: string; imageAlt?: string; type?: 'website' | 'article'; locale?: string; themeColor?: string; robots?: string; keywords?: string[]; twitterCard?: 'summary' | 'summary_large_image'; structuredData?: Record | Array>; } export interface ApplyPageMetaOptions { document?: Document; baseUrl?: string; titleTemplate?: string; restoreOnCleanup?: boolean; } export interface MachineMetaInput { id: string; title: string; description: string; category?: string; difficulty?: string; thumbnailUrl?: string; keywords?: string[]; } const MANAGED_ATTRIBUTE = 'data-mechanica-meta'; const DEFAULT_SITE_NAME = 'Mechanica'; const DEFAULT_IMAGE = '/social/mechanica-og.svg'; export function applyPageMeta( meta: PageMeta, options: ApplyPageMetaOptions = {}, ): () => void { const activeDocument = options.document ?? (typeof document !== 'undefined' ? document : undefined); if (!activeDocument) { return () => undefined; } const previousTitle = activeDocument.title; const desiredKeys = new Set(); const siteName = meta.siteName ?? DEFAULT_SITE_NAME; const title = formatTitle(meta.title, options.titleTemplate); activeDocument.title = title; setManagedMeta(activeDocument, desiredKeys, 'description', 'name', 'description', meta.description); setManagedMeta(activeDocument, desiredKeys, 'keywords', 'name', 'keywords', formatKeywords(meta.keywords)); setManagedMeta(activeDocument, desiredKeys, 'robots', 'name', 'robots', meta.robots); setManagedMeta(activeDocument, desiredKeys, 'theme-color', 'name', 'theme-color', meta.themeColor); setManagedMeta(activeDocument, desiredKeys, 'og:title', 'property', 'og:title', title); setManagedMeta(activeDocument, desiredKeys, 'og:description', 'property', 'og:description', meta.description); setManagedMeta(activeDocument, desiredKeys, 'og:type', 'property', 'og:type', meta.type ?? 'website'); setManagedMeta(activeDocument, desiredKeys, 'og:site_name', 'property', 'og:site_name', siteName); setManagedMeta(activeDocument, desiredKeys, 'og:url', 'property', 'og:url', absoluteUrl(meta.url, options.baseUrl)); setManagedMeta(activeDocument, desiredKeys, 'og:image', 'property', 'og:image', absoluteUrl(meta.image, options.baseUrl)); setManagedMeta(activeDocument, desiredKeys, 'og:image:alt', 'property', 'og:image:alt', meta.imageAlt); setManagedMeta(activeDocument, desiredKeys, 'og:locale', 'property', 'og:locale', meta.locale); setManagedMeta(activeDocument, desiredKeys, 'twitter:card', 'name', 'twitter:card', meta.twitterCard ?? 'summary_large_image'); setManagedMeta(activeDocument, desiredKeys, 'twitter:title', 'name', 'twitter:title', title); setManagedMeta(activeDocument, desiredKeys, 'twitter:description', 'name', 'twitter:description', meta.description); setManagedMeta(activeDocument, desiredKeys, 'twitter:image', 'name', 'twitter:image', absoluteUrl(meta.image, options.baseUrl)); setManagedCanonical( activeDocument, desiredKeys, absoluteUrl(meta.canonicalUrl ?? meta.url, options.baseUrl), ); setManagedStructuredData(activeDocument, desiredKeys, meta.structuredData); removeStaleManagedNodes(activeDocument, desiredKeys); return () => { if (!options.restoreOnCleanup) { return; } activeDocument.title = previousTitle; for (const key of desiredKeys) { findManagedNode(activeDocument, key)?.remove(); } }; } export function buildMachinePageMeta( machine: MachineMetaInput, options: { baseUrl?: string; pathPrefix?: string; siteName?: string } = {}, ): PageMeta { const pathPrefix = options.pathPrefix ?? '/machines'; const url = `${pathPrefix.replace(/\/$/, '')}/${encodeURIComponent(machine.id)}`; const keywords = [ machine.title, machine.category, machine.difficulty, 'mechanical system', '3D viewer', ...(machine.keywords ?? []), ].filter((value): value is string => Boolean(value)); return { title: `${machine.title} | 3D Mechanical System`, description: machine.description, siteName: options.siteName ?? DEFAULT_SITE_NAME, url: absoluteUrl(url, options.baseUrl), canonicalUrl: absoluteUrl(url, options.baseUrl), image: absoluteUrl(machine.thumbnailUrl ?? DEFAULT_IMAGE, options.baseUrl), imageAlt: `${machine.title} interactive 3D mechanical visualization`, type: 'article', keywords, twitterCard: 'summary_large_image', structuredData: { '@context': 'https://schema.org', '@type': 'LearningResource', name: machine.title, description: machine.description, educationalUse: 'Interactive mechanical systems reference', learningResourceType: '3D visualization', about: machine.category, proficiencyLevel: machine.difficulty, url: absoluteUrl(url, options.baseUrl), }, }; } export function absoluteUrl(value: string | undefined, baseUrl?: string): string | undefined { if (!value) { return undefined; } try { return new URL(value).toString(); } catch { const origin = baseUrl ?? (typeof window !== 'undefined' ? window.location.origin : undefined); if (!origin) { return value; } return new URL(value, origin).toString(); } } function formatTitle(title: string, template = '%s • Mechanica'): string { if (!template.includes('%s')) { return title; } return template.replace('%s', title); } function formatKeywords(keywords: string[] | undefined): string | undefined { if (!keywords || keywords.length === 0) { return undefined; } return [...new Set(keywords.map((keyword) => keyword.trim()).filter(Boolean))].join(', '); } function setManagedMeta( activeDocument: Document, desiredKeys: Set, key: string, attributeName: 'name' | 'property', attributeValue: string, content: string | undefined, ): void { if (!content) { return; } desiredKeys.add(key); const element = ensureManagedNode(activeDocument, key, 'meta'); element.setAttribute(attributeName, attributeValue); element.setAttribute('content', content); } function setManagedCanonical( activeDocument: Document, desiredKeys: Set, href: string | undefined, ): void { if (!href) { return; } const key = 'canonical'; desiredKeys.add(key); const element = ensureManagedNode(activeDocument, key, 'link'); element.setAttribute('rel', 'canonical'); element.setAttribute('href', href); } function setManagedStructuredData( activeDocument: Document, desiredKeys: Set, structuredData: PageMeta['structuredData'], ): void { if (!structuredData) { return; } const key = 'structured-data'; desiredKeys.add(key); const element = ensureManagedNode(activeDocument, key, 'script'); element.setAttribute('type', 'application/ld+json'); element.textContent = JSON.stringify(structuredData); } function ensureManagedNode( activeDocument: Document, key: string, tagName: K, ): HTMLElementTagNameMap[K] { const existing = findManagedNode(activeDocument, key); if (existing && existing.tagName.toLowerCase() === tagName) { return existing as HTMLElementTagNameMap[K]; } existing?.remove(); const element = activeDocument.createElement(tagName); element.setAttribute(MANAGED_ATTRIBUTE, key); activeDocument.head.appendChild(element); return element; } function findManagedNode(activeDocument: Document, key: string): HTMLElement | undefined { return Array.from(activeDocument.head.querySelectorAll(`[${MANAGED_ATTRIBUTE}]`)).find( (element) => element.getAttribute(MANAGED_ATTRIBUTE) === key, ); } function removeStaleManagedNodes(activeDocument: Document, desiredKeys: Set): void { for (const element of Array.from( activeDocument.head.querySelectorAll(`[${MANAGED_ATTRIBUTE}]`), )) { const key = element.getAttribute(MANAGED_ATTRIBUTE); if (!key || !desiredKeys.has(key)) { element.remove(); } } }