import { useEffect, useRef } from 'react'; import { getDocModule, getLastModified } from '@/lib/content'; import { resolveLocale } from '@/lib/locale'; import { SITE_URL, toAbsoluteUrl } from '@/lib/paths'; import { getLocaleByPathname, getPageByPathname, getPageTitle, getScopeByPathname, getSeo, getStandalonePage, showTimestamp, siteConfig, siteModel, siteName, } from '@/lib/site-config'; /** * The document head is a pure function of the route. * * Every input — docs.json and the eagerly-globbed frontmatter — is available * synchronously on both the server and the client, so there is no need for a * head manager, a context provider, or a data router. `renderHeadToString` and * `applyHead` consume the exact same `buildHead` output, which makes the * prerendered markup and post-navigation DOM identical by construction. */ export interface HeadTag { tag: 'title' | 'meta' | 'link' | 'script'; attrs?: Record; children?: string; } /** Marks tags this module owns, so client navigation can replace exactly its own. */ export const HEAD_MARKER = 'data-shiso-head'; function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** JSON-LD is script content, not attribute content: only `<` and `&` need care. */ function escapeJsonLd(value: string): string { return value.replace(/ !!item); tags.push({ tag: 'script', attrs: { type: 'application/ld+json' }, children: JSON.stringify([ { '@context': 'https://schema.org', '@type': 'TechArticle', headline: pageTitle || page.label, description, url: canonical, inLanguage: resolveLocale(page.language, siteModel.locale), ...(siteName ? { isPartOf: { '@type': 'WebSite', name: siteName, url: SITE_URL } } : {}), }, { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: breadcrumbs.map((item, index) => ({ '@type': 'ListItem', position: index + 1, name: item.name, ...(item.url ? { item: item.url } : {}), })), }, ]), }); } return tags; } export function renderHeadToString(tags: HeadTag[]): string { return tags .map(({ tag, attrs, children }) => { const attributes = Object.entries(attrs || {}) .map(([key, value]) => ` ${key}="${escapeHtml(value)}"`) .join(''); if (tag === 'meta' || tag === 'link') { return `<${tag}${attributes} ${HEAD_MARKER} />`; } const content = tag === 'script' ? escapeJsonLd(children || '') : escapeHtml(children || ''); return `<${tag}${attributes} ${HEAD_MARKER}>${content}`; }) .join('\n '); } export function applyHead(tags: HeadTag[]) { const { head } = document; head.querySelectorAll(`[${HEAD_MARKER}]`).forEach(node => { node.remove(); }); for (const { tag, attrs, children } of tags) { const element = document.createElement(tag); for (const [key, value] of Object.entries(attrs || {})) { element.setAttribute(key, value); } if (children !== undefined) { element.textContent = children; } element.setAttribute(HEAD_MARKER, ''); head.append(element); } } /** * Applies the head for the current route on client navigation. * * The first render after hydration is skipped: the prerendered head is already * correct, and rewriting it would tear down and recreate every tag on load. */ export function useHead(pathname: string) { const hydratedPath = useRef(null); useEffect(() => { if (hydratedPath.current === null) { hydratedPath.current = pathname; return; } applyHead(buildHead(pathname)); // Keep the document language and direction in sync when navigating // between language scopes. const { lang, dir } = getLocaleByPathname(pathname); document.documentElement.lang = lang; document.documentElement.dir = dir; }, [pathname]); }