// c51 — SxDocument → HTML renderer. // // SAMA placement: c51 because this file produces HTML — Architecture.md // picking-order regel 4: "Does it produce HTML? Yes → c51". Sub-page // renderer (fragment-level) used by c51_render_layout / page builders to // embed sxdoc content inside larger templates. // // Pure deterministic transform — no DOM, no I/O, no time, no randomness. import type { SxDocument, SxBlock, SxInline, SxMark, SxShortcode, } from "./a31_sxdoc.ts"; export const sxToHtml = (doc: SxDocument): string => doc.blocks.map(renderBlock).join("\n"); // ─── block-level ───────────────────────────────────────────────────────── const renderBlock = (block: SxBlock): string => { switch (block.t) { case "p": return `

${renderInline(block.c)}

`; case "h": return `${renderInline(block.c)}`; case "ul": case "ol": { const items = block.items .map((blocks) => `
  • ${blocks.map(renderBlock).join("")}
  • `) .join(""); return `<${block.t}>${items}`; } case "li": return `
  • ${block.c.map(renderBlock).join("")}
  • `; case "quote": return `
    ${block.c.map(renderBlock).join("")}
    `; case "code": return renderCodeBlock(block); case "img": return renderImg(block); case "hr": return `
    `; case "html": // Raw passthrough — trust whoever inserted it. The parser only // emits SxHtml for round-trip-preservation of unknown HTML. return block.src; case "shortcode": return renderShortcode(block); } }; const renderCodeBlock = (block: { lang?: string; src: string }): string => { const langClass = block.lang ? ` class="language-${escAttr(block.lang)}"` : ""; return `
    ${escText(block.src)}
    `; }; const renderImg = (block: { src: string; alt?: string; caption?: string; w?: number; h?: number }): string => { const attrs = [`src="${escAttr(block.src)}"`]; if (block.alt !== undefined) attrs.push(`alt="${escAttr(block.alt)}"`); if (block.w !== undefined) attrs.push(`width="${block.w}"`); if (block.h !== undefined) attrs.push(`height="${block.h}"`); const img = ``; if (block.caption) { return `
    ${img}
    ${escText(block.caption)}
    `; } return img; }; const renderShortcode = (block: SxShortcode): string => { const args = Object.entries(block.args) .map(([k, v]) => `${k}="${v.replace(/"/g, """)}"`) .join(" "); return args ? `[[sx:${block.name} ${args}]]` : `[[sx:${block.name}]]`; }; // ─── inline ────────────────────────────────────────────────────────────── // Stable mark order — matters so round-tripping is deterministic. The // parser dedupes marks per text-run; renderer wraps them in this fixed // order regardless of input ordering. const MARK_ORDER: SxMark[] = ["b", "i", "u", "s", "c"]; const MARK_TAG: Record = { b: "strong", i: "em", u: "u", s: "s", c: "code", }; const renderInline = (inlines: SxInline[]): string => inlines.map(renderOneInline).join(""); const renderOneInline = (inline: SxInline): string => { if (inline.t === "a") { return `${renderInline(inline.c)}`; } // Newline runs render as
    . Marks on a
    are meaningless so we // drop them — the parser already emits them on the next text run. if (inline.v === "\n") return "
    "; let body = escText(inline.v); if (inline.m && inline.m.length > 0) { // MARK_ORDER lists marks outer→inner. Wrap in reverse so the // innermost mark is applied first, leaving the outermost-listed // mark as the outermost tag. Without the reverse, the deepest tag // becomes the outermost — and a re-parse flips the mark order. const sortedMarks = MARK_ORDER.filter((m) => inline.m!.includes(m)); for (let i = sortedMarks.length - 1; i >= 0; i--) { const m = sortedMarks[i]!; body = `<${MARK_TAG[m]}>${body}`; } } return body; }; // ─── escape helpers ────────────────────────────────────────────────────── const escText = (s: string): string => s .replace(/&/g, "&") .replace(//g, ">"); const escAttr = (s: string): string => s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """);