/* Shared utilities for semantic page builders: slug generation, markdown answer block extraction, and conversion helpers. Do not contain page-specific logic or component content loading. Do not interact with astro:content — use astro/semantic-page.ts for that. RFC-0372: toSemanticAnswerBlocks now returns SemanticBlock[] with blockType: "prose". RFC-0915: replaced slugify import from extract.ts with slugId from canonical slug module. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ import type { SemanticBlock } from "./models.ts"; import { slugId } from "../slug/index.ts"; /** * Extracts structured answer blocks from markdown body text. * Looks for h2 headings (## ...) and captures content until the next h2 or end of document. */ export function extractAnswerBlocksFromMarkdown( bodyText: string, ): Array<{ heading: string; content: string }> { const blocks: Array<{ heading: string; content: string }> = []; const lines = bodyText.split("\n"); let currentHeading = ""; let currentContent: string[] = []; for (const line of lines) { const h2Match = line.match(/^##\s+(.+)$/); if (h2Match) { if (currentHeading && currentContent.length > 0) { blocks.push({ heading: currentHeading, content: currentContent.join("\n").trim(), }); } currentHeading = h2Match[1]; currentContent = []; } else if (currentHeading) { currentContent.push(line); } } if (currentHeading && currentContent.length > 0) { blocks.push({ heading: currentHeading, content: currentContent.join("\n").trim(), }); } return blocks; } /** * Converts structured answer blocks back to markdown format. */ export function blocksToMarkdown(blocks: Array<{ heading: string; content: string }>): string { return blocks .map((block) => `## ${block.heading}\n\n${block.content}`) .join("\n\n") .trim(); } /** * Generates a page entry ID from a URL path. * Removes language prefix and .html extension, then converts to kebab-case. */ export function toPageEntryId(url: URL): string { const pathname = url.pathname; const withoutLang = pathname.replace(/^\/[a-z]{2}\//, "/"); const clean = withoutLang.replace(/^\//, "").replace(/\.html$/, ""); return clean.replace(/\//g, "-") || "index"; } /** * RFC-0372: Transforms markdown answer blocks to SemanticBlock[] with blockType: "prose". * Adds id, summary, and facts fields according to SemanticBlock type. */ export function toSemanticAnswerBlocks( blocks: Array<{ heading: string; content: string }>, ): SemanticBlock[] { return blocks.map((block) => { // A GFM table or a section with more than one paragraph carries structure that a flat // one-line-per-fact split would destroy (table rows becoming stray bullets, an unrelated // closing paragraph merging into the fact list). Preserve that content verbatim as the // block summary instead of fragmenting it. const hasTable = /^\s*\|/m.test(block.content); const hasMultipleParagraphs = /\n[ \t]*\n/.test(block.content.trim()); if (hasTable || hasMultipleParagraphs) { return { id: slugId(block.heading), blockType: "prose", heading: block.heading, summary: block.content.trim(), facts: undefined, }; } const lines = block.content.split("\n").filter((line) => line.trim()); const firstLine = lines[0] || ""; const remainingLines = lines.slice(1); const isSummary = !firstLine.startsWith("-") && !firstLine.startsWith("*") && !firstLine.startsWith("#"); return { id: slugId(block.heading), blockType: "prose", heading: block.heading, summary: isSummary ? firstLine : undefined, facts: isSummary ? remainingLines : lines, }; }); }