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
text/typescript
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
]);
}