/** * Per-route SEO injection for the production SPA fallback. * * AI and search crawlers fetch raw HTML without executing JavaScript, so * serving the bare SPA shell makes every page look empty to them. The crawlers * this app recognises (GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, * Bingbot, Googlebot and the rest) are enumerated in `lib/crawlers.ts` — that * registry is the source of truth, and the middleware that records their hits * for the admin "AI Visibility" page reads the same list. * * Instead of prerendering the React app, the server * splices per-route metadata and content — generated at build time by * scripts/generate-seo.ts from the same MDX the client renders — into the * shell. Hydration replaces #root, so browser users see the SPA unchanged. */ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import type { Context } from 'hono'; import { logger } from './logger'; export interface RouteSeo { title: string; description: string; canonical?: string; /** Build-time rendered HTML from repo-controlled MDX — trusted, injected verbatim. */ html?: string; jsonLd?: Record; } export function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** Serialize JSON-LD so `` (or any tag) in data can't break out of the script element. */ function serializeJsonLd(jsonLd: Record): string { return JSON.stringify(jsonLd).replace(/ `${open}${escapeHtml(value)}${close}` ); } /** Pure shell transform — exported for unit tests. */ export function injectSeo(shell: string, meta: RouteSeo): string { let out = shell; out = out.replace(/[^<]*<\/title>/, () => `<title>${escapeHtml(meta.title)}`); out = replaceMetaContent(out, 'name', 'description', meta.description); out = replaceMetaContent(out, 'property', 'og:title', meta.title); out = replaceMetaContent(out, 'property', 'og:description', meta.description); out = replaceMetaContent(out, 'name', 'twitter:title', meta.title); out = replaceMetaContent(out, 'name', 'twitter:description', meta.description); const headExtras: string[] = []; if (meta.canonical) { out = replaceMetaContent(out, 'property', 'og:url', meta.canonical); headExtras.push(``); } if (meta.jsonLd) { headExtras.push(``); } if (headExtras.length) { out = out.replace('', () => ` ${headExtras.join('\n ')}\n `); } if (meta.html) { // inside the content would end the element early and // let the rest parse as live HTML — neutralize it (trusted MDX today, but // a docs page quoting a