/*
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 =
/(