bd8f3aa6d73724482e62f4caf6352d662b96d212
diff --git a/public/style.css b/public/style.css
index fbe05e12ade844a5388631064c964455fa593460..8cc17406a51e8beafb6d7599474f9ba7507388da 100644
--- a/public/style.css
+++ b/public/style.css
@@ -507,3 +507,195 @@ main.md table.test-stability td.test-stab-num {
margin: 0 0 1.2rem;
}
.project-form-error strong { color: var(--red); }
+
+/* -----------------------------------------------------------------
+ Docs layout — GitBook-style sidebar + content + on-this-page rail.
+ Used by /sama/*, /guides/*, /blog/* via renderDocsPage. Mobile
+ stacks vertically; sidebar collapses behind a details/summary on
+ narrow viewports.
+----------------------------------------------------------------- */
+
+.docs-body main.md {
+ max-width: none;
+ padding: 0;
+}
+
+.docs-layout {
+ display: grid;
+ grid-template-columns: 240px minmax(0, 1fr) 220px;
+ gap: 2rem;
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 1rem 1.5rem 4rem;
+ align-items: start;
+}
+
+.docs-sidebar,
+.docs-rail {
+ position: sticky;
+ top: 1rem;
+ font-size: 0.88rem;
+ align-self: start;
+ max-height: calc(100vh - 2rem);
+ overflow-y: auto;
+}
+
+.docs-sidebar { padding-right: 0.5rem; }
+
+.docs-side-section { margin: 0 0 1.5rem; }
+.docs-side-title {
+ margin: 0 0 0.4rem;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+}
+.docs-side-title a {
+ color: inherit;
+ text-decoration: none;
+}
+.docs-side-title a:hover { color: var(--fg); }
+
+.docs-side-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
+}
+.docs-side-list li { margin: 0; }
+
+.docs-side-link {
+ display: block;
+ padding: 0.3rem 0.6rem;
+ margin-left: -1px;
+ border-left: 2px solid transparent;
+ color: var(--muted);
+ text-decoration: none;
+ line-height: 1.35;
+}
+.docs-side-link:hover {
+ color: var(--fg);
+ border-left-color: color-mix(in srgb, var(--fg) 40%, transparent);
+}
+.docs-side-link-active {
+ color: var(--accent);
+ border-left-color: var(--accent);
+ font-weight: 600;
+}
+
+.docs-content {
+ min-width: 0;
+ font-size: 1rem;
+ line-height: 1.65;
+}
+.docs-content > h1:first-child { margin-top: 0; }
+.docs-content h2,
+.docs-content h3 {
+ scroll-margin-top: 1rem;
+ position: relative;
+}
+.docs-h-anchor {
+ position: absolute;
+ left: -1.2rem;
+ color: var(--muted);
+ text-decoration: none;
+ opacity: 0;
+ transition: opacity 0.1s;
+ font-weight: normal;
+}
+.docs-content h2:hover .docs-h-anchor,
+.docs-content h3:hover .docs-h-anchor {
+ opacity: 1;
+}
+.docs-h-anchor:hover { color: var(--accent); }
+
+.docs-edit {
+ text-align: right;
+ font-size: 0.85rem;
+ margin: 0 0 0.5rem;
+}
+.docs-edit a {
+ color: var(--muted);
+ text-decoration: none;
+}
+.docs-edit a:hover { color: var(--accent); }
+
+.docs-rail-title {
+ margin: 0 0 0.5rem;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+}
+.docs-rail-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
+}
+.docs-rail-link {
+ display: block;
+ padding: 0.25rem 0.6rem;
+ margin-left: -1px;
+ border-left: 2px solid transparent;
+ color: var(--muted);
+ text-decoration: none;
+ line-height: 1.35;
+}
+.docs-rail-link:hover {
+ color: var(--fg);
+ border-left-color: color-mix(in srgb, var(--fg) 40%, transparent);
+}
+.docs-rail-link-h3 { padding-left: 1.4rem; font-size: 0.82rem; }
+
+.docs-prev-next {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ margin: 3rem 0 0;
+ border-top: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
+ padding-top: 1.5rem;
+}
+.docs-pn-prev,
+.docs-pn-next {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.8rem 1rem;
+ border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--fg);
+ background: color-mix(in srgb, var(--muted) 6%, transparent);
+}
+.docs-pn-prev:hover,
+.docs-pn-next:hover {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+.docs-pn-prev { justify-content: flex-start; }
+.docs-pn-next { justify-content: flex-end; text-align: right; }
+.docs-pn-arrow { color: var(--muted); font-size: 1.1rem; }
+.docs-pn-label { font-weight: 500; }
+.docs-pn-spacer {}
+
+@media (max-width: 1080px) {
+ .docs-layout {
+ grid-template-columns: 220px minmax(0, 1fr);
+ }
+ .docs-rail { display: none; }
+}
+
+@media (max-width: 768px) {
+ .docs-layout {
+ grid-template-columns: 1fr;
+ }
+ .docs-sidebar {
+ position: static;
+ max-height: none;
+ border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
+ border-radius: 6px;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+ }
+}
diff --git a/src/c21_app.ts b/src/c21_app.ts
index f589718d3406ef72c993d7fc69dbcd4ad1123143..00a236b64ac5ef3c51569a4ca3febf0077138674 100644
--- a/src/c21_app.ts
+++ b/src/c21_app.ts
@@ -8,6 +8,7 @@ import {
htmlResponse,
escape,
} from "./c51_render_layout.ts";
+import { renderDocsPage } from "./c51_render_docs_layout.ts";
import {
projectsLandingMd,
projectRegisterMd,
@@ -349,12 +350,14 @@ ${rows}
[← back to tdd.md](/) · [the guides](/guides) · [the katas](/games)
`;
- const html = await renderPage({
+ const html = await renderDocsPage({
title: "Blog — tdd.md",
description: "Posts on test-driven development for AI coding agents — how to apply TDD with Claude Code, Cursor, and Aider, what we learn from the verdicts.",
bodyMarkdown: body,
ogPath: "https://tdd.md/blog",
active: "blog",
+ pathForDocs: "/blog",
+ editPathOverride: null,
});
return htmlResponse(html);
},
@@ -372,12 +375,13 @@ ${rows}
return htmlResponse(html, 404);
}
const md = await file.text();
- const html = await renderPage({
+ const html = await renderDocsPage({
title: `${entry.title} — tdd.md`,
description: entry.description,
bodyMarkdown: md,
ogPath: `https://tdd.md/blog/${slug}`,
active: "blog",
+ pathForDocs: `/blog/${slug}`,
jsonLd: {
"@context": "https://schema.org",
"@type": "BlogPosting",
@@ -605,12 +609,14 @@ ${rows}
[← play a kata](/games) · [register your agent →](/you)
`;
- const html = await renderPage({
+ const html = await renderDocsPage({
title: "TDD guides for agentic coding tools — tdd.md",
description: "Practical TDD walkthroughs for Claude Code, Cursor, Aider and other AI coding agents — keep your agent honest with red→green→refactor commits, scored by tdd.md.",
bodyMarkdown: body,
ogPath: "https://tdd.md/guides",
active: "guides",
+ pathForDocs: "/guides",
+ editPathOverride: null,
});
return htmlResponse(html);
},
@@ -628,12 +634,13 @@ ${rows}
return htmlResponse(html, 404);
}
const md = await file.text();
- const html = await renderPage({
+ const html = await renderDocsPage({
title: `${entry.title} — tdd.md`,
description: entry.description,
bodyMarkdown: md,
ogPath: `https://tdd.md/guides/${slug}`,
active: "guides",
+ pathForDocs: `/guides/${slug}`,
});
return htmlResponse(html);
},
@@ -673,12 +680,13 @@ ${rows}
> The frontmatter at the top of the file (\`name\`, \`description\`) is what your agent's loader keys off — don't edit it. [View raw markdown →](/skills/sama.md)
`;
const body = `${installNote}\n\n${stripped}\n\n---\n\n[← /sama](/sama) · [the four disciplines](/sama) · [back to tdd.md](/)\n`;
- const html = await renderPage({
+ const html = await renderDocsPage({
title: "SAMA skill — drop into your agent — tdd.md",
description: "An obra/superpowers-style SKILL.md for the SAMA file-naming convention. Save it to ~/.claude/skills/sama.md and your agent will load the layer-prefix discipline on demand.",
bodyMarkdown: body,
ogPath: "https://tdd.md/sama/skill",
active: "sama",
+ pathForDocs: "/sama/skill",
});
return htmlResponse(html);
},
@@ -706,22 +714,25 @@ Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses
`;
if (!repoArg) {
- const html = await renderPage({
+ const html = await renderDocsPage({
title: "SAMA verify — tdd.md",
description: "Paste a public GitHub repo, get the four SAMA disciplines verified mechanically: sorted (lower never imports higher), architecture (known layer prefixes), modeled (sibling tests), atomic (700-line + placeholder-test detection).",
bodyMarkdown: formMd,
ogPath: "https://tdd.md/sama/verify",
active: "sama",
+ pathForDocs: "/sama/verify",
});
return htmlResponse(html);
}
const m = /^([^\/\s]+)\/([^\/\s]+)$/.exec(repoArg);
if (!m) {
- const html = await renderPage({
+ const html = await renderDocsPage({
title: "SAMA verify · bad input — tdd.md",
description: "SAMA verify expects an owner/name repo identifier.",
bodyMarkdown: `# SAMA verify\n\n> Couldn't parse \`${repoArg}\`. Use the form: \`owner/name\`.\n\n[← back](/sama/verify)\n`,
+ pathForDocs: "/sama/verify",
+ editPathOverride: null,
ogPath: "https://tdd.md/sama/verify",
active: "sama",
noindex: true,
@@ -781,13 +792,15 @@ Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
- const html = await renderPage({
+ const html = await renderDocsPage({
title: `SAMA verify · ${owner}/${name} · error — tdd.md`,
description: `SAMA verify could not inspect ${owner}/${name}.`,
bodyMarkdown: `# SAMA verify · \`${owner}/${name}\`\n\n> Couldn't fetch the repo: ${escape(msg)}\n\nMost common causes: the repo is private, the name is wrong, or you've hit GitHub's anonymous rate limit (60/hour). [← try another repo](/sama/verify)\n`,
ogPath: `https://tdd.md/sama/verify?repo=${owner}/${name}`,
active: "sama",
noindex: true,
+ pathForDocs: "/sama/verify",
+ editPathOverride: null,
});
return htmlResponse(html, 502);
}
@@ -815,12 +828,14 @@ ${checkBlocks}
[← verify another repo](/sama/verify) · [the four SAMA disciplines →](/sama) · [SAMA skill for your agent →](/sama/skill)
`;
- const html = await renderPage({
+ const html = await renderDocsPage({
title: `SAMA verify · ${report.repoSlug} — tdd.md`,
description: `SAMA verification for ${report.repoSlug}: ${report.overallPassed ? "all four checks passed" : `${report.checks.filter((c) => !c.passed).length}/4 checks failed`}.`,
bodyMarkdown: reportMd,
ogPath: `https://tdd.md/sama/verify?repo=${report.repoSlug}`,
active: "sama",
+ pathForDocs: "/sama/verify",
+ editPathOverride: null,
});
return htmlResponse(html);
},
@@ -941,12 +956,14 @@ The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) arg
[← back to tdd.md](/) · [the blog](/blog) · [the guides](/guides)
`;
- const html = await renderPage({
+ const html = await renderDocsPage({
title: "SAMA — sorted, architecture, modeled, atomic — tdd.md",
description: "SAMA is a four-property file-naming and module convention for codebases that AI agents work in: sorted by layer prefix, architecture as a contract, models with siblings, atomic files. One page per discipline.",
bodyMarkdown: body,
ogPath: "https://tdd.md/sama",
active: "sama",
+ pathForDocs: "/sama",
+ editPathOverride: null,
});
return htmlResponse(html);
},
@@ -964,12 +981,13 @@ The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) arg
return htmlResponse(html, 404);
}
const md = await file.text();
- const html = await renderPage({
+ const html = await renderDocsPage({
title: `SAMA · ${entry.letter} — ${entry.title} — tdd.md`,
description: entry.description,
bodyMarkdown: md,
ogPath: `https://tdd.md/sama/${slug}`,
active: "sama",
+ pathForDocs: `/sama/${slug}`,
});
return htmlResponse(html);
},
diff --git a/src/c31_docs_nav.ts b/src/c31_docs_nav.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1c481bb738f1c2ed44a5de73d2213ef9ffc36c88
--- /dev/null
+++ b/src/c31_docs_nav.ts
@@ -0,0 +1,93 @@
+// c31 — model: hierarchical site-nav for the GitBook-style docs
+// chrome. Pure data: combines the existing per-section registries
+// (sama, guides, blog) into one structure the sidebar walks at
+// render-time, plus a flat per-section list the prev/next navigator
+// uses to compute neighbours.
+
+import { ALL_SAMA, type SamaDiscipline } from "./c31_sama.ts";
+import { ALL_GUIDES, type GuideEntry } from "./c31_guides.ts";
+import { ALL_POSTS, type BlogEntry } from "./c31_blog.ts";
+
+export interface DocsNavLink {
+ href: string;
+ label: string;
+ // GitHub raw-edit URL for the source markdown, when applicable.
+ // null for pages whose body is built inline in c21_app.ts.
+ editPath: string | null;
+}
+
+export interface DocsNavSection {
+ id: "sama" | "guides" | "blog";
+ title: string;
+ rootHref: string;
+ links: DocsNavLink[];
+}
+
+const samaLink = (d: SamaDiscipline): DocsNavLink => ({
+ href: `/sama/${d.slug}`,
+ label: `${d.letter} — ${d.title}`,
+ editPath: `content/sama/${d.slug}.md`,
+});
+
+const guideLink = (g: GuideEntry): DocsNavLink => ({
+ href: `/guides/${g.slug}`,
+ label: g.title,
+ editPath: `content/guides/${g.slug}.md`,
+});
+
+const blogLink = (p: BlogEntry): DocsNavLink => ({
+ href: `/blog/${p.slug}`,
+ label: p.title,
+ editPath: `content/blog/${p.slug}.md`,
+});
+
+export const SITE_NAV: DocsNavSection[] = [
+ {
+ id: "sama",
+ title: "SAMA",
+ rootHref: "/sama",
+ links: [
+ ...ALL_SAMA.map(samaLink),
+ { href: "/sama/skill", label: "SKILL.md (drop into your agent)", editPath: "content/sama/skill.md" },
+ { href: "/sama/verify", label: "verify a public repo", editPath: null },
+ ],
+ },
+ {
+ id: "guides",
+ title: "Guides",
+ rootHref: "/guides",
+ links: ALL_GUIDES.map(guideLink),
+ },
+ {
+ id: "blog",
+ title: "Blog",
+ rootHref: "/blog",
+ links: ALL_POSTS.map(blogLink),
+ },
+];
+
+// Resolve the section + position of a given path. Used by the
+// docs layout to select the right sidebar section and to compute
+// prev/next neighbours.
+export interface ResolvedDocsLocation {
+ section: DocsNavSection;
+ index: number;
+ current: DocsNavLink;
+ prev: DocsNavLink | null;
+ next: DocsNavLink | null;
+}
+
+export const resolveDocsLocation = (path: string): ResolvedDocsLocation | null => {
+ for (const section of SITE_NAV) {
+ const i = section.links.findIndex((l) => l.href === path);
+ if (i === -1) continue;
+ return {
+ section,
+ index: i,
+ current: section.links[i]!,
+ prev: i > 0 ? section.links[i - 1]! : null,
+ next: i < section.links.length - 1 ? section.links[i + 1]! : null,
+ };
+ }
+ return null;
+};
diff --git a/src/c32_anchor_extract.test.ts b/src/c32_anchor_extract.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e8d7c654b11d2a1282a119178d7bd86740df185f
--- /dev/null
+++ b/src/c32_anchor_extract.test.ts
@@ -0,0 +1,57 @@
+import { test, expect } from "bun:test";
+import { extractAnchors } from "./c32_anchor_extract.ts";
+
+test("extracts h2 with explicit id", () => {
+ const html = `
Getting started
`;
+ expect(extractAnchors(html)).toEqual([
+ { level: 2, text: "Getting started", id: "getting-started" },
+ ]);
+});
+
+test("extracts h3 with explicit id", () => {
+ const html = `Why
`;
+ expect(extractAnchors(html)).toEqual([
+ { level: 3, text: "Why", id: "why" },
+ ]);
+});
+
+test("ignores h1 and h4+", () => {
+ const html = `T
A
B
`;
+ const anchors = extractAnchors(html);
+ expect(anchors.map((a) => a.id)).toEqual(["a"]);
+});
+
+test("slugifies when id attribute is missing", () => {
+ const html = `What this number does *not* measure
`;
+ const anchors = extractAnchors(html);
+ expect(anchors[0]?.id).toBe("what-this-number-does-not-measure");
+});
+
+test("strips inline tags from text and id source", () => {
+ const html = `red: phase
`;
+ const anchors = extractAnchors(html);
+ expect(anchors[0]?.text).toBe("red: phase");
+ expect(anchors[0]?.id).toBe("red-phase");
+});
+
+test("returns multiple anchors in document order", () => {
+ const html = `One
x
Two
Three
`;
+ const anchors = extractAnchors(html);
+ expect(anchors.map((a) => `${a.level}:${a.id}`)).toEqual([
+ "2:one",
+ "3:two",
+ "2:three",
+ ]);
+});
+
+test("skips empty headings", () => {
+ const html = `Real
`;
+ expect(extractAnchors(html).length).toBe(1);
+});
+
+test("handles HTML entities in text", () => {
+ const html = `Tom & Jerry
`;
+ const anchors = extractAnchors(html);
+ expect(anchors[0]?.text).toBe("Tom & Jerry");
+ expect(anchors[0]?.id).toBe("tom-jerry");
+});
diff --git a/src/c32_anchor_extract.ts b/src/c32_anchor_extract.ts
new file mode 100644
index 0000000000000000000000000000000000000000..182e4b3e8ceab5b87fd315a848fe49a2080e6589
--- /dev/null
+++ b/src/c32_anchor_extract.ts
@@ -0,0 +1,44 @@
+// c32 — pure: parse rendered HTML and extract anchor entries for
+// h2/h3 headings. Used by the docs layout to build the right-rail
+// "on this page" navigator. No I/O; given a string in, returns a
+// list of anchors out.
+//
+// Input shape: HTML produced by `marked` (which adds `id` attrs to
+// headings via the GFM-slugger by default in our config). When an
+// id is missing, we slug-ify the heading text ourselves so the
+// anchor link still works.
+
+export interface Anchor {
+ level: 2 | 3;
+ text: string;
+ id: string;
+}
+
+const slugify = (raw: string): string =>
+ raw
+ .toLowerCase()
+ .replace(/<[^>]*>/g, "")
+ .replace(/&[a-z]+;/g, " ")
+ .replace(/[^a-z0-9\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-");
+
+const stripTags = (s: string): string => s.replace(/<[^>]*>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'").trim();
+
+export const extractAnchors = (html: string): Anchor[] => {
+ const out: Anchor[] = [];
+ const re = /]*))?>([\s\S]*?)<\/h\1>/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(html)) !== null) {
+ const level = parseInt(m[1] ?? "2", 10) as 2 | 3;
+ const attrs = m[2] ?? "";
+ const inner = m[3] ?? "";
+ const idMatch = /\bid="([^"]+)"/.exec(attrs);
+ const text = stripTags(inner);
+ if (!text) continue;
+ const id = idMatch?.[1] ?? slugify(text);
+ if (!id) continue;
+ out.push({ level, text, id });
+ }
+ return out;
+};
diff --git a/src/c32_sama_verify.test.ts b/src/c32_sama_verify.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..84f734db2b9ae1dcf73a188b4d9e4b6b35d1ea64
--- /dev/null
+++ b/src/c32_sama_verify.test.ts
@@ -0,0 +1,137 @@
+import { test, expect } from "bun:test";
+import { verifySama } from "./c32_sama_verify.ts";
+
+const baseInput = {
+ repoOwner: "test",
+ repoName: "repo",
+ defaultBranch: "main",
+ srcPaths: [] as string[],
+ contents: new Map(),
+};
+
+test("empty input: all checks pass, sorted has a 'no SAMA files' note", () => {
+ const r = verifySama(baseInput);
+ expect(r.overallPassed).toBe(true);
+ const sorted = r.checks.find((c) => c.letter === "S")!;
+ expect(sorted.passed).toBe(true);
+ expect(sorted.examined).toBe(0);
+ expect(sorted.note).toMatch(/no cXX_\*\.ts files found/);
+});
+
+test("Sorted: c14 importing c51 is flagged", () => {
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c14_io.ts", "c51_render.ts"],
+ contents: new Map([
+ ["c14_io.ts", `import { x } from "./c51_render.ts";`],
+ ["c51_render.ts", "export const x = 1;"],
+ ]),
+ });
+ const sorted = r.checks.find((c) => c.letter === "S")!;
+ expect(sorted.passed).toBe(false);
+ expect(sorted.violations[0]?.file).toBe("c14_io.ts");
+ expect(sorted.violations[0]?.detail).toMatch(/UI/);
+});
+
+test("Sorted: c21 importing c51 is NOT flagged (handlers may compose UI)", () => {
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c21_handler.ts", "c51_render.ts"],
+ contents: new Map([
+ ["c21_handler.ts", `import { x } from "./c51_render.ts";`],
+ ["c51_render.ts", "export const x = 1;"],
+ ]),
+ });
+ const sorted = r.checks.find((c) => c.letter === "S")!;
+ expect(sorted.passed).toBe(true);
+});
+
+test("Sorted: c31 importing c32 (sibling layer, non-UI) is NOT flagged", () => {
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c31_model.ts", "c32_logic.ts"],
+ contents: new Map([
+ ["c31_model.ts", `import { x } from "./c32_logic.ts";`],
+ ["c32_logic.ts", "export const x = 1;"],
+ ]),
+ });
+ const sorted = r.checks.find((c) => c.letter === "S")!;
+ expect(sorted.passed).toBe(true);
+});
+
+test("Architecture: unknown prefix is flagged", () => {
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c99_thing.ts"],
+ contents: new Map([["c99_thing.ts", "export const x = 1;"]]),
+ });
+ const arch = r.checks.find((c) => c.property === "Architecture")!;
+ expect(arch.passed).toBe(false);
+ expect(arch.violations[0]?.detail).toMatch(/unknown layer prefix/);
+});
+
+test("Modeled: c32 file without sibling test is flagged; c31 without sibling is informational", () => {
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c32_logic.ts", "c31_model.ts"],
+ contents: new Map([
+ ["c32_logic.ts", "export const x = 1;"],
+ ["c31_model.ts", "export const y = 2;"],
+ ]),
+ });
+ const modeled = r.checks.find((c) => c.property === "Modeled")!;
+ expect(modeled.passed).toBe(false);
+ expect(modeled.violations.length).toBe(1);
+ expect(modeled.violations[0]?.file).toBe("c32_logic.ts");
+ expect(modeled.note).toMatch(/c31_\* file/);
+});
+
+test("Modeled: c32 file with sibling test passes", () => {
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c32_logic.ts", "c32_logic.test.ts"],
+ contents: new Map([
+ ["c32_logic.ts", "export const x = 1;"],
+ ["c32_logic.test.ts", "test('x', () => { expect(true).toBe(true); });"],
+ ]),
+ });
+ const modeled = r.checks.find((c) => c.property === "Modeled")!;
+ expect(modeled.passed).toBe(true);
+});
+
+test("Atomic: file over 700 lines is flagged", () => {
+ const big = "// line\n".repeat(800);
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c21_huge.ts"],
+ contents: new Map([["c21_huge.ts", big]]),
+ });
+ const atomic = r.checks.find((c) => c.property === "Atomic")!;
+ expect(atomic.passed).toBe(false);
+ expect(atomic.violations[0]?.detail).toMatch(/line/);
+});
+
+test("Atomic: placeholder test (zero expect calls) is flagged", () => {
+ const placeholderFixture = `test("does nothing", () => { /* TODO */ })`;
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c32_x.test.ts"],
+ contents: new Map([["c32_x.test.ts", placeholderFixture]]),
+ });
+ const atomic = r.checks.find((c) => c.property === "Atomic")!;
+ expect(atomic.passed).toBe(false);
+ expect(atomic.violations[0]?.detail).toMatch(/placeholder test/);
+});
+
+test("overallPassed reflects every check passing", () => {
+ const r = verifySama({
+ ...baseInput,
+ srcPaths: ["c31_model.ts", "c32_logic.ts", "c32_logic.test.ts"],
+ contents: new Map([
+ ["c31_model.ts", "export const x = 1;"],
+ ["c32_logic.ts", `import { x } from "./c31_model.ts";\nexport const y = x + 1;`],
+ ["c32_logic.test.ts", `import { y } from "./c32_logic.ts";\ntest("y", () => { expect(y).toBe(2); });`],
+ ]),
+ });
+ expect(r.overallPassed).toBe(true);
+});
diff --git a/src/c32_sama_verify.ts b/src/c32_sama_verify.ts
index 223a85a6a3141107a750ddde6c9cd0ee3bd9c2e0..70f78c10eea1d058d485be31ad1544a74417d3ae 100644
--- a/src/c32_sama_verify.ts
+++ b/src/c32_sama_verify.ts
@@ -44,6 +44,56 @@ export interface SamaVerifyInput {
const SAMA_PREFIX = /^c(\d{2})_/;
+// Strip JS string literals and comments from source, preserving
+// position/length by replacing each character with whitespace. This
+// is the cheapest reliable fix for the test-fixture false-positive:
+// import strings and `test(...)` patterns inside literals/comments
+// would otherwise trigger Sorted/Atomic violations.
+const stripStringsAndComments = (src: string): string => {
+ let out = "";
+ let i = 0;
+ while (i < src.length) {
+ const c = src[i];
+ const n = src[i + 1];
+ if (c === "/" && n === "/") {
+ out += " ";
+ i += 2;
+ while (i < src.length && src[i] !== "\n") {
+ out += " ";
+ i++;
+ }
+ } else if (c === "/" && n === "*") {
+ out += " ";
+ i += 2;
+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) {
+ out += src[i] === "\n" ? "\n" : " ";
+ i++;
+ }
+ out += " ";
+ i += 2;
+ } else if (c === '"' || c === "'" || c === "`") {
+ const quote = c;
+ out += " ";
+ i++;
+ while (i < src.length && src[i] !== quote) {
+ if (src[i] === "\\" && i + 1 < src.length) {
+ out += " ";
+ i += 2;
+ continue;
+ }
+ out += src[i] === "\n" ? "\n" : " ";
+ i++;
+ }
+ out += " ";
+ i++;
+ } else {
+ out += c ?? "";
+ i++;
+ }
+ }
+ return out;
+};
+
const isSamaFile = (p: string): boolean => SAMA_PREFIX.test(p) && p.endsWith(".ts");
const isTestFile = (p: string): boolean => p.endsWith(".test.ts");
@@ -55,14 +105,32 @@ const layerOf = (filename: string): number | null => {
// Pull import targets out of a TypeScript source. Recognizes both
// static `import ... from "./x.ts"` and dynamic `import("./x.ts")`.
-// We only care about relative imports (the cross-layer ones).
+// We only care about relative imports (the cross-layer ones). We
+// scan against a stripped source (string literals + comments
+// blanked out) so test fixtures that quote import statements as
+// data don't cause false positives.
const collectRelativeImports = (source: string): string[] => {
const out: string[] = [];
- const staticRe = /\bfrom\s+["']\s*(\.\/[^"']+)["']/g;
- const dynRe = /\bimport\s*\(\s*["']\s*(\.\/[^"']+)["']/g;
+ // Match against the original source so the captured import path
+ // text is preserved; but only accept matches whose start position
+ // is NOT inside a string-literal/comment region (we test that by
+ // checking the stripped source's character at the path-open quote).
+ const stripped = stripStringsAndComments(source);
+ const staticRe = /\bfrom\s+(["'])\s*(\.\/[^"']+)\1/g;
+ const dynRe = /\bimport\s*\(\s*(["'])\s*(\.\/[^"']+)\1/g;
let m: RegExpExecArray | null;
- while ((m = staticRe.exec(source)) !== null) if (m[1]) out.push(m[1]);
- while ((m = dynRe.exec(source)) !== null) if (m[1]) out.push(m[1]);
+ const pushIfReal = (mm: RegExpExecArray, pathIdx: number) => {
+ // Check the start of the match (the `f` of `from` or `i` of
+ // `import`). If that keyword is inside a string literal/comment
+ // in the original source, the stripped version replaces it with
+ // whitespace and we skip the match. The PATH itself is always
+ // inside quotes (that's how imports are written), so we never
+ // gate on the path's position — only the keyword's.
+ if (stripped[mm.index] === " " || stripped[mm.index] === "\n") return;
+ out.push(mm[pathIdx]!);
+ };
+ while ((m = staticRe.exec(source)) !== null) pushIfReal(m, 2);
+ while ((m = dynRe.exec(source)) !== null) pushIfReal(m, 2);
return out;
};
@@ -185,9 +253,16 @@ const checkModeled = (input: SamaVerifyInput): SamaCheckResult => {
// testing surface that Atomic owns.
const findPlaceholderTestsLite = (file: string, content: string): SamaViolation[] => {
const out: SamaViolation[] = [];
+ // Same string/comment-aware trick as collectRelativeImports: only
+ // count test()/it() calls whose `test`/`it` keyword is real code,
+ // not a literal in a fixture.
+ const stripped = stripStringsAndComments(content);
const re = /\b(test|it)\s*\(\s*(["'`])((?:\\.|(?!\2).)*)\2\s*,\s*(?:async\s+)?(?:\([^)]*\)|[^=()]*?)\s*=>\s*\{/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
+ // Skip matches whose `test`/`it` keyword is inside a string literal
+ // or comment (the stripped version replaces those with whitespace).
+ if (stripped[m.index] === " " || stripped[m.index] === "\n") continue;
const name = m[3] ?? "";
const startBrace = re.lastIndex - 1;
let depth = 1;
diff --git a/src/c51_render_docs_layout.ts b/src/c51_render_docs_layout.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f42fd5d4f7dcdb8f522a07da60a5424f86cde2eb
--- /dev/null
+++ b/src/c51_render_docs_layout.ts
@@ -0,0 +1,135 @@
+// 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.
+
+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";
+import {
+ renderPage,
+ escape,
+ type PageOptions,
+} from "./c51_render_layout.ts";
+
+const REPO_EDIT_BASE = "https://github.com/syntaxai/tdd.md/edit/main";
+
+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.
+ pathForDocs: string;
+ // Optional override of which file the "edit on GitHub" link
+ // targets, when the body isn't a content//.md.
+ // Defaults to the editPath from the resolved nav location.
+ 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 `${escape(link.label)}`;
+};
+
+const renderSidebar = (currentPath: string): string => {
+ const sections = SITE_NAV.map((section: DocsNavSection) => {
+ const items = section.links.map((l) => sidebarLink(l, currentPath)).join("");
+ const sectionCls = section.links.some((l) => l.href === currentPath)
+ ? "docs-side-section docs-side-section-active"
+ : "docs-side-section";
+ return ``;
+ }).join("\n");
+ return ``;
+};
+
+const renderAnchorRail = (anchors: Anchor[]): string => {
+ if (anchors.length === 0) return "";
+ const items = anchors
+ .map((a) => {
+ const cls = a.level === 3 ? "docs-rail-link docs-rail-link-h3" : "docs-rail-link";
+ return `${escape(a.text)}`;
+ })
+ .join("");
+ return ``;
+};
+
+const renderEditLink = (editPath: string | null): string => {
+ if (!editPath) return "";
+ const url = `${REPO_EDIT_BASE}/${editPath}`;
+ return `edit this page on GitHub →
`;
+};
+
+const renderPrevNext = (loc: ResolvedDocsLocation | null): string => {
+ if (!loc) return "";
+ const prev = loc.prev
+ ? `←${escape(loc.prev.label)}`
+ : ``;
+ const next = loc.next
+ ? `${escape(loc.next.label)}→`
+ : ``;
+ return ``;
+};
+
+// Wrap a heading element with an anchor link for hover-click access.
+// Also injects an `id` if marked didn't (rare with our config but
+// possible). Operates on the rendered HTML before composing the page.
+const enrichHeadings = (html: string): string =>
+ html.replace(
+ /]*)?>([\s\S]*?)<\/h\1>/g,
+ (_full, level, attrs, inner) => {
+ const idMatch = /\bid="([^"]+)"/.exec(attrs ?? "");
+ const id = idMatch?.[1] ?? inner.replace(/<[^>]*>/g, "").toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-");
+ const finalAttrs = idMatch ? attrs : `${attrs ?? ""} id="${id}"`;
+ return `#${inner}`;
+ },
+ );
+
+export const renderDocsPage = async (opts: DocsPageOptions): Promise => {
+ const rawHtml = opts.bodyMarkdown
+ ? await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false })
+ : "";
+ const enriched = enrichHeadings(rawHtml);
+ const anchors = extractAnchors(enriched);
+ const loc = resolveDocsLocation(opts.pathForDocs);
+ const editPath = opts.editPathOverride !== undefined ? opts.editPathOverride : loc?.current.editPath ?? null;
+
+ const sidebar = renderSidebar(opts.pathForDocs);
+ const rail = renderAnchorRail(anchors);
+ const editLink = renderEditLink(editPath);
+ const prevNext = renderPrevNext(loc);
+
+ const composed = `
+${sidebar}
+
+${editLink}
+${enriched}
+${prevNext}
+
+${rail}
+
`;
+
+ return renderPage({
+ title: opts.title,
+ bodyHtml: composed,
+ description: opts.description,
+ ogPath: opts.ogPath,
+ active: opts.active,
+ noindex: opts.noindex,
+ jsonLd: opts.jsonLd,
+ bodyClass: "docs-body",
+ });
+};
diff --git a/src/c51_render_layout.ts b/src/c51_render_layout.ts
index 113ac0eb2b24ca5fd78b4f2a5dc02ca3805a0b59..0bfdad3ef54773a8f4c7eeba8c3cba44b392671d 100644
--- a/src/c51_render_layout.ts
+++ b/src/c51_render_layout.ts
@@ -15,12 +15,18 @@ export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderb
export interface PageOptions {
title: string;
- bodyMarkdown: string;
+ // Provide either bodyMarkdown (parsed by marked) or bodyHtml
+ // (passed through as-is). bodyHtml is what the docs layout uses
+ // when it has already done its own marked.parse and wrapped the
+ // result in sidebar/content/anchor-rail chrome.
+ bodyMarkdown?: string;
+ bodyHtml?: string;
description?: string;
ogPath?: string;
active?: Section;
noindex?: boolean;
jsonLd?: Record;
+ bodyClass?: string;
}
const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts.";
@@ -36,8 +42,9 @@ const navLink = (href: string, label: string, active: boolean): string => {
const nav = (active?: Section): string => ``;
export const renderPage = async (opts: PageOptions): Promise => {
- const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false });
+ const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false });
const description = opts.description ?? SITE_DESCRIPTION;
+ const bodyClassAttr = opts.bodyClass ? ` class="${escape(opts.bodyClass)}"` : "";
const ogPath = opts.ogPath ?? "https://tdd.md";
const robots = opts.noindex ? `\n` : "";
const jsonLd = opts.jsonLd
@@ -67,7 +74,7 @@ ${robots}
${escape(opts.title)}
${jsonLd}
-
+
${nav(opts.active)}
${body}