/* RFC-1290: rendered-text corpus contract. `text.corpus.generate` (werkstatt-site) parses every dist/client HTML page with parse5 and emits a deterministic list of visible-text segments (title, headings, body text, list items, buttons, labels, alt/aria-label/placeholder/title attributes) per route+lang; `release.prepare` (werkstatt-engine) copies the corpus into release evidence and verifies its htmlTreeHash against the staged dist. Text rules (CORPUS-*, TERM-*, GRAM-*) consume this artifact instead of re-parsing dist. No language/terminology rules — RFC-1291/RFC-1292 layer those onto text.corpus.validate. No browser-rendered text — the corpus is parsed from static HTML only. No timestamps or run ids — the corpus is a pure function of dist HTML bytes (DNA-58). htmlTreeHash covers EVERY dist/client html file including noindex pages and redirect stubs — it attests the input set, so staleness detection stays strong. Segments key on (route, lang, kind, path) — deterministic element paths make release-to-release diffs stable. RFC-1290: initial — corpusSegment/CorpusPage/TextCorpus schemas + computeHtmlTreeHash shared by site generator and engine release gate. RFC-1290 RFC-1290: rendered-text corpus + release text diff */ import { z } from "zod"; import { byteHash } from "../fingerprint/primitives.ts"; export const corpusSegmentKindSchema = z.enum([ "title", "meta-description", "heading", "text", "list-item", "button", "label", "alt", "aria-label", "placeholder", "title-attr", ]); export type CorpusSegmentKind = z.infer; export const corpusSegmentSchema = z .object({ kind: corpusSegmentKindSchema, /** Whitespace-collapsed, entity-decoded, trimmed visible text. */ text: z.string().min(1), /** Element path, e.g. "main>section[data-slug=price-card]>ul>li:3>span". */ path: z.string().min(1), /** Nearest ancestor element carrying data-slug. */ section: z.string().optional(), }) .strict(); export type CorpusSegment = z.infer; export const corpusPageSchema = z .object({ route: z.string().startsWith("/"), lang: z.string().min(2), /** dist/client-relative file path. */ file: z.string(), segments: z.array(corpusSegmentSchema), }) .strict(); export type CorpusPage = z.infer; export const textCorpusSchema = z .object({ schema: z.literal("werkstatt/text-corpus@1"), systemId: z.string(), htmlTreeHash: z.string().startsWith("sha256:"), pages: z.array(corpusPageSchema), }) .strict(); export type TextCorpus = z.infer; /** * sha256 over sorted "relpath\0sha256(content)\n" lines of every * dist/client html file. Shared by the site generator and the engine's * release.prepare so both compute the same value (RFC-1290). */ export function computeHtmlTreeHash(files: Array<{ relPath: string; content: string }>): string { const lines = files .map((f) => `${f.relPath}\0${byteHash(f.content)}`) .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); return byteHash(lines.join("\n") + (lines.length > 0 ? "\n" : "")); }