/* RFC-0235 egress text normalizer. A server-only adapter that strips AI-authorship typographic signals (special dashes, curly/guillemet quotes, special + zero-width spaces, typographic HTML entities, single-char ellipsis) from text on its way to public output. Pure string transforms — no DOM, no heavy parser — safe in SSR/SSG frontmatter, kernel build steps, and the OG-image generator. Authored sources are never touched; this only transforms output strings. Do not import in browser scripts — server-only (entity decode + Node usage assumptions). Do not touch structural HTML/JSON/XML syntax or the structural entities & < > " '. Do not normalize text inside code/pre/script/style or fenced/inline Markdown code. Do not apply heuristics that distinguish "intentional" from "AI" typography — blanket per signal. RFC-0235: initial egress text normalizer. RFC-0569: add createDevNormalizeMiddleware for dev/prod egress parity. RFC-1070: add U+02BC modifier letter apostrophe to quotes signal. */ // @ai-invariant: RFC-0235. Server-only. Never import in browser scripts. // All special-character sets are expressed as \u escapes — never literal glyphs — // so the source contains no invisible characters and the matchers are auditable. // RFC-0569: createDevNormalizeMiddleware adds a dev-only Astro middleware factory. import type { MiddlewareHandler } from "astro"; export type SignalId = "dashes" | "quotes" | "spaces" | "zeroWidth" | "htmlEntities" | "ellipsis"; export const SIGNAL_IDS: readonly SignalId[] = [ "dashes", "quotes", "spaces", "zeroWidth", "htmlEntities", "ellipsis", ] as const; export interface NormalizeConfig { /** Master switch. When false, every normalizer is a no-op. */ enabled: boolean; /** Per-signal toggle. An absent signal defaults to ON. */ signals: Record; } export interface SignalSpec { id: SignalId; title: string; /** Human-readable summary of the matched Unicode set. */ unicode: string; /** Human-readable replacement description. */ replacement: string; /** Default gating — always true (everything on by default, RFC-0235). */ default: boolean; } /** * The single source of truth for the signal taxonomy. Consumed by * `text.normalize.rules.list`, the validators, and the transforms. */ export const SIGNAL_REGISTRY: readonly SignalSpec[] = [ { id: "dashes", title: "Special dashes", unicode: "U+2010 U+2011 U+2012 U+2013 U+2014 U+2015 U+2212", replacement: "hyphen-minus '-' (surrounding spaces preserved)", default: true, }, { id: "quotes", title: "Typographic / smart quotes", unicode: "U+00AB U+00BB U+2018-U+201F U+02BC", replacement: "straight double / single quote", default: true, }, { id: "spaces", title: "Special spaces", unicode: "U+00A0 U+1680 U+2000-U+200A U+202F U+205F U+3000", replacement: "regular space U+0020 (1:1, runs not collapsed)", default: true, }, { id: "zeroWidth", title: "Zero-width / invisible characters", unicode: "U+00AD U+061C U+180E U+200B U+200C U+200D* U+2060 U+2061-U+2064 U+FEFF", replacement: "removed (ZWJ inside emoji sequences preserved)", default: true, }, { id: "htmlEntities", title: "Typographic HTML entities", unicode: "  — – … « » “ ” ‘ ’   —", replacement: "decoded, then routed through the matching char signal", default: true, }, { id: "ellipsis", title: "Single-character ellipsis", unicode: "U+2026", replacement: "three dots '...'", default: true, }, ]; /** All-on config (the default, applied when no `text.normalize` block is present). */ export const DEFAULT_NORMALIZE_CONFIG: NormalizeConfig = { enabled: true, signals: { dashes: true, quotes: true, spaces: true, zeroWidth: true, htmlEntities: true, ellipsis: true, }, }; /** * Resolve the effective normalize config from a parsed system manifest. * Absent block ⇒ all signals on. Absent individual signal ⇒ on. */ export function resolveNormalizeConfig(manifest: unknown): NormalizeConfig { const block = (manifest as { text?: { normalize?: unknown } } | null | undefined)?.text ?.normalize as { enabled?: unknown; signals?: Record } | undefined; if (!block || typeof block !== "object") { return { ...DEFAULT_NORMALIZE_CONFIG, signals: { ...DEFAULT_NORMALIZE_CONFIG.signals } }; } const enabled = block.enabled === undefined ? true : block.enabled !== false; const sig = block.signals ?? {}; const signals = {} as Record; for (const id of SIGNAL_IDS) { signals[id] = sig[id] === undefined ? true : sig[id] !== false; } return { enabled, signals }; } // --------------------------------------------------------------------------- // Character-level transforms (sets are \u escapes — no literal glyphs) // --------------------------------------------------------------------------- // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash, U+2013 en dash, // U+2014 em dash, U+2015 horizontal bar, U+2212 minus sign. const RE_DASHES = /[‐‑‒–—―−]/g; // U+00AB U+00BB U+201C U+201D U+201E U+201F const RE_QUOTES_DOUBLE = /[«»“”„‟]/g; // U+2018 U+2019 U+201A U+201B U+02BC (modifier letter apostrophe, RFC-1070) const RE_QUOTES_SINGLE = /[‘’‚‛ʼ]/g; const RE_ELLIPSIS = /…/g; // U+00A0 nbsp, U+1680 ogham, U+2000-U+200A, U+202F narrow nbsp, U+205F medium math, // U+3000 ideographic space. const RE_SPACES = /[\u00a0  -\u200a\u202f  ]/g; // Zero-width / invisible cruft: U+00AD soft hyphen, U+061C arabic letter mark, // U+180E Mongolian vowel sep, U+200B ZWSP, U+200C ZWNJ, U+2060 word joiner, // U+2061-U+2064 invisible math ops, U+FEFF BOM/ZWNBSP. NOTE: U+200D (ZWJ) is // handled separately to preserve emoji ZWJ sequences. Directional marks // (U+200E/U+200F) are intentionally excluded — meaningful for RTL content. const RE_ZERO_WIDTH = /[\u00ad\u061c\u180e\u200b\u200c\u2060\u2061-\u2064\ufeff]/g; // Standalone ZWJ: only when NOT bridging two emoji components. const EMOJI_PART = "[\\p{Extended_Pictographic}\\uFE0F\\u{1F3FB}-\\u{1F3FF}]"; const RE_STANDALONE_ZWJ = new RegExp(`(? = { nbsp: "\u00a0", ensp: " ", emsp: " ", emsp13: " ", emsp14: " ", numsp: "\u2007", puncsp: " ", thinsp: "\u2009", hairsp: "\u200a", mdash: "—", ndash: "–", dash: "‐", horbar: "―", minus: "−", hellip: "…", mldr: "…", laquo: "«", raquo: "»", ldquo: "“", rdquo: "”", lsquo: "‘", rsquo: "’", bdquo: "„", sbquo: "‚", shy: "\u00ad", zwnj: "\u200c", zwj: "\u200d", }; // Every codepoint any char signal can match — gates numeric-entity decoding. const TYPOGRAPHIC_CODEPOINTS = new Set([ 0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2015, 0x2212, // dashes 0x00ab, 0x00bb, 0x2018, 0x2019, 0x201a, 0x201b, 0x201c, 0x201d, 0x201e, 0x201f, // quotes 0x00a0, 0x1680, 0x202f, 0x205f, 0x3000, // spaces (U+2000-U+200A range added below) 0x00ad, 0x061c, 0x180e, 0x200b, 0x200c, 0x200d, 0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0xfeff, // zero-width 0x2026, // ellipsis ]); for (let cp = 0x2000; cp <= 0x200a; cp++) TYPOGRAPHIC_CODEPOINTS.add(cp); const RE_NAMED_ENTITY = /&([a-z][a-z0-9]*);/gi; const RE_NUMERIC_ENTITY = /&#(x?)([0-9a-f]+);/gi; /** Decode only the typographic entity subset; leave structural entities intact. */ function decodeTypographicEntities(input: string): string { let s = input.replace(RE_NAMED_ENTITY, (m, name: string) => { const decoded = NAMED_ENTITIES[name.toLowerCase()]; return decoded ?? m; }); s = s.replace(RE_NUMERIC_ENTITY, (m, hex: string, digits: string) => { const cp = parseInt(digits, hex ? 16 : 10); return TYPOGRAPHIC_CODEPOINTS.has(cp) ? String.fromCodePoint(cp) : m; }); return s; } // --------------------------------------------------------------------------- // normalizeText — plain text / char level // --------------------------------------------------------------------------- /** Normalize a plain-text string (also used for HTML text nodes and attr values). */ export function normalizeText(input: string, cfg: NormalizeConfig): string { if (!cfg.enabled || !input) return input; let s = input; if (cfg.signals.htmlEntities) s = decodeTypographicEntities(s); s = applyCharSignals(s, cfg); return s; } // --------------------------------------------------------------------------- // normalizeHtml — tokenizer-based, escaping-safe // --------------------------------------------------------------------------- const HTML_SKIP_TAGS = new Set(["script", "style", "code", "pre", "kbd", "samp"]); const ATTR_WHITELIST = ["alt", "title", "content", "aria-label", "aria-description", "placeholder"]; const RE_JSONLD_BLOCK = /(]*type\s*=\s*["']application\/ld\+json["'][^>]*>)([\s\S]*?)(<\/script>)/gi; function tagName(token: string): string { const m = token.match(/^<\/?([a-zA-Z][a-zA-Z0-9]*)/); return m ? m[1].toLowerCase() : ""; } /** Escape a normalized attribute value for the given quote delimiter. */ function escapeAttr(value: string, quote: '"' | "'"): string { // Only the delimiter needs escaping; we never introduce < or &. return quote === '"' ? value.replace(/"/g, """) : value.replace(/'/g, "'"); } function normalizeOpeningTagAttrs(tag: string, cfg: NormalizeConfig): string { const re = new RegExp(`\\b(${ATTR_WHITELIST.join("|")})\\s*=\\s*("([^"]*)"|'([^']*)')`, "gi"); return tag.replace(re, (_full, name: string, _q: string, dq?: string, sq?: string) => { const quote: '"' | "'" = dq !== undefined ? '"' : "'"; const raw = dq !== undefined ? dq : (sq ?? ""); const normalized = escapeAttr(normalizeText(raw, cfg), quote); return `${name}=${quote}${normalized}${quote}`; }); } /** Normalize an HTML string: text nodes, whitelisted attributes, and inline JSON-LD. */ export function normalizeHtml(input: string, cfg: NormalizeConfig): string { if (!cfg.enabled || !input) return input; // 1. Inline JSON-LD: normalize string values via the JSON path (re-escapes safely). const html = input.replace( RE_JSONLD_BLOCK, (_full, open: string, body: string, close: string) => { return `${open}${normalizeJson(body, cfg)}${close}`; }, ); // 2. Tokenize on tags; normalize text segments + whitelisted attributes. // Only match real HTML tags (starting with a letter, /, or !) — not stray < // in CSS like @media (width<=768px) which would swallow the closer // and permanently trap the skip stack. const tokens = html.split(/(<[a-zA-Z/!][^>]*>)/); const skipStack: string[] = []; const out: string[] = []; for (const token of tokens) { if (token.startsWith("<")) { const isComment = token.startsWith("