");
+ expect(doc.blocks).toHaveLength(1);
+ expect(doc.blocks[0]).toEqual({ t: "p", c: [{ t: "text", v: "real" }] });
+});
diff --git a/src/c31_sxdoc_parse.ts b/src/c31_sxdoc_parse.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1642369e48aa87a62ec29be8b341dad94dc720ff
--- /dev/null
+++ b/src/c31_sxdoc_parse.ts
@@ -0,0 +1,327 @@
+// c31 — HTML → SxDocument parser.
+//
+// SAMA placement: c31 because this is a parser for external input —
+// Modeled.md is explicit: "every external input has a parser in a c31_*
+// model — types and parse-functions colocated". HTML strings reach this
+// file from the editor's save POST, from the markdown-import script, and
+// from the AI-edit response — all "outside the process" → c31.
+//
+// Why a typed tree and not HTML strings: see c31_sxdoc.ts header.
+//
+// Why node-html-parser and not Bun's HTMLRewriter: we need a tree we can
+// recurse over, not a streaming filter. The dep is pure-logic (no I/O,
+// no fs, no spawn) so it doesn't push the file into c14 territory.
+
+import { parse, type HTMLElement, type Node, NodeType } from "node-html-parser";
+import type { SxDocument, SxBlock, SxInline, SxMark } from "./c31_sxdoc.ts";
+import { SX_DOC_VERSION } from "./c31_sxdoc.ts";
+
+const SHORTCODE_RE = /\[\[sx:([a-z][a-z0-9-]*)((?:\s+[a-z0-9_-]+=(?:"[^"]*"|[^\s"\]]+))*)\s*\]\]/g;
+const SHORTCODE_ARG_RE = /([a-z0-9_-]+)=(?:"([^"]*)"|([^\s"\]]+))/g;
+
+const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
+
+// Block-level tags — used by parseListItem to know where to stop
+// collecting inlines and recurse instead. Keep in sync with the
+// pushBlocksFromNode dispatcher above.
+const BLOCK_TAGS = new Set([
+ "p", "h1", "h2", "h3", "h4", "h5", "h6",
+ "ul", "ol", "blockquote", "pre",
+ "img", "figure", "hr",
+ "div", "section", "article", "table",
+]);
+
+const MARK_FOR_TAG: Record = {
+ b: "b", strong: "b",
+ i: "i", em: "i",
+ u: "u",
+ s: "s", strike: "s", del: "s",
+ code: "c",
+};
+
+export const htmlToSx = (html: string): SxDocument => {
+ // Wrap in so we always have a single parent to walk childNodes
+ // of, regardless of whether the input has its own wrapper element.
+ const root = parse(`${html}`, {
+ blockTextElements: { script: false, style: false },
+ });
+ const rootEl = root.firstChild as HTMLElement;
+ const blocks: SxBlock[] = [];
+ for (const node of rootEl.childNodes) {
+ pushBlocksFromNode(node, blocks);
+ }
+ return { v: SX_DOC_VERSION, blocks };
+};
+
+// ─── block-level dispatch ────────────────────────────────────────────────
+
+const pushBlocksFromNode = (node: Node, out: SxBlock[]): void => {
+ if (node.nodeType === NodeType.TEXT_NODE) {
+ const text = (node.text ?? "").trim();
+ if (text) out.push(...textWithShortcodesToBlocks(text, []));
+ return;
+ }
+ if (node.nodeType !== NodeType.ELEMENT_NODE) return;
+
+ const el = node as HTMLElement;
+ const tag = el.tagName?.toLowerCase();
+ if (!tag) return;
+
+ // Comments / processing-instructions surface as element nodes with a
+ // tagName starting with "!" — drop them, they're not content.
+ if (tag === "!" || tag === "comment") return;
+
+ if (tag === "p") {
+ const inlines = parseInline(el.childNodes, []);
+ if (inlines.length === 0) return;
+ out.push(...splitShortcodesFromParagraph(inlines));
+ return;
+ }
+
+ if (HEADING_TAGS.has(tag)) {
+ const level = parseInt(tag.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6;
+ out.push({ t: "h", level, c: parseInline(el.childNodes, []) });
+ return;
+ }
+
+ if (tag === "ul" || tag === "ol") { out.push(parseList(el, tag)); return; }
+ if (tag === "blockquote") { out.push(parseQuote(el)); return; }
+ if (tag === "pre") { out.push(parseCodeBlock(el)); return; }
+ if (tag === "img") {
+ const img = parseImg(el);
+ if (img) out.push(img);
+ return;
+ }
+ if (tag === "figure") { out.push(parseFigure(el)); return; }
+ if (tag === "hr") { out.push({ t: "hr" }); return; }
+
+ if (tag === "div" || tag === "section" || tag === "article") {
+ for (const child of el.childNodes) pushBlocksFromNode(child, out);
+ return;
+ }
+
+ // Anything else → escape hatch so round-tripping stays lossless.
+ out.push({ t: "html", src: el.outerHTML });
+};
+
+// ─── per-block parsers ───────────────────────────────────────────────────
+
+const parseList = (el: HTMLElement, tag: "ul" | "ol"): SxBlock => {
+ const items: SxBlock[][] = [];
+ for (const child of el.childNodes) {
+ if (child.nodeType !== NodeType.ELEMENT_NODE) continue;
+ const childEl = child as HTMLElement;
+ if (childEl.tagName?.toLowerCase() !== "li") continue;
+ const itemBlocks = parseListItem(childEl);
+ if (itemBlocks.length > 0) items.push(itemBlocks);
+ }
+ return { t: tag, items };
+};
+
+// Walk an
's children in source-order. Inline runs collect into
+// paragraphs; block-level children (nested ul/ol/blockquote/pre/…)
+// flush the current inline buffer and recurse as their own block.
+// Without this split, parseInline would walk into nested
and the
+// inner text would leak into the outer paragraph.
+const parseListItem = (li: HTMLElement): SxBlock[] => {
+ const result: SxBlock[] = [];
+ let inlineBuf: Node[] = [];
+ const flushInlines = (): void => {
+ if (inlineBuf.length === 0) return;
+ const inlines = parseInline(inlineBuf, []);
+ if (inlines.length > 0) result.push({ t: "p", c: inlines });
+ inlineBuf = [];
+ };
+ for (const node of li.childNodes) {
+ if (node.nodeType === NodeType.ELEMENT_NODE) {
+ const t = (node as HTMLElement).tagName?.toLowerCase();
+ if (t && BLOCK_TAGS.has(t)) {
+ flushInlines();
+ pushBlocksFromNode(node, result);
+ continue;
+ }
+ }
+ inlineBuf.push(node);
+ }
+ flushInlines();
+ return result;
+};
+
+const parseQuote = (el: HTMLElement): SxBlock => {
+ const inner: SxBlock[] = [];
+ for (const child of el.childNodes) pushBlocksFromNode(child, inner);
+ if (inner.length === 0) {
+ const inlines = parseInline(el.childNodes, []);
+ if (inlines.length > 0) inner.push({ t: "p", c: inlines });
+ }
+ return { t: "quote", c: inner };
+};
+
+const parseCodeBlock = (el: HTMLElement): SxBlock => {
+ // Canonical shape:
…
.
+ // Loose
text
also supported.
+ const codeChild = el.querySelector("code");
+ const inner = codeChild ?? el;
+ const lang = parseLangFromClass(inner.getAttribute("class") ?? "");
+ return { t: "code", lang, src: decodeEntities(inner.innerHTML) };
+};
+
+const parseImg = (el: HTMLElement): SxBlock | null => {
+ const src = el.getAttribute("src") ?? "";
+ if (!src) return null;
+ const block: { t: "img"; src: string; alt?: string; w?: number; h?: number } = { t: "img", src };
+ const alt = el.getAttribute("alt");
+ if (alt) block.alt = alt;
+ const w = numAttr(el, "width"); if (w !== undefined) block.w = w;
+ const h = numAttr(el, "height"); if (h !== undefined) block.h = h;
+ return block as SxBlock;
+};
+
+const parseFigure = (el: HTMLElement): SxBlock => {
+ const img = el.querySelector("img");
+ const caption = el.querySelector("figcaption");
+ if (img) {
+ const src = img.getAttribute("src") ?? "";
+ if (src) {
+ const block: { t: "img"; src: string; alt?: string; caption?: string; w?: number; h?: number } = { t: "img", src };
+ const alt = img.getAttribute("alt"); if (alt) block.alt = alt;
+ if (caption) block.caption = caption.text;
+ const w = numAttr(img, "width"); if (w !== undefined) block.w = w;
+ const h = numAttr(img, "height"); if (h !== undefined) block.h = h;
+ return block as SxBlock;
+ }
+ }
+ return { t: "html", src: el.outerHTML };
+};
+
+// ─── inline parsing ──────────────────────────────────────────────────────
+
+const parseInline = (nodes: Node[] | undefined, marks: SxMark[]): SxInline[] => {
+ if (!nodes) return [];
+ const out: SxInline[] = [];
+ for (const node of nodes) {
+ if (node.nodeType === NodeType.TEXT_NODE) {
+ const v = decodeEntities(node.text ?? "");
+ if (v.length > 0) {
+ out.push({ t: "text", v, ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
+ }
+ continue;
+ }
+ if (node.nodeType !== NodeType.ELEMENT_NODE) continue;
+ const el = node as HTMLElement;
+ const tag = el.tagName?.toLowerCase();
+ if (!tag) continue;
+
+ if (tag === "br") {
+ out.push({ t: "text", v: "\n", ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
+ continue;
+ }
+
+ if (tag === "a") {
+ const href = el.getAttribute("href") ?? "";
+ out.push({ t: "a", href, c: parseInline(el.childNodes, marks) });
+ continue;
+ }
+
+ const mark = MARK_FOR_TAG[tag];
+ if (mark) {
+ out.push(...parseInline(el.childNodes, [...marks, mark]));
+ continue;
+ }
+
+ // , , etc. — strip wrapper, keep contents.
+ out.push(...parseInline(el.childNodes, marks));
+ }
+ return out;
+};
+
+const dedupeMarks = (marks: SxMark[]): SxMark[] => {
+ const seen = new Set();
+ const out: SxMark[] = [];
+ for (const m of marks) if (!seen.has(m)) { seen.add(m); out.push(m); }
+ return out;
+};
+
+// ─── shortcode lifting ──────────────────────────────────────────────────
+
+// When a
contains [[sx:foo]] tokens mixed with text, split it into
+// (paragraph)(shortcode)(paragraph) blocks so the document is queryable
+// per-shortcode rather than per-paragraph-with-substring.
+const splitShortcodesFromParagraph = (inlines: SxInline[]): SxBlock[] => {
+ const out: SxBlock[] = [];
+ let buf: SxInline[] = [];
+ const flush = (): void => {
+ if (buf.length > 0 && buf.some((i) => !(i.t === "text" && i.v.trim() === ""))) {
+ out.push({ t: "p", c: buf });
+ }
+ buf = [];
+ };
+ for (const i of inlines) {
+ if (i.t !== "text" || !SHORTCODE_RE.test(i.v)) {
+ buf.push(i);
+ continue;
+ }
+ SHORTCODE_RE.lastIndex = 0;
+ const blocks = textWithShortcodesToBlocks(i.v, i.m ?? []);
+ for (const b of blocks) {
+ if (b.t === "shortcode") {
+ flush();
+ out.push(b);
+ } else if (b.t === "p") {
+ for (const inner of b.c) buf.push(inner);
+ }
+ }
+ }
+ flush();
+ return out;
+};
+
+const textWithShortcodesToBlocks = (text: string, marks: SxMark[]): SxBlock[] => {
+ const out: SxBlock[] = [];
+ let last = 0;
+ SHORTCODE_RE.lastIndex = 0;
+ for (const m of text.matchAll(SHORTCODE_RE)) {
+ const idx = m.index ?? 0;
+ if (idx > last) {
+ const before = text.slice(last, idx);
+ if (before.trim() !== "") {
+ out.push({ t: "p", c: [{ t: "text", v: before, ...(marks.length ? { m: marks } : {}) }] });
+ }
+ }
+ const name = m[1]!;
+ const args: Record = {};
+ for (const a of (m[2] ?? "").matchAll(SHORTCODE_ARG_RE)) {
+ args[a[1]!] = a[2] ?? a[3] ?? "";
+ }
+ out.push({ t: "shortcode", name, args });
+ last = idx + m[0].length;
+ }
+ const tail = text.slice(last);
+ if (tail.trim() !== "") {
+ out.push({ t: "p", c: [{ t: "text", v: tail, ...(marks.length ? { m: marks } : {}) }] });
+ }
+ return out;
+};
+
+// ─── small helpers ───────────────────────────────────────────────────────
+
+const parseLangFromClass = (cls: string): string => {
+ const m = cls.match(/(?:^|\s)language-([\w-]+)/);
+ return m?.[1] ?? "";
+};
+
+const numAttr = (el: HTMLElement, name: string): number | undefined => {
+ const v = el.getAttribute(name);
+ if (!v) return undefined;
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) ? n : undefined;
+};
+
+const decodeEntities = (s: string): string =>
+ s
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, " ");
diff --git a/src/c51_render_admin.ts b/src/c51_render_admin.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e2cd79ebd675a577abac15314a792f8dc3a7528f
--- /dev/null
+++ b/src/c51_render_admin.ts
@@ -0,0 +1,163 @@
+// c51 — UI: shells for the admin sxdoc editor.
+//
+// Three views: list (GET /admin), edit form (GET /admin/edit/...), and
+// auth walls for non-admin viewers. Body builders return HTML strings;
+// the c21 handler wraps them in htmlResponse.
+//
+// Fase 2a: raw-HTML textarea editor. Fase 2b adds the block editor on
+// top — the textarea stays as the underlying form field, and the
+// block-editor JS will hydrate it into a typed UI. So the form shape
+// here is forward-compatible with the block editor that lands next.
+
+import { escape, renderPage } from "./c51_render_layout.ts";
+import type { SxDocumentSummary } from "./c13_database.ts";
+import type { SxDocument } from "./c31_sxdoc.ts";
+import { sxToHtml } from "./c51_render_sxdoc.ts";
+
+export const renderAdminList = async (documents: SxDocumentSummary[]): Promise => {
+ const pages = documents.filter((d) => d.type === "page");
+ const posts = documents.filter((d) => d.type === "post");
+ const body = `# admin
+
+[+ new document](/admin/new)
+
+## pages (${pages.length})
+
+${pages.length === 0 ? "_no pages yet — migrate or create one._" : adminTable(pages)}
+
+## posts (${posts.length})
+
+${posts.length === 0 ? "_no posts yet — migrate or create one._" : adminTable(posts)}
+
+[← back to home](/)
+`;
+ return renderPage({
+ title: "admin — tdd.md",
+ bodyMarkdown: body,
+ noindex: true,
+ });
+};
+
+const adminTable = (rows: SxDocumentSummary[]): string => {
+ const lines = rows.map((r) =>
+ `| [${escape(r.title)}](/admin/edit/${r.type}/${r.slug}) | \`${escape(r.slug)}\` | ${r.status} | ${r.primaryTag ?? "—"} |`,
+ );
+ return `| title | slug | status | tag |
+|---|---|---|---|
+${lines.join("\n")}`;
+};
+
+export interface AdminEditViewModel {
+ mode: "new" | "edit";
+ title: string;
+ slug: string;
+ type: "page" | "post";
+ // SxDocument is the canonical input — server projects it to HTML for
+ // the textarea and embeds the JSON for the client editor's hydration.
+ doc: SxDocument;
+ status: "published" | "draft";
+ primaryTag: string | null;
+ error?: string;
+}
+
+// Embed JSON safely inside " in user content can't break out of the
+// script tag. JSON.parse handles "<" identically to "<".
+const safeJsonForScript = (value: unknown): string =>
+ JSON.stringify(value).replace(/ => {
+ const action = vm.mode === "new" ? "/admin/new" : `/admin/edit/${vm.type}/${vm.slug}`;
+ const heading = vm.mode === "new" ? "new document" : "edit document";
+ const submitLabel = vm.mode === "new" ? "Create" : "Save";
+ const html = sxToHtml(vm.doc);
+ const docJson = safeJsonForScript(vm.doc);
+
+ const errorBlock = vm.error
+ ? `
${escape(vm.error)}
`
+ : "";
+
+ // Delete button uses a separate form to avoid posting the entire edit
+ // payload to the delete endpoint. confirm() catches accidental clicks.
+ const deleteForm = vm.mode === "edit"
+ ? ``
+ : "";
+
+ const form = `
+${deleteForm}
+
+`;
+
+ const title = vm.mode === "new"
+ ? "new — admin — tdd.md"
+ : `${vm.title} — admin — tdd.md`;
+ return renderPage({
+ title,
+ bodyHtml: `
${heading}
${form}`,
+ noindex: true,
+ });
+};
+
+export const renderAdminLoginWall = async (): Promise =>
+ renderPage({
+ title: "admin — sign in — tdd.md",
+ bodyMarkdown: `# admin
+
+> Sign in with GitHub to access the admin UI.
+
+[ sign in with github → ](/auth/github/start)
+
+[← back to home](/)`,
+ noindex: true,
+ });
+
+export const renderAdminNonAdminWall = async (viewer: string): Promise =>
+ renderPage({
+ title: "admin — not authorized — tdd.md",
+ bodyMarkdown: `# not authorized
+
+> You are signed in as \`${escape(viewer)}\`, but the admin UI is reserved for the site admin.
+
+[← back to home](/) · [your agent](/agents/${escape(viewer)})`,
+ noindex: true,
+ });
diff --git a/src/c51_render_commit.ts b/src/c51_render_commit.ts
index f9b2e7ac6b4f24efd63310c45852aabe3839f15d..2a6ff660e903a2b64660c45586244e0f81a6fe56 100644
--- a/src/c51_render_commit.ts
+++ b/src/c51_render_commit.ts
@@ -123,5 +123,6 @@ export const renderCommitView = async (params: {
description: `Commit ${shortSha(detail.sha)} on ${owner}/${repo}: ${subject}`,
noindex: true,
bodyClass: "commit-body-page",
+ hideNav: true,
});
};
diff --git a/src/c51_render_docs_layout.ts b/src/c51_render_docs_layout.ts
index a70372312f2fcc5caeaafe838b47e0c05b78e6d1..88a112b05b47e63e37b202883246e412392ba06d 100644
--- a/src/c51_render_docs_layout.ts
+++ b/src/c51_render_docs_layout.ts
@@ -1,16 +1,13 @@
// c51 (docs-layout) — UI: GitBook-style chrome around the existing
-// renderPage. Wraps content with a left sidebar (sections from
-// SITE_NAV), a right "on this page" anchor rail (h2/h3 from the
-// rendered body), an edit-on-GitHub link at the top of content, and
-// a prev/next navigator at the bottom. Per SAMA: imports c31 (data),
-// c32 (logic), and c51_render_layout (chrome). No I/O of its own.
+// renderPage. Wraps content with a right "on this page" anchor rail
+// (h2/h3 from the rendered body), an edit-on-GitHub link at the top
+// of content, and a prev/next navigator at the bottom. Per SAMA:
+// imports c31 (data), c32 (logic), and c51_render_layout (chrome).
+// No I/O of its own.
import { marked } from "marked";
import {
- SITE_NAV,
resolveDocsLocation,
- type DocsNavLink,
- type DocsNavSection,
type ResolvedDocsLocation,
} from "./c31_docs_nav.ts";
import { extractAnchors, type Anchor } from "./c32_anchor_extract.ts";
@@ -22,7 +19,7 @@ import {
export interface DocsPageOptions extends Omit {
// The route path the user is on, e.g. "/sama/sorted". Used to
- // highlight the active sidebar entry and compute prev/next.
+ // compute prev/next.
pathForDocs: string;
// Optional override of which file the "edit on GitHub" link
// targets, when the body isn't a content//.md.
@@ -30,27 +27,6 @@ export interface DocsPageOptions extends Omit {
editPathOverride?: string | null;
}
-const sidebarLink = (link: DocsNavLink, current: string): string => {
- const cls = link.href === current ? "docs-side-link docs-side-link-active" : "docs-side-link";
- return `
-${sidebar}
${editLink}
${enriched}
diff --git a/src/c51_render_layout.ts b/src/c51_render_layout.ts
index 0bfdad3ef54773a8f4c7eeba8c3cba44b392671d..705117b91c8bfcebd7079f0c336006fb3aa7b984 100644
--- a/src/c51_render_layout.ts
+++ b/src/c51_render_layout.ts
@@ -27,9 +27,14 @@ export interface PageOptions {
noindex?: boolean;
jsonLd?: Record;
bodyClass?: string;
+ // Skip the top nav bar (tdd.md · games · guides · sama · blog · agents
+ // · leaderboard). Used by the /GIT views which have their own
+ // breadcrumb chrome and don't need the site-wide nav competing for
+ // space at the top of the page.
+ hideNav?: boolean;
}
-const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts.";
+const SITE_DESCRIPTION = "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic. Four pillars, one CI verifier.";
export const escape = (s: string): string =>
s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
@@ -75,7 +80,7 @@ ${robots}
${jsonLd}
-${nav(opts.active)}
+${opts.hideNav ? "" : nav(opts.active)}
${body}
diff --git a/src/c51_render_repo.ts b/src/c51_render_repo.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d13f5fc2ca0fd821337769575eaf579a347b3946
--- /dev/null
+++ b/src/c51_render_repo.ts
@@ -0,0 +1,154 @@
+// c51 — UI: tree listing + blob viewer for the local bare repo.
+// Visited at /GIT/:owner/:repo/tree/:ref/ and /blob/:ref/.
+// Renders through tdd.md's chrome (renderPage with bodyHtml). Markdown
+// blobs get parsed via marked; everything else is rendered as
+// preformatted source.
+
+import { marked } from "marked";
+import { renderPage, escape } from "./c51_render_layout.ts";
+import type { TreeEntry } from "./c14_git.ts";
+
+const shortSha = (sha: string): string => sha.slice(0, 7);
+
+// Build a breadcrumb: "owner/repo · main · content/blog" with each
+// segment a clickable link to /GIT/.../tree//.
+const renderBreadcrumb = (params: {
+ owner: string;
+ repo: string;
+ ref: string;
+ path: string;
+ asBlob?: boolean;
+}): string => {
+ const { owner, repo, ref, path, asBlob } = params;
+ const repoLink = `${escape(owner)}/${escape(repo)}`;
+ const refLink = `${escape(ref)}`;
+ if (path === "") return `
${repoLink} · ${refLink}
`;
+
+ const segments = path.split("/");
+ const lastIdx = segments.length - 1;
+ const links = segments
+ .map((seg, i) => {
+ const so_far = segments.slice(0, i + 1).join("/");
+ // For blob view, the last segment is the file itself — no link.
+ // For tree view, every segment links to the tree at that depth.
+ const isLastFile = asBlob && i === lastIdx;
+ if (isLastFile) return `${escape(seg)}`;
+ return `${escape(seg)}`;
+ })
+ .join(" / ");
+ return `