/** * Per-route document head. * * `scripts/prerender.mjs` bakes these tags into the static HTML at build time, * which is what crawlers and link unfurlers actually read. This hook exists for * the other half: a visitor who lands on `/` and clicks through to a demo never * fetches a new document, so without it the tab title and the canonical link * would still say "home" three pages later. * * Deliberately no cleanup. Restoring the previous head on unmount would mean * every navigation flickers back to the old title before the next route sets * its own; the next route always sets one, so the last writer simply wins. */ import { useEffect } from 'react'; export const SITE_ORIGIN = 'https://demo.primeintellectgrowth.com'; export const SITE_NAME = 'PIG Demo'; /** `Wordle-five — PIG Demo`. One place, so every tab reads the same shape. */ export function pageTitle(name?: string): string { return name && name.trim() !== '' ? `${name} — ${SITE_NAME}` : SITE_NAME; } /** * Absolutise a site-root path. Open Graph consumers do not resolve relative * URLs — a relative `og:image` is simply no image, silently, and you only find * out when someone pastes the link into Slack. */ export function absoluteUrl(pathOrUrl: string): string { if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl; return `${SITE_ORIGIN}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`; } export interface SeoInput { /** Used verbatim as `document.title`. Wrap with `pageTitle()` if you want the suffix. */ title: string; description?: string; /** Site-root path or absolute URL. */ canonical?: string; /** Site-root path or absolute URL. */ ogImage?: string; } type MetaKey = { name: string } | { property: string }; function upsertMeta(key: MetaKey, content: string): void { const selector = 'name' in key ? `meta[name="${key.name}"]` : `meta[property="${key.property}"]`; let tag = document.head.querySelector(selector); if (!tag) { tag = document.createElement('meta'); if ('name' in key) tag.setAttribute('name', key.name); else tag.setAttribute('property', key.property); document.head.appendChild(tag); } tag.setAttribute('content', content); } function upsertCanonical(href: string): void { let link = document.head.querySelector('link[rel="canonical"]'); if (!link) { link = document.createElement('link'); link.setAttribute('rel', 'canonical'); document.head.appendChild(link); } link.setAttribute('href', href); } export function applySeo(input: SeoInput): void { if (typeof document === 'undefined') return; document.title = input.title; upsertMeta({ property: 'og:title' }, input.title); upsertMeta({ name: 'twitter:title' }, input.title); if (input.description) { upsertMeta({ name: 'description' }, input.description); upsertMeta({ property: 'og:description' }, input.description); upsertMeta({ name: 'twitter:description' }, input.description); } if (input.canonical) { const href = absoluteUrl(input.canonical); upsertCanonical(href); upsertMeta({ property: 'og:url' }, href); } if (input.ogImage) { const href = absoluteUrl(input.ogImage); upsertMeta({ property: 'og:image' }, href); upsertMeta({ name: 'twitter:image' }, href); } } /** * Set the head for this route. * * Deps are the individual strings rather than the object, so a caller can pass * an inline literal without re-running this on every render. */ export function useSeo(input: SeoInput): void { const { title, description, canonical, ogImage } = input; useEffect(() => { applySeo({ title, ...(description === undefined ? {} : { description }), ...(canonical === undefined ? {} : { canonical }), ...(ogImage === undefined ? {} : { ogImage }), }); }, [title, description, canonical, ogImage]); }