/* Text-surface extractor for the typography rule engine (RFC-1068). Extracts scannable text segments from Markdown frontmatter strings and body lines, applying a normative stripping order so rules see clean text without code blocks, HTML tags, formulas, or URLs. Also catches YAML frontmatter parse errors and maps them to a structured yamlError field. Do not implement typography rules — that is rules-tier1.ts. Do not read files or perform I/O — the caller passes source text. Do not validate content semantics — only extracts text for rule scanning. RFC-1068: initial creation. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1324 cold-run defect: declared frontmatter `lang` now wins over the directory segment — RFC-1329 binding legal documents live under non-binding locale dirs carrying `lang: de`; directory-keyed checking was a false-positive by locale mismatch. */ import YAML from "yaml"; import { parseMarkdownFrontmatter } from "../content/markdown-frontmatter.ts"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type TextSegmentSource = "frontmatter" | "body"; export interface TextSegment { /** Repo-relative file path. */ file: string; /** 1-based file line of the segment start (frontmatter offset already applied). */ line: number; /** JSON-path for frontmatter strings (`$.blocks[2].props.items[0].text`), `$body` for body lines. */ path: string; source: TextSegmentSource; /** Segment text after stripping; this is what rules see. */ text: string; /** Original text before stripping; used for fix (RFC-1072) and messages. */ raw: string; /** True when the segment is a markdown table row (some rules skip these). */ isTableRow: boolean; locale: string; } export interface TextSurfaceOptions { /** Keys whose values are never text (default: TECHNICAL_KEYS). */ skipKeys?: ReadonlySet; /** Site i18n languages; used to derive file locale. */ languages: readonly string[]; defaultLanguage: string; } export interface YamlError { line: number; column: number; message: string; } export interface ExtractTextSurfaceResult { segments: TextSegment[]; yamlError: YamlError | null; } // --------------------------------------------------------------------------- // Technical keys — values under these keys are never treated as text // --------------------------------------------------------------------------- export const TECHNICAL_KEYS = new Set([ "id", "slug", "href", "url", "src", "path", "type", "use", "kind", "variant", "layout", "anchorId", "blockId", "ref", "key", "vendor", "collection", "lang", "locale", "currency", "email", "phone", "proseId", ]); // Parent key → child keys whose values are technical (never text) const TECHNICAL_PARENT_CHILD: Record> = { icon: new Set(["name"]), // labels.md shell switches: enum values (byIntent, shown, hidden), not text. attribution: new Set(["credits", "proseAuthorship", "footerCopyright"]), }; // --------------------------------------------------------------------------- // Identifier heuristic — values without whitespace that contain /, _, or . // and no letter-space-letter sequence are treated as identifiers and skipped // --------------------------------------------------------------------------- const IDENTIFIER_REGEX = /^[^\s]+$/; const HAS_SLASH_OR_UNDERSCORE_OR_DOT = /[/_\.]/; const LETTER_SPACE_LETTER = /\p{L}\s\p{L}/u; function isIdentifierValue(value: string): boolean { if (!IDENTIFIER_REGEX.test(value)) return false; if (!HAS_SLASH_OR_UNDERSCORE_OR_DOT.test(value)) return false; if (LETTER_SPACE_LETTER.test(value)) return false; return true; } // --------------------------------------------------------------------------- // File locale derivation // --------------------------------------------------------------------------- export function deriveFileLocale( file: string, languages: readonly string[], defaultLanguage: string, ): string { // file is repo-relative: may be src/content///... or // missions//workpiece/src/content///... const parts = file.split("/"); const srcIdx = parts.indexOf("src"); if (srcIdx >= 0 && srcIdx + 3 < parts.length && parts[srcIdx + 1] === "content") { const lang = parts[srcIdx + 3]; if (languages.includes(lang)) return lang; } return defaultLanguage; } // --------------------------------------------------------------------------- // Frontmatter string extraction // --------------------------------------------------------------------------- interface FrontmatterStringEntry { path: string; value: string; /** 1-based line within the frontmatter block (relative to frontmatter start). */ lineInFrontmatter: number; } function extractFrontmatterStrings( data: unknown, parentKey: string | null, skipKeys: ReadonlySet, lineCounter: YAML.LineCounter | null, frontmatterText: string, ): FrontmatterStringEntry[] { const results: FrontmatterStringEntry[] = []; // fs.walk.lint: allow — walks a frontmatter object graph, not the filesystem function walk(value: unknown, currentPath: string, parent: string | null): void { if (typeof value === "string") { // Check if this key is technical const lastKey = currentPath.split(".").pop() ?? currentPath; const isTechnical = skipKeys.has(lastKey) || (parent !== null && TECHNICAL_PARENT_CHILD[parent]?.has(lastKey) === true); if (isTechnical) return; if (isIdentifierValue(value)) return; // Derive line from lineCounter if available let lineInFrontmatter = 0; if (lineCounter) { const range = lineCounter.lineStarts; // Find the position of this value in the source — we approximate by // searching for the value in the frontmatter text const valueIndex = frontmatterText.indexOf(value); if (valueIndex >= 0) { lineInFrontmatter = range.findIndex((start, i) => { const nextStart = range[i + 1] ?? frontmatterText.length + 1; return valueIndex >= start && valueIndex < nextStart; }); if (lineInFrontmatter < 0) lineInFrontmatter = 0; lineInFrontmatter += 1; // 1-based } } results.push({ path: currentPath, value, lineInFrontmatter }); return; } if (Array.isArray(value)) { for (let i = 0; i < value.length; i += 1) { walk(value[i], `${currentPath}[${i}]`, parent); } return; } if (value !== null && typeof value === "object") { for (const [key, child] of Object.entries(value as Record)) { const newParent = key; // Check if this key itself is technical (skip entire subtree) if (skipKeys.has(key)) continue; if (parent !== null && TECHNICAL_PARENT_CHILD[parent]?.has(key) === true) continue; walk(child, currentPath === "$" ? `$.${key}` : `${currentPath}.${key}`, newParent); } return; } } walk(data, "$", parentKey); return results; } // --------------------------------------------------------------------------- // Body text extraction with normative stripping order // --------------------------------------------------------------------------- // (1) Inline code spans const INLINE_CODE = /`[^`]*`/g; // (1b) Markdown autolinks — must be handled before HTML tag stripping const AUTOLINK = /<(https?:\/\/[^\s>]+)>/gi; // (2) HTML comments const HTML_COMMENT = //g; // (3) Void HTML elements const VOID_HTML = /<(?:br|hr|img|input|meta|link|source|area|base|col|embed|param|track|wbr)\b[^>]*\/?>/giu; // (3) Non-void HTML elements — strip tags, keep text const NON_VOID_HTML = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/gu; const VOID_ELEMENTS = new Set([ "br", "hr", "img", "input", "meta", "link", "source", "area", "base", "col", "embed", "param", "track", "wbr", ]); // (4) Formula expressions =(…) const FORMULA = /=\([^)]*\)/g; // (4b) CMS template expression placeholder (same as rules-tier1.ts CMS_PLACEHOLDER) const CMS_PLACEHOLDER = "\uE002"; // (5) Markdown link/image targets — keep link text, remove ](…) const LINK_TARGET = /\]\([^)]*\)/g; // (6) URL, e-mail, domain tokens const URL_PATTERN = /https?:\/\/[^\s)]+/gi; const EMAIL_PATTERN = /[\w.+-]+@[\w.-]+\.\w{2,}/gi; const DOMAIN_PATTERN = /\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b/gi; function stripBodyLine(line: string): string { let result = line; // (1) Remove fenced code blocks (they span multiple lines, but for per-line // processing we rely on the caller to skip fenced blocks entirely) result = result.replace(INLINE_CODE, CMS_PLACEHOLDER); // (1b) Replace markdown autolinks with URL placeholder result = result.replace(AUTOLINK, "\uE001"); // (2) Remove HTML comments result = result.replace(HTML_COMMENT, ""); // (3) Strip inline HTML tags result = result.replace(VOID_HTML, ""); result = result.replace(NON_VOID_HTML, (match, tagName) => { if (VOID_ELEMENTS.has(String(tagName).toLowerCase())) return ""; // Non-void: keep text content (the match is just the tag, not content) return ""; }); // (4) Replace formulas with placeholder result = result.replace(FORMULA, "\uE000"); // (4b) Replace CMS template expressions ({price:...}, {t:...}, etc.) with placeholder result = result.replace(/\{[^}]*\}/g, CMS_PLACEHOLDER); // (4c) Replace CMS template expressions (=(path.to.value), =(path/to/value)) with placeholder result = result.replace(/=\([^)]*\)/g, "\uE002"); // (5) Remove markdown link targets, keep link text result = result.replace(LINK_TARGET, ""); // (6) Replace URLs, e-mails, domains with placeholder result = result.replace(URL_PATTERN, "\uE001"); result = result.replace(EMAIL_PATTERN, "\uE001"); result = result.replace(DOMAIN_PATTERN, "\uE001"); return result; } // --------------------------------------------------------------------------- // Main extractor // --------------------------------------------------------------------------- export function extractTextSurface( file: string, source: string, options: TextSurfaceOptions, ): ExtractTextSurfaceResult { const skipKeys = options.skipKeys ?? TECHNICAL_KEYS; const dirLocale = deriveFileLocale(file, options.languages, options.defaultLanguage); // Parse frontmatter — catch YAML errors // Strip BOM if present so the frontmatter regex matches const bomStripped = source.replace(/^\uFEFF/, ""); const frontmatterMatch = bomStripped.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); const frontmatterText = frontmatterMatch?.[1] ?? ""; const frontmatterLineCount = frontmatterMatch ? frontmatterMatch[0].split(/\r?\n/).length - 1 : 0; let data: Record = {}; let yamlError: YamlError | null = null; try { const parsed = parseMarkdownFrontmatter(source); data = parsed.data; } catch (err) { // YAML parse error — extract line/column from YAMLParseError if (err instanceof YAML.YAMLParseError) { const linePos = (err as unknown as { linePos?: Array<{ line: number; col: number }> }) .linePos; const line = linePos?.[0]?.line ?? 0; const column = linePos?.[0]?.col ?? 0; yamlError = { line, column, message: err.message }; } else { yamlError = { line: 0, column: 0, message: err instanceof Error ? err.message : String(err) }; } } // A declared frontmatter `lang` wins over the directory segment — legal // binding documents live under a non-binding locale directory but carry // `lang: de` (RFC-1329), and checking German prose under `ru`/`uz` rules is // a false-positive by locale mismatch. const declaredLang = typeof data.lang === "string" && data.lang.trim().length > 0 ? data.lang.trim() : undefined; const locale = declaredLang ?? dirLocale; const segments: TextSegment[] = []; // Extract frontmatter strings if (Object.keys(data).length > 0 && !yamlError) { let lineCounter: YAML.LineCounter | null = null; try { lineCounter = new YAML.LineCounter(); YAML.parseDocument(frontmatterText, { lineCounter }); } catch { lineCounter = null; } const fmStrings = extractFrontmatterStrings(data, null, skipKeys, lineCounter, frontmatterText); for (const entry of fmStrings) { const stripped = stripBodyLine(entry.value); if (stripped.trim().length === 0) continue; segments.push({ file, line: entry.lineInFrontmatter + 1, // +1 because frontmatter starts at line 2 (after ---) path: entry.path, source: "frontmatter", text: stripped, raw: entry.value, isTableRow: false, locale, }); } } // Extract body lines const body = frontmatterMatch ? bomStripped.slice(frontmatterMatch[0].length) : bomStripped; const bodyLines = body.split(/\r?\n/); const bodyLineOffset = frontmatterLineCount; let inFencedBlock = false; let inHtmlComment = false; for (let i = 0; i < bodyLines.length; i += 1) { const rawLine = bodyLines[i]; const lineNum = i + 1 + bodyLineOffset; // Track fenced code blocks if (/^```/.test(rawLine.trim())) { inFencedBlock = !inFencedBlock; continue; } if (inFencedBlock) continue; // Track multi-line HTML comments if (inHtmlComment) { if (rawLine.includes("-->")) { inHtmlComment = false; // Strip everything up to and including -->, keep the rest const after = rawLine.slice(rawLine.indexOf("-->") + 3); if (after.trim().length === 0) continue; const strippedAfter = stripBodyLine(after); if (strippedAfter.trim().length === 0) continue; segments.push({ file, line: lineNum, path: "$body", source: "body", text: strippedAfter, raw: after, isTableRow: /^\|.*\|/.test(after.trim()), locale, }); } continue; } // Check if line opens a multi-line HTML comment ( on same line) const openIdx = rawLine.indexOf("", openIdx); if (closeIdx === -1) { // Comment opens but doesn't close on this line — keep content before