/* RFC-0377: pure per-page Markdown projector. Turns a SemanticPageModel into a clean, agent-structured Markdown document with a predictable section hierarchy (Summary, Business context, Data / APIs, User flows, Constraints) plus optional People, Initiatives, and FAQ. Framework-agnostic — no DOM, no fetch, no HTML scraping. Do not convert HTML — content is already structured. HTML→Markdown (CMS richText) lives in the content-source adapter, never here. RFC-0166: initial implementation — build-time per-page Markdown twin. RFC-0372: buildPageMarkdown now renders from unified page.blocks instead of answerBlocks + contentBlocks. RFC-0377: restructured body into standardized sections (Summary, Business context, Data / APIs, User flows, Constraints) with heuristic block classification. */ import { canonicalizeGeneratedMarkdownText, formatGeneratedMarkdownListItem, } from "./markdown-hygiene.ts"; import type { SemanticBlock, SemanticPageModel } from "./models.ts"; type SectionKind = "summary" | "business-context" | "data-apis" | "user-flows" | "constraints"; function clean(page: SemanticPageModel, text: string | undefined): string { const baseUrl = (() => { try { return new URL(page.url).origin; } catch { return undefined; } })(); return canonicalizeGeneratedMarkdownText(text, { baseUrl, defaultLanguage: page.defaultLanguage, }); } function blockHasContent(block: SemanticBlock): boolean { return Boolean( block.heading || block.summary || block.body || block.facts?.length || block.items?.length, ); } function classifyBlock(block: SemanticBlock): Exclude { const heading = block.heading.toLowerCase(); const blockType = (block.blockType ?? "").toLowerCase(); const constraintTokens = [ "legal", "recht", "datenschutz", "privacy", "impressum", "terms", "conditions", "constraints", "pflicht", "liability", "haftung", ]; if (constraintTokens.some((token) => heading.includes(token) || blockType.includes(token))) { return "constraints"; } const flowTokens = [ "flow", "how it works", "so funktioniert", "steps", "schritte", "process", "ablauf", "cta", "call-to-action", ]; if ( flowTokens.some((token) => heading.includes(token) || blockType.includes(token)) || blockType.includes("hero-decision-card") ) { return "user-flows"; } if (block.facts?.length || block.items?.length) { return "data-apis"; } return "business-context"; } function extractSummaryText(page: SemanticPageModel): string | undefined { if (page.lead) return page.lead; if (page.description) return page.description; for (const block of page.blocks) { if (block.summary) return block.summary; if (block.body) return block.body; } return undefined; } function renderBlock(page: SemanticPageModel, block: SemanticBlock, depth = 3): string[] { const lines: string[] = []; const prefix = "#".repeat(depth); if (block.heading) { lines.push(`${prefix} ${block.heading}`); } if (block.summary) { lines.push(""); lines.push(clean(page, block.summary)); } if (block.body) { lines.push(""); lines.push(clean(page, block.body)); } if (block.facts?.length) { lines.push(""); for (const fact of block.facts) { lines.push(...formatGeneratedMarkdownListItem(clean(page, fact))); } } if (block.items?.length) { lines.push(""); for (const item of block.items) { lines.push(`${"#".repeat(depth + 1)} ${item.title}`); if (item.description) { lines.push(""); lines.push(clean(page, item.description)); } lines.push(""); } } return lines; } function renderSection( page: SemanticPageModel, heading: string, blocks: SemanticBlock[], ): string[] { const lines: string[] = ["", `## ${heading}`, ""]; for (const block of blocks) { lines.push(...renderBlock(page, block)); } return lines; } export function buildPageMarkdown(page: SemanticPageModel): string { const lines: string[] = []; lines.push(`# ${page.title}`); lines.push(""); // RFC-0377: Summary section is required. Fallback chain: lead → description → first block summary → first block body. const summaryText = extractSummaryText(page); if (summaryText) { lines.push("## Summary"); lines.push(""); lines.push(clean(page, summaryText)); } // RFC-0320: the old relative `Source: /path` footer is retired. // Provenance now lives in YAML frontmatter added by the generator. // RFC-0377: classify blocks into standardized sections. const businessContext: SemanticBlock[] = []; const dataApis: SemanticBlock[] = []; const userFlows: SemanticBlock[] = []; const constraints: SemanticBlock[] = []; for (const block of page.blocks) { if (!blockHasContent(block)) continue; const section = classifyBlock(block); if (section === "business-context") businessContext.push(block); if (section === "data-apis") dataApis.push(block); if (section === "user-flows") userFlows.push(block); if (section === "constraints") constraints.push(block); } lines.push(...renderSection(page, "Business context", businessContext)); lines.push(...renderSection(page, "Data / APIs", dataApis)); lines.push(...renderSection(page, "User flows", userFlows)); lines.push(...renderSection(page, "Constraints", constraints)); if (page.people?.length) { lines.push(""); lines.push("## People"); for (const person of page.people) { lines.push(""); lines.push(`### ${person.name}`); if (person.role) { lines.push(`**${person.role}**`); } if (person.affiliations?.length) { lines.push(`*${person.affiliations.join(", ")}*`); } if (person.description) { lines.push(""); lines.push(clean(page, person.description)); } } lines.push(""); } if (page.initiatives?.length) { lines.push(""); lines.push("## Initiatives"); for (const initiative of page.initiatives) { const facts = initiative.facts?.length ? ` (${initiative.facts.join("; ")})` : ""; lines.push(`- ${initiative.name}: ${initiative.summary}${facts}`); } lines.push(""); } if (page.faqEntries?.length) { lines.push(""); lines.push("## FAQ"); for (const entry of page.faqEntries) { lines.push(""); lines.push(`### ${entry.question}`); lines.push(""); lines.push(clean(page, entry.answer)); } lines.push(""); } return `${lines .join("\n") .replace(/\n{3,}/g, "\n\n") .trim()}\n`; }