/*
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" : ""));
}