UNPKG

preact-missing-hooks

Version:

A lightweight, extendable collection of missing React-like hooks for Preact — plus fresh, powerful new ones designed specifically for modern Preact apps.

419 lines (389 loc) 13 kB
import { useEffect, useRef } from "react"; const SCRIPT_SELECTOR = 'script[data-llm="true"]'; const SCRIPT_TYPE = "application/llm+json"; /** Max lengths for string fields to avoid huge payloads and XSS surface. */ const MAX_TITLE = 200; const MAX_DESCRIPTION = 2000; const MAX_TAG_LENGTH = 100; const MAX_TAGS = 50; const MAX_OUTLINE_ITEM = 300; const MAX_OUTLINE_ITEMS = 50; const MAX_URL_LENGTH = 2048; const MAX_AUTHOR = 200; const MAX_SITE_NAME = 100; const MAX_ROBOTS = 100; /** Open Graph / content type for og:type. */ export type OGType = | "website" | "article" | "profile" | "video.other" | "product" | "music.song" | "book"; /** Config for the useLLMMetadata hook. All fields optional except route. */ export interface LLMConfig { /** Current route path (e.g. "/blog/ai-hooks"). Changes trigger metadata update. */ route: string; /** "manual" = use title/description/tags from config. "auto-extract" = derive from DOM. */ mode?: "manual" | "auto-extract"; /** Page title (manual mode). */ title?: string; /** Page description (manual mode). */ description?: string; /** Tags/keywords (manual mode). */ tags?: string[]; /** Canonical URL (absolute). */ canonicalUrl?: string; /** Content language (e.g. "en", "en-US"). */ language?: string; /** Open Graph type (website, article, etc.). */ ogType?: OGType; /** OG image URL (absolute). */ ogImage?: string; /** OG image alt text. */ ogImageAlt?: string; /** Site name (e.g. for social previews). */ siteName?: string; /** Author name (for articles). */ author?: string; /** ISO date string (article publish). */ publishedTime?: string; /** ISO date string (article last modified). */ modifiedTime?: string; /** Robots hint (e.g. "index, follow"). */ robots?: string; /** Extra key-value pairs (e.g. section, category). Keys/values are sanitized. */ extra?: Record<string, string | number | boolean | string[]>; } /** Payload injected as JSON in the LLM script tag. Only includes defined, safe values. */ export interface LLMPayload { route: string; title?: string; description?: string; tags?: string[]; outline?: string[]; canonicalUrl?: string; language?: string; ogType?: string; ogImage?: string; ogImageAlt?: string; siteName?: string; author?: string; publishedTime?: string; modifiedTime?: string; robots?: string; generatedAt: string; extra?: Record<string, string | number | boolean | string[]>; } /** Selectors for elements to ignore when auto-extracting. */ const IGNORE_SELECTOR = "nav, footer, [role='navigation'], [role='contentinfo'], script, style, noscript"; /** * Coerce value to a safe string for JSON. Never throws. */ function safeStr(value: unknown, maxLen: number): string { if (value == null) return ""; const s = typeof value === "string" ? value : String(value); const trimmed = s.trim(); return trimmed.length > maxLen ? trimmed.slice(0, maxLen) : trimmed; } /** * Coerce to safe string array. Never throws. */ function safeTagList(value: unknown): string[] { if (!Array.isArray(value)) return []; const out: string[] = []; for (let i = 0; i < value.length && out.length < MAX_TAGS; i++) { const s = safeStr(value[i], MAX_TAG_LENGTH); if (s) out.push(s); } return out; } /** * Safe URL: only include if it looks like a valid http(s) URL and within length. */ function safeUrl(value: unknown): string { const s = safeStr(value, MAX_URL_LENGTH); if (!s) return ""; try { const u = new URL(s); if (u.protocol === "http:" || u.protocol === "https:") return s; } catch { // ignore } return ""; } /** * Returns true if el is visible. Never throws. */ function isVisible(el: Element): boolean { try { if (typeof window === "undefined") return false; const style = window.getComputedStyle(el); return ( style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" ); } catch { return false; } } /** * Auto-extract title, outline (h1/h2), and first 3 visible paragraphs. * Fully safe: never throws; returns defaults on any error. */ function autoExtract(): { title: string; outline: string[]; description: string; } { const empty = { title: "", outline: [] as string[], description: "" }; try { if (typeof document === "undefined") return empty; const title = safeStr(document.title, MAX_TITLE); const outline: string[] = []; let description = ""; try { const ignoreRoots = document.querySelectorAll(IGNORE_SELECTOR); const isInsideIgnored = (el: Element): boolean => { for (const root of ignoreRoots) { if (root.contains(el)) return true; } return false; }; const headings = document.querySelectorAll("h1, h2"); for (const h of headings) { if (outline.length >= MAX_OUTLINE_ITEMS) break; if (isVisible(h) && !isInsideIgnored(h)) { const text = safeStr(h.textContent, MAX_OUTLINE_ITEM); if (text) outline.push(text); } } const paragraphs = document.querySelectorAll("p"); const visibleParagraphs: string[] = []; for (const p of paragraphs) { if (visibleParagraphs.length >= 3) break; if (isVisible(p) && !isInsideIgnored(p)) { const text = safeStr(p.textContent, 1000); if (text) visibleParagraphs.push(text); } } description = visibleParagraphs.join(" ").trim().slice(0, MAX_DESCRIPTION) || ""; } catch { // keep title, empty outline/description } return { title, outline, description }; } catch { return empty; } } /** * Normalize config to a safe object. Never throws. */ function normalizeConfig(config: LLMConfig | null | undefined): LLMConfig { if (config == null || typeof config !== "object") { return { route: "/" }; } return { route: safeStr(config.route, 2048) || "/", mode: config.mode === "auto-extract" ? "auto-extract" : "manual", title: config.title !== undefined ? safeStr(config.title, MAX_TITLE) : undefined, description: config.description !== undefined ? safeStr(config.description, MAX_DESCRIPTION) : undefined, tags: config.tags !== undefined ? safeTagList(config.tags) : undefined, canonicalUrl: config.canonicalUrl !== undefined ? safeUrl(config.canonicalUrl) : undefined, language: config.language !== undefined ? safeStr(config.language, 20) : undefined, ogType: config.ogType !== undefined ? (safeStr(config.ogType, 50) as OGType) : undefined, ogImage: config.ogImage !== undefined ? safeUrl(config.ogImage) : undefined, ogImageAlt: config.ogImageAlt !== undefined ? safeStr(config.ogImageAlt, 200) : undefined, siteName: config.siteName !== undefined ? safeStr(config.siteName, MAX_SITE_NAME) : undefined, author: config.author !== undefined ? safeStr(config.author, MAX_AUTHOR) : undefined, publishedTime: config.publishedTime !== undefined ? safeStr(config.publishedTime, 50) : undefined, modifiedTime: config.modifiedTime !== undefined ? safeStr(config.modifiedTime, 50) : undefined, robots: config.robots !== undefined ? safeStr(config.robots, MAX_ROBOTS) : undefined, extra: config.extra !== undefined && typeof config.extra === "object" && !Array.isArray(config.extra) ? sanitizeExtra(config.extra) : undefined, }; } /** * Sanitize extra object: only string/number/boolean/string[] values; keys and strings bounded. */ function sanitizeExtra( extra: Record<string, unknown> ): Record<string, string | number | boolean | string[]> { const out: Record<string, string | number | boolean | string[]> = {}; try { for (const key of Object.keys(extra).slice(0, 20)) { const safeKey = safeStr(key, 50); if (!safeKey) continue; const v = extra[key]; if (typeof v === "string") out[safeKey] = v.slice(0, 500); else if (typeof v === "number" && Number.isFinite(v)) out[safeKey] = v; else if (typeof v === "boolean") out[safeKey] = v; else if (Array.isArray(v)) out[safeKey] = v .map((x) => safeStr(x, 200)) .filter(Boolean) .slice(0, 20); } } catch { // ignore } return out; } /** * Build the LLM payload. Never throws; returns minimal payload on any error. */ function buildPayload(normalized: LLMConfig): LLMPayload { try { const generatedAt = new Date().toISOString(); const base: LLMPayload = { route: normalized.route, generatedAt, }; if (normalized.mode === "auto-extract") { const extracted = autoExtract(); base.title = (normalized.title ?? extracted.title) || undefined; base.description = (normalized.description ?? extracted.description) || undefined; if (extracted.outline.length > 0) base.outline = extracted.outline; } else { if (normalized.title !== undefined && normalized.title !== "") base.title = normalized.title; if (normalized.description !== undefined && normalized.description !== "") base.description = normalized.description; } if (normalized.tags && normalized.tags.length > 0) base.tags = normalized.tags; if (normalized.canonicalUrl) base.canonicalUrl = normalized.canonicalUrl; if (normalized.language) base.language = normalized.language; if (normalized.ogType) base.ogType = normalized.ogType; if (normalized.ogImage) base.ogImage = normalized.ogImage; if (normalized.ogImageAlt) base.ogImageAlt = normalized.ogImageAlt; if (normalized.siteName) base.siteName = normalized.siteName; if (normalized.author) base.author = normalized.author; if (normalized.publishedTime) base.publishedTime = normalized.publishedTime; if (normalized.modifiedTime) base.modifiedTime = normalized.modifiedTime; if (normalized.robots) base.robots = normalized.robots; if (normalized.extra && Object.keys(normalized.extra).length > 0) base.extra = normalized.extra; return base; } catch { return { route: normalized?.route ?? "/", generatedAt: new Date().toISOString(), }; } } /** * Remove any existing LLM script. Never throws. */ function removeExistingScript(): void { try { if (typeof document === "undefined" || !document.querySelectorAll) return; document.querySelectorAll(SCRIPT_SELECTOR).forEach((el) => el.remove()); } catch { // ignore } } /** * Inject a new LLM script. Never throws. */ function injectScript(payload: LLMPayload): void { try { if (typeof document === "undefined" || !document.head) return; const script = document.createElement("script"); script.type = SCRIPT_TYPE; script.setAttribute("data-llm", "true"); script.textContent = JSON.stringify(payload); document.head.appendChild(script); } catch { // ignore } } /** * Production-ready hook: injects an AI-readable metadata block into the document head * when the route changes. Framework-agnostic (React 18+ and Preact 10+ via aliasing). * * - Rich structure: route, title, description, tags, outline (auto), canonicalUrl, language, * ogType, ogImage, ogImageAlt, siteName, author, publishedTime, modifiedTime, robots, extra. * - Safe usage: never throws; invalid/missing config is normalized; DOM and JSON are guarded. * - Cacheable: if the generated payload is unchanged, the script is not replaced. * - SSR-safe: no-op when window/document is undefined. * - Cleans up on unmount (removes the script). * * @param config - Route (required), mode, and optional metadata fields. Can be partial; defaults applied. */ export function useLLMMetadata(config: LLMConfig | null | undefined): void { const prevPayloadRef = useRef<string | null>(null); useEffect(() => { try { if (typeof window === "undefined") return; const normalized = normalizeConfig(config); const payload = buildPayload(normalized); const payloadStr = JSON.stringify(payload); if (prevPayloadRef.current === payloadStr) return; prevPayloadRef.current = payloadStr; removeExistingScript(); injectScript(payload); return () => { removeExistingScript(); prevPayloadRef.current = null; }; } catch { // no-op: never throw from effect } }, [ config?.route, config?.mode, config?.title, config?.description, config?.tags, config?.canonicalUrl, config?.language, config?.ogType, config?.ogImage, config?.ogImageAlt, config?.siteName, config?.author, config?.publishedTime, config?.modifiedTime, config?.robots, // extra is read inside effect; change route/title/etc. to force update when only extra changed ]); }