syntaxai/tdd.md · main · src / b51_render_sxdoc.ts
// 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 `<p>${renderInline(block.c)}</p>`;
case "h":
return `<h${block.level}>${renderInline(block.c)}</h${block.level}>`;
case "ul":
case "ol": {
const items = block.items
.map((blocks) => `<li>${blocks.map(renderBlock).join("")}</li>`)
.join("");
return `<${block.t}>${items}</${block.t}>`;
}
case "li":
return `<li>${block.c.map(renderBlock).join("")}</li>`;
case "quote":
return `<blockquote>${block.c.map(renderBlock).join("")}</blockquote>`;
case "code":
return renderCodeBlock(block);
case "img":
return renderImg(block);
case "hr":
return `<hr>`;
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 `<pre><code${langClass}>${escText(block.src)}</code></pre>`;
};
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 = `<img ${attrs.join(" ")}>`;
if (block.caption) {
return `<figure>${img}<figcaption>${escText(block.caption)}</figcaption></figure>`;
}
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<SxMark, string> = {
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 `<a href="${escAttr(inline.href)}">${renderInline(inline.c)}</a>`;
}
// Newline runs render as <br>. Marks on a <br> are meaningless so we
// drop them — the parser already emits them on the next text run.
if (inline.v === "\n") return "<br>";
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, "<")
.replace(/>/g, ">");
const escAttr = (s: string): string =>
s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);