// 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}${block.t}>`;
}
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}${MARK_TAG[m]}>`;
}
}
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, """);