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 `
    +

    ${escape(section.title)}

    +
      ${items}
    +
    `; + }).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}