syntaxai/tdd.md · main · src / b51_render_sxdoc.ts

b51_render_sxdoc.ts 133 lines · 4483 bytes raw
// 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, "&quot;")}"`)
    .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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");

const escAttr = (s: string): string =>
  s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");