SAMA-native docs layout: sidebar + on-this-page + prev/next + edit-on-GitHub
GitBook-style chrome around /sama, /guides, /blog content pages. One
codebase, one deploy, content stays in content/<section>/<slug>.md.
Built per the SAMA conventions the site itself preaches.
src/c31_docs_nav.ts (new c31 — pure data registry)
Combines ALL_SAMA + ALL_GUIDES + ALL_POSTS into one hierarchical
SITE_NAV the sidebar walks. Resolves a path to its section +
prev/next neighbours.
src/c32_anchor_extract.ts (new c32 — pure logic)
src/c32_anchor_extract.test.ts (sibling test, 8 cases)
Parses rendered HTML for h2/h3 headings, extracts anchor entries
for the right-rail "on this page" navigator. Slugifies headings
that lack an explicit id attribute.
src/c32_sama_verify.test.ts (sibling test, 10 cases)
Tests verifySama directly — covers Sorted (c14->c51 flagged,
c21->c51 not flagged, c31->c32 not flagged), Architecture
(unknown prefix flagged), Modeled (c32 without sibling flagged,
c31 informational), Atomic (oversized + placeholder), and
overallPassed semantics.
src/c32_sama_verify.ts
Adds stripStringsAndComments() pre-pass. Both collectRelative-
Imports and findPlaceholderTestsLite now skip matches whose
keyword is inside a string literal or comment in the source —
fixes the test-fixture false positive where the verifier was
flagging its own test file's data fixtures as real imports /
real placeholder tests. Real path/body parsing still works.
src/c51_render_docs_layout.ts (new c51 — chrome wrapper)
renderDocsPage() composes a left sidebar (from c31_docs_nav),
right-rail anchor list (from c32_anchor_extract), prev/next
navigation, and an edit-on-GitHub link, then delegates to the
existing renderPage. Heading anchor enrichment adds clickable
`#` links on hover.
src/c51_render_layout.ts
PageOptions gains optional bodyHtml (alternative to bodyMarkdown
when the docs layout has already done its own marked.parse) and
bodyClass (lets the docs layout request "docs-body" so CSS can
reset main.md's max-width and padding for the wider three-column
layout).
src/c21_app.ts
/sama, /sama/:slug, /sama/skill, /sama/verify (form + report +
error paths), /guides, /guides/:slug, /blog, /blog/:slug all now
use renderDocsPage. /reports/* and /games/* keep renderPage
because they aren't doc-content pages.
public/style.css
GitBook-style three-column layout (240px sidebar / content /
220px rail). Sticky positioning. Mobile collapse at 768px.
Heading anchor icons on hover.
Dogfood after this commit:
S — Sorted: ✓ pass (was: ✗ 2 false positives from test fixtures
containing import-shaped strings)
A — Architecture: ✓ pass
M — Modeled: ✗ 4 violations (was 5 — c32_sama_verify.ts now has
its sibling test; c32_anchor_extract
also has one. Remaining: c32_judge,
c32_session, c32_real_reports,
c32_real_tests.)
A — Atomic: ✗ 1 violation (was 2 false positives plus the real
c21_app.ts > 700 lines finding. Now
just the real one.)
The verifier improvements (stripStringsAndComments) are general-
purpose and ship to every consumer of /sama/verify and the sama-cli.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
9 files changed · +777 −19
public/style.css
+192
−0
| @@ -507,3 +507,195 @@ main.md table.test-stability td.test-stab-num { | ||
| 507 | 507 | margin: 0 0 1.2rem; |
| 508 | 508 | } |
| 509 | 509 | .project-form-error strong { color: var(--red); } |
| 510 | + | |
| 511 | +/* ----------------------------------------------------------------- | |
| 512 | + Docs layout — GitBook-style sidebar + content + on-this-page rail. | |
| 513 | + Used by /sama/*, /guides/*, /blog/* via renderDocsPage. Mobile | |
| 514 | + stacks vertically; sidebar collapses behind a details/summary on | |
| 515 | + narrow viewports. | |
| 516 | +----------------------------------------------------------------- */ | |
| 517 | + | |
| 518 | +.docs-body main.md { | |
| 519 | + max-width: none; | |
| 520 | + padding: 0; | |
| 521 | +} | |
| 522 | + | |
| 523 | +.docs-layout { | |
| 524 | + display: grid; | |
| 525 | + grid-template-columns: 240px minmax(0, 1fr) 220px; | |
| 526 | + gap: 2rem; | |
| 527 | + max-width: 1400px; | |
| 528 | + margin: 0 auto; | |
| 529 | + padding: 1rem 1.5rem 4rem; | |
| 530 | + align-items: start; | |
| 531 | +} | |
| 532 | + | |
| 533 | +.docs-sidebar, | |
| 534 | +.docs-rail { | |
| 535 | + position: sticky; | |
| 536 | + top: 1rem; | |
| 537 | + font-size: 0.88rem; | |
| 538 | + align-self: start; | |
| 539 | + max-height: calc(100vh - 2rem); | |
| 540 | + overflow-y: auto; | |
| 541 | +} | |
| 542 | + | |
| 543 | +.docs-sidebar { padding-right: 0.5rem; } | |
| 544 | + | |
| 545 | +.docs-side-section { margin: 0 0 1.5rem; } | |
| 546 | +.docs-side-title { | |
| 547 | + margin: 0 0 0.4rem; | |
| 548 | + font-size: 0.75rem; | |
| 549 | + text-transform: uppercase; | |
| 550 | + letter-spacing: 0.06em; | |
| 551 | + color: var(--muted); | |
| 552 | +} | |
| 553 | +.docs-side-title a { | |
| 554 | + color: inherit; | |
| 555 | + text-decoration: none; | |
| 556 | +} | |
| 557 | +.docs-side-title a:hover { color: var(--fg); } | |
| 558 | + | |
| 559 | +.docs-side-list { | |
| 560 | + list-style: none; | |
| 561 | + padding: 0; | |
| 562 | + margin: 0; | |
| 563 | + border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); | |
| 564 | +} | |
| 565 | +.docs-side-list li { margin: 0; } | |
| 566 | + | |
| 567 | +.docs-side-link { | |
| 568 | + display: block; | |
| 569 | + padding: 0.3rem 0.6rem; | |
| 570 | + margin-left: -1px; | |
| 571 | + border-left: 2px solid transparent; | |
| 572 | + color: var(--muted); | |
| 573 | + text-decoration: none; | |
| 574 | + line-height: 1.35; | |
| 575 | +} | |
| 576 | +.docs-side-link:hover { | |
| 577 | + color: var(--fg); | |
| 578 | + border-left-color: color-mix(in srgb, var(--fg) 40%, transparent); | |
| 579 | +} | |
| 580 | +.docs-side-link-active { | |
| 581 | + color: var(--accent); | |
| 582 | + border-left-color: var(--accent); | |
| 583 | + font-weight: 600; | |
| 584 | +} | |
| 585 | + | |
| 586 | +.docs-content { | |
| 587 | + min-width: 0; | |
| 588 | + font-size: 1rem; | |
| 589 | + line-height: 1.65; | |
| 590 | +} | |
| 591 | +.docs-content > h1:first-child { margin-top: 0; } | |
| 592 | +.docs-content h2, | |
| 593 | +.docs-content h3 { | |
| 594 | + scroll-margin-top: 1rem; | |
| 595 | + position: relative; | |
| 596 | +} | |
| 597 | +.docs-h-anchor { | |
| 598 | + position: absolute; | |
| 599 | + left: -1.2rem; | |
| 600 | + color: var(--muted); | |
| 601 | + text-decoration: none; | |
| 602 | + opacity: 0; | |
| 603 | + transition: opacity 0.1s; | |
| 604 | + font-weight: normal; | |
| 605 | +} | |
| 606 | +.docs-content h2:hover .docs-h-anchor, | |
| 607 | +.docs-content h3:hover .docs-h-anchor { | |
| 608 | + opacity: 1; | |
| 609 | +} | |
| 610 | +.docs-h-anchor:hover { color: var(--accent); } | |
| 611 | + | |
| 612 | +.docs-edit { | |
| 613 | + text-align: right; | |
| 614 | + font-size: 0.85rem; | |
| 615 | + margin: 0 0 0.5rem; | |
| 616 | +} | |
| 617 | +.docs-edit a { | |
| 618 | + color: var(--muted); | |
| 619 | + text-decoration: none; | |
| 620 | +} | |
| 621 | +.docs-edit a:hover { color: var(--accent); } | |
| 622 | + | |
| 623 | +.docs-rail-title { | |
| 624 | + margin: 0 0 0.5rem; | |
| 625 | + font-size: 0.75rem; | |
| 626 | + text-transform: uppercase; | |
| 627 | + letter-spacing: 0.06em; | |
| 628 | + color: var(--muted); | |
| 629 | +} | |
| 630 | +.docs-rail-list { | |
| 631 | + list-style: none; | |
| 632 | + padding: 0; | |
| 633 | + margin: 0; | |
| 634 | + border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); | |
| 635 | +} | |
| 636 | +.docs-rail-link { | |
| 637 | + display: block; | |
| 638 | + padding: 0.25rem 0.6rem; | |
| 639 | + margin-left: -1px; | |
| 640 | + border-left: 2px solid transparent; | |
| 641 | + color: var(--muted); | |
| 642 | + text-decoration: none; | |
| 643 | + line-height: 1.35; | |
| 644 | +} | |
| 645 | +.docs-rail-link:hover { | |
| 646 | + color: var(--fg); | |
| 647 | + border-left-color: color-mix(in srgb, var(--fg) 40%, transparent); | |
| 648 | +} | |
| 649 | +.docs-rail-link-h3 { padding-left: 1.4rem; font-size: 0.82rem; } | |
| 650 | + | |
| 651 | +.docs-prev-next { | |
| 652 | + display: grid; | |
| 653 | + grid-template-columns: 1fr 1fr; | |
| 654 | + gap: 1rem; | |
| 655 | + margin: 3rem 0 0; | |
| 656 | + border-top: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); | |
| 657 | + padding-top: 1.5rem; | |
| 658 | +} | |
| 659 | +.docs-pn-prev, | |
| 660 | +.docs-pn-next { | |
| 661 | + display: flex; | |
| 662 | + align-items: center; | |
| 663 | + gap: 0.5rem; | |
| 664 | + padding: 0.8rem 1rem; | |
| 665 | + border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); | |
| 666 | + border-radius: 6px; | |
| 667 | + text-decoration: none; | |
| 668 | + color: var(--fg); | |
| 669 | + background: color-mix(in srgb, var(--muted) 6%, transparent); | |
| 670 | +} | |
| 671 | +.docs-pn-prev:hover, | |
| 672 | +.docs-pn-next:hover { | |
| 673 | + border-color: var(--accent); | |
| 674 | + background: color-mix(in srgb, var(--accent) 10%, transparent); | |
| 675 | +} | |
| 676 | +.docs-pn-prev { justify-content: flex-start; } | |
| 677 | +.docs-pn-next { justify-content: flex-end; text-align: right; } | |
| 678 | +.docs-pn-arrow { color: var(--muted); font-size: 1.1rem; } | |
| 679 | +.docs-pn-label { font-weight: 500; } | |
| 680 | +.docs-pn-spacer {} | |
| 681 | + | |
| 682 | +@media (max-width: 1080px) { | |
| 683 | + .docs-layout { | |
| 684 | + grid-template-columns: 220px minmax(0, 1fr); | |
| 685 | + } | |
| 686 | + .docs-rail { display: none; } | |
| 687 | +} | |
| 688 | + | |
| 689 | +@media (max-width: 768px) { | |
| 690 | + .docs-layout { | |
| 691 | + grid-template-columns: 1fr; | |
| 692 | + } | |
| 693 | + .docs-sidebar { | |
| 694 | + position: static; | |
| 695 | + max-height: none; | |
| 696 | + border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); | |
| 697 | + border-radius: 6px; | |
| 698 | + padding: 1rem; | |
| 699 | + margin-bottom: 1.5rem; | |
| 700 | + } | |
| 701 | +} | |
src/c21_app.ts
+29
−11
| @@ -8,6 +8,7 @@ import { | ||
| 8 | 8 | htmlResponse, |
| 9 | 9 | escape, |
| 10 | 10 | } from "./c51_render_layout.ts"; |
| 11 | +import { renderDocsPage } from "./c51_render_docs_layout.ts"; | |
| 11 | 12 | import { |
| 12 | 13 | projectsLandingMd, |
| 13 | 14 | projectRegisterMd, |
| @@ -349,12 +350,14 @@ ${rows} | ||
| 349 | 350 | |
| 350 | 351 | [← back to tdd.md](/) · [the guides](/guides) · [the katas](/games) |
| 351 | 352 | `; |
| 352 | - const html = await renderPage({ | |
| 353 | + const html = await renderDocsPage({ | |
| 353 | 354 | title: "Blog — tdd.md", |
| 354 | 355 | 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.", |
| 355 | 356 | bodyMarkdown: body, |
| 356 | 357 | ogPath: "https://tdd.md/blog", |
| 357 | 358 | active: "blog", |
| 359 | + pathForDocs: "/blog", | |
| 360 | + editPathOverride: null, | |
| 358 | 361 | }); |
| 359 | 362 | return htmlResponse(html); |
| 360 | 363 | }, |
| @@ -372,12 +375,13 @@ ${rows} | ||
| 372 | 375 | return htmlResponse(html, 404); |
| 373 | 376 | } |
| 374 | 377 | const md = await file.text(); |
| 375 | - const html = await renderPage({ | |
| 378 | + const html = await renderDocsPage({ | |
| 376 | 379 | title: `${entry.title} — tdd.md`, |
| 377 | 380 | description: entry.description, |
| 378 | 381 | bodyMarkdown: md, |
| 379 | 382 | ogPath: `https://tdd.md/blog/${slug}`, |
| 380 | 383 | active: "blog", |
| 384 | + pathForDocs: `/blog/${slug}`, | |
| 381 | 385 | jsonLd: { |
| 382 | 386 | "@context": "https://schema.org", |
| 383 | 387 | "@type": "BlogPosting", |
| @@ -605,12 +609,14 @@ ${rows} | ||
| 605 | 609 | |
| 606 | 610 | [← play a kata](/games) · [register your agent →](/you) |
| 607 | 611 | `; |
| 608 | - const html = await renderPage({ | |
| 612 | + const html = await renderDocsPage({ | |
| 609 | 613 | title: "TDD guides for agentic coding tools — tdd.md", |
| 610 | 614 | 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.", |
| 611 | 615 | bodyMarkdown: body, |
| 612 | 616 | ogPath: "https://tdd.md/guides", |
| 613 | 617 | active: "guides", |
| 618 | + pathForDocs: "/guides", | |
| 619 | + editPathOverride: null, | |
| 614 | 620 | }); |
| 615 | 621 | return htmlResponse(html); |
| 616 | 622 | }, |
| @@ -628,12 +634,13 @@ ${rows} | ||
| 628 | 634 | return htmlResponse(html, 404); |
| 629 | 635 | } |
| 630 | 636 | const md = await file.text(); |
| 631 | - const html = await renderPage({ | |
| 637 | + const html = await renderDocsPage({ | |
| 632 | 638 | title: `${entry.title} — tdd.md`, |
| 633 | 639 | description: entry.description, |
| 634 | 640 | bodyMarkdown: md, |
| 635 | 641 | ogPath: `https://tdd.md/guides/${slug}`, |
| 636 | 642 | active: "guides", |
| 643 | + pathForDocs: `/guides/${slug}`, | |
| 637 | 644 | }); |
| 638 | 645 | return htmlResponse(html); |
| 639 | 646 | }, |
| @@ -673,12 +680,13 @@ ${rows} | ||
| 673 | 680 | > 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) |
| 674 | 681 | `; |
| 675 | 682 | const body = `${installNote}\n\n${stripped}\n\n---\n\n[← /sama](/sama) · [the four disciplines](/sama) · [back to tdd.md](/)\n`; |
| 676 | - const html = await renderPage({ | |
| 683 | + const html = await renderDocsPage({ | |
| 677 | 684 | title: "SAMA skill — drop into your agent — tdd.md", |
| 678 | 685 | 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.", |
| 679 | 686 | bodyMarkdown: body, |
| 680 | 687 | ogPath: "https://tdd.md/sama/skill", |
| 681 | 688 | active: "sama", |
| 689 | + pathForDocs: "/sama/skill", | |
| 682 | 690 | }); |
| 683 | 691 | return htmlResponse(html); |
| 684 | 692 | }, |
| @@ -706,22 +714,25 @@ Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses | ||
| 706 | 714 | `; |
| 707 | 715 | |
| 708 | 716 | if (!repoArg) { |
| 709 | - const html = await renderPage({ | |
| 717 | + const html = await renderDocsPage({ | |
| 710 | 718 | title: "SAMA verify — tdd.md", |
| 711 | 719 | 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).", |
| 712 | 720 | bodyMarkdown: formMd, |
| 713 | 721 | ogPath: "https://tdd.md/sama/verify", |
| 714 | 722 | active: "sama", |
| 723 | + pathForDocs: "/sama/verify", | |
| 715 | 724 | }); |
| 716 | 725 | return htmlResponse(html); |
| 717 | 726 | } |
| 718 | 727 | |
| 719 | 728 | const m = /^([^\/\s]+)\/([^\/\s]+)$/.exec(repoArg); |
| 720 | 729 | if (!m) { |
| 721 | - const html = await renderPage({ | |
| 730 | + const html = await renderDocsPage({ | |
| 722 | 731 | title: "SAMA verify · bad input — tdd.md", |
| 723 | 732 | description: "SAMA verify expects an owner/name repo identifier.", |
| 724 | 733 | bodyMarkdown: `# SAMA verify\n\n> Couldn't parse \`${repoArg}\`. Use the form: \`owner/name\`.\n\n[← back](/sama/verify)\n`, |
| 734 | + pathForDocs: "/sama/verify", | |
| 735 | + editPathOverride: null, | |
| 725 | 736 | ogPath: "https://tdd.md/sama/verify", |
| 726 | 737 | active: "sama", |
| 727 | 738 | noindex: true, |
| @@ -781,13 +792,15 @@ Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses | ||
| 781 | 792 | } |
| 782 | 793 | } catch (e) { |
| 783 | 794 | const msg = e instanceof Error ? e.message : String(e); |
| 784 | - const html = await renderPage({ | |
| 795 | + const html = await renderDocsPage({ | |
| 785 | 796 | title: `SAMA verify · ${owner}/${name} · error — tdd.md`, |
| 786 | 797 | description: `SAMA verify could not inspect ${owner}/${name}.`, |
| 787 | 798 | 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`, |
| 788 | 799 | ogPath: `https://tdd.md/sama/verify?repo=${owner}/${name}`, |
| 789 | 800 | active: "sama", |
| 790 | 801 | noindex: true, |
| 802 | + pathForDocs: "/sama/verify", | |
| 803 | + editPathOverride: null, | |
| 791 | 804 | }); |
| 792 | 805 | return htmlResponse(html, 502); |
| 793 | 806 | } |
| @@ -815,12 +828,14 @@ ${checkBlocks} | ||
| 815 | 828 | |
| 816 | 829 | [← verify another repo](/sama/verify) · [the four SAMA disciplines →](/sama) · [SAMA skill for your agent →](/sama/skill) |
| 817 | 830 | `; |
| 818 | - const html = await renderPage({ | |
| 831 | + const html = await renderDocsPage({ | |
| 819 | 832 | title: `SAMA verify · ${report.repoSlug} — tdd.md`, |
| 820 | 833 | description: `SAMA verification for ${report.repoSlug}: ${report.overallPassed ? "all four checks passed" : `${report.checks.filter((c) => !c.passed).length}/4 checks failed`}.`, |
| 821 | 834 | bodyMarkdown: reportMd, |
| 822 | 835 | ogPath: `https://tdd.md/sama/verify?repo=${report.repoSlug}`, |
| 823 | 836 | active: "sama", |
| 837 | + pathForDocs: "/sama/verify", | |
| 838 | + editPathOverride: null, | |
| 824 | 839 | }); |
| 825 | 840 | return htmlResponse(html); |
| 826 | 841 | }, |
| @@ -941,12 +956,14 @@ The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) arg | ||
| 941 | 956 | |
| 942 | 957 | [← back to tdd.md](/) · [the blog](/blog) · [the guides](/guides) |
| 943 | 958 | `; |
| 944 | - const html = await renderPage({ | |
| 959 | + const html = await renderDocsPage({ | |
| 945 | 960 | title: "SAMA — sorted, architecture, modeled, atomic — tdd.md", |
| 946 | 961 | 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.", |
| 947 | 962 | bodyMarkdown: body, |
| 948 | 963 | ogPath: "https://tdd.md/sama", |
| 949 | 964 | active: "sama", |
| 965 | + pathForDocs: "/sama", | |
| 966 | + editPathOverride: null, | |
| 950 | 967 | }); |
| 951 | 968 | return htmlResponse(html); |
| 952 | 969 | }, |
| @@ -964,12 +981,13 @@ The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) arg | ||
| 964 | 981 | return htmlResponse(html, 404); |
| 965 | 982 | } |
| 966 | 983 | const md = await file.text(); |
| 967 | - const html = await renderPage({ | |
| 984 | + const html = await renderDocsPage({ | |
| 968 | 985 | title: `SAMA · ${entry.letter} — ${entry.title} — tdd.md`, |
| 969 | 986 | description: entry.description, |
| 970 | 987 | bodyMarkdown: md, |
| 971 | 988 | ogPath: `https://tdd.md/sama/${slug}`, |
| 972 | 989 | active: "sama", |
| 990 | + pathForDocs: `/sama/${slug}`, | |
| 973 | 991 | }); |
| 974 | 992 | return htmlResponse(html); |
| 975 | 993 | }, |
src/c31_docs_nav.ts
+93
−0
| @@ -0,0 +1,93 @@ | ||
| 1 | +// c31 — model: hierarchical site-nav for the GitBook-style docs | |
| 2 | +// chrome. Pure data: combines the existing per-section registries | |
| 3 | +// (sama, guides, blog) into one structure the sidebar walks at | |
| 4 | +// render-time, plus a flat per-section list the prev/next navigator | |
| 5 | +// uses to compute neighbours. | |
| 6 | + | |
| 7 | +import { ALL_SAMA, type SamaDiscipline } from "./c31_sama.ts"; | |
| 8 | +import { ALL_GUIDES, type GuideEntry } from "./c31_guides.ts"; | |
| 9 | +import { ALL_POSTS, type BlogEntry } from "./c31_blog.ts"; | |
| 10 | + | |
| 11 | +export interface DocsNavLink { | |
| 12 | + href: string; | |
| 13 | + label: string; | |
| 14 | + // GitHub raw-edit URL for the source markdown, when applicable. | |
| 15 | + // null for pages whose body is built inline in c21_app.ts. | |
| 16 | + editPath: string | null; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface DocsNavSection { | |
| 20 | + id: "sama" | "guides" | "blog"; | |
| 21 | + title: string; | |
| 22 | + rootHref: string; | |
| 23 | + links: DocsNavLink[]; | |
| 24 | +} | |
| 25 | + | |
| 26 | +const samaLink = (d: SamaDiscipline): DocsNavLink => ({ | |
| 27 | + href: `/sama/${d.slug}`, | |
| 28 | + label: `${d.letter} — ${d.title}`, | |
| 29 | + editPath: `content/sama/${d.slug}.md`, | |
| 30 | +}); | |
| 31 | + | |
| 32 | +const guideLink = (g: GuideEntry): DocsNavLink => ({ | |
| 33 | + href: `/guides/${g.slug}`, | |
| 34 | + label: g.title, | |
| 35 | + editPath: `content/guides/${g.slug}.md`, | |
| 36 | +}); | |
| 37 | + | |
| 38 | +const blogLink = (p: BlogEntry): DocsNavLink => ({ | |
| 39 | + href: `/blog/${p.slug}`, | |
| 40 | + label: p.title, | |
| 41 | + editPath: `content/blog/${p.slug}.md`, | |
| 42 | +}); | |
| 43 | + | |
| 44 | +export const SITE_NAV: DocsNavSection[] = [ | |
| 45 | + { | |
| 46 | + id: "sama", | |
| 47 | + title: "SAMA", | |
| 48 | + rootHref: "/sama", | |
| 49 | + links: [ | |
| 50 | + ...ALL_SAMA.map(samaLink), | |
| 51 | + { href: "/sama/skill", label: "SKILL.md (drop into your agent)", editPath: "content/sama/skill.md" }, | |
| 52 | + { href: "/sama/verify", label: "verify a public repo", editPath: null }, | |
| 53 | + ], | |
| 54 | + }, | |
| 55 | + { | |
| 56 | + id: "guides", | |
| 57 | + title: "Guides", | |
| 58 | + rootHref: "/guides", | |
| 59 | + links: ALL_GUIDES.map(guideLink), | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + id: "blog", | |
| 63 | + title: "Blog", | |
| 64 | + rootHref: "/blog", | |
| 65 | + links: ALL_POSTS.map(blogLink), | |
| 66 | + }, | |
| 67 | +]; | |
| 68 | + | |
| 69 | +// Resolve the section + position of a given path. Used by the | |
| 70 | +// docs layout to select the right sidebar section and to compute | |
| 71 | +// prev/next neighbours. | |
| 72 | +export interface ResolvedDocsLocation { | |
| 73 | + section: DocsNavSection; | |
| 74 | + index: number; | |
| 75 | + current: DocsNavLink; | |
| 76 | + prev: DocsNavLink | null; | |
| 77 | + next: DocsNavLink | null; | |
| 78 | +} | |
| 79 | + | |
| 80 | +export const resolveDocsLocation = (path: string): ResolvedDocsLocation | null => { | |
| 81 | + for (const section of SITE_NAV) { | |
| 82 | + const i = section.links.findIndex((l) => l.href === path); | |
| 83 | + if (i === -1) continue; | |
| 84 | + return { | |
| 85 | + section, | |
| 86 | + index: i, | |
| 87 | + current: section.links[i]!, | |
| 88 | + prev: i > 0 ? section.links[i - 1]! : null, | |
| 89 | + next: i < section.links.length - 1 ? section.links[i + 1]! : null, | |
| 90 | + }; | |
| 91 | + } | |
| 92 | + return null; | |
| 93 | +}; | |
src/c32_anchor_extract.test.ts
+57
−0
| @@ -0,0 +1,57 @@ | ||
| 1 | +import { test, expect } from "bun:test"; | |
| 2 | +import { extractAnchors } from "./c32_anchor_extract.ts"; | |
| 3 | + | |
| 4 | +test("extracts h2 with explicit id", () => { | |
| 5 | + const html = `<h2 id="getting-started">Getting started</h2>`; | |
| 6 | + expect(extractAnchors(html)).toEqual([ | |
| 7 | + { level: 2, text: "Getting started", id: "getting-started" }, | |
| 8 | + ]); | |
| 9 | +}); | |
| 10 | + | |
| 11 | +test("extracts h3 with explicit id", () => { | |
| 12 | + const html = `<h3 id="why">Why</h3>`; | |
| 13 | + expect(extractAnchors(html)).toEqual([ | |
| 14 | + { level: 3, text: "Why", id: "why" }, | |
| 15 | + ]); | |
| 16 | +}); | |
| 17 | + | |
| 18 | +test("ignores h1 and h4+", () => { | |
| 19 | + const html = `<h1 id="t">T</h1><h2 id="a">A</h2><h4 id="b">B</h4>`; | |
| 20 | + const anchors = extractAnchors(html); | |
| 21 | + expect(anchors.map((a) => a.id)).toEqual(["a"]); | |
| 22 | +}); | |
| 23 | + | |
| 24 | +test("slugifies when id attribute is missing", () => { | |
| 25 | + const html = `<h2>What this number does *not* measure</h2>`; | |
| 26 | + const anchors = extractAnchors(html); | |
| 27 | + expect(anchors[0]?.id).toBe("what-this-number-does-not-measure"); | |
| 28 | +}); | |
| 29 | + | |
| 30 | +test("strips inline tags from text and id source", () => { | |
| 31 | + const html = `<h3><code>red:</code> phase</h3>`; | |
| 32 | + const anchors = extractAnchors(html); | |
| 33 | + expect(anchors[0]?.text).toBe("red: phase"); | |
| 34 | + expect(anchors[0]?.id).toBe("red-phase"); | |
| 35 | +}); | |
| 36 | + | |
| 37 | +test("returns multiple anchors in document order", () => { | |
| 38 | + const html = `<h2 id="one">One</h2><p>x</p><h3 id="two">Two</h3><h2 id="three">Three</h2>`; | |
| 39 | + const anchors = extractAnchors(html); | |
| 40 | + expect(anchors.map((a) => `${a.level}:${a.id}`)).toEqual([ | |
| 41 | + "2:one", | |
| 42 | + "3:two", | |
| 43 | + "2:three", | |
| 44 | + ]); | |
| 45 | +}); | |
| 46 | + | |
| 47 | +test("skips empty headings", () => { | |
| 48 | + const html = `<h2 id="empty"></h2><h2 id="real">Real</h2>`; | |
| 49 | + expect(extractAnchors(html).length).toBe(1); | |
| 50 | +}); | |
| 51 | + | |
| 52 | +test("handles HTML entities in text", () => { | |
| 53 | + const html = `<h2>Tom & Jerry</h2>`; | |
| 54 | + const anchors = extractAnchors(html); | |
| 55 | + expect(anchors[0]?.text).toBe("Tom & Jerry"); | |
| 56 | + expect(anchors[0]?.id).toBe("tom-jerry"); | |
| 57 | +}); | |
src/c32_anchor_extract.ts
+44
−0
| @@ -0,0 +1,44 @@ | ||
| 1 | +// c32 — pure: parse rendered HTML and extract anchor entries for | |
| 2 | +// h2/h3 headings. Used by the docs layout to build the right-rail | |
| 3 | +// "on this page" navigator. No I/O; given a string in, returns a | |
| 4 | +// list of anchors out. | |
| 5 | +// | |
| 6 | +// Input shape: HTML produced by `marked` (which adds `id` attrs to | |
| 7 | +// headings via the GFM-slugger by default in our config). When an | |
| 8 | +// id is missing, we slug-ify the heading text ourselves so the | |
| 9 | +// anchor link still works. | |
| 10 | + | |
| 11 | +export interface Anchor { | |
| 12 | + level: 2 | 3; | |
| 13 | + text: string; | |
| 14 | + id: string; | |
| 15 | +} | |
| 16 | + | |
| 17 | +const slugify = (raw: string): string => | |
| 18 | + raw | |
| 19 | + .toLowerCase() | |
| 20 | + .replace(/<[^>]*>/g, "") | |
| 21 | + .replace(/&[a-z]+;/g, " ") | |
| 22 | + .replace(/[^a-z0-9\s-]/g, "") | |
| 23 | + .trim() | |
| 24 | + .replace(/\s+/g, "-"); | |
| 25 | + | |
| 26 | +const stripTags = (s: string): string => s.replace(/<[^>]*>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'").trim(); | |
| 27 | + | |
| 28 | +export const extractAnchors = (html: string): Anchor[] => { | |
| 29 | + const out: Anchor[] = []; | |
| 30 | + const re = /<h([23])(?:\s+([^>]*))?>([\s\S]*?)<\/h\1>/g; | |
| 31 | + let m: RegExpExecArray | null; | |
| 32 | + while ((m = re.exec(html)) !== null) { | |
| 33 | + const level = parseInt(m[1] ?? "2", 10) as 2 | 3; | |
| 34 | + const attrs = m[2] ?? ""; | |
| 35 | + const inner = m[3] ?? ""; | |
| 36 | + const idMatch = /\bid="([^"]+)"/.exec(attrs); | |
| 37 | + const text = stripTags(inner); | |
| 38 | + if (!text) continue; | |
| 39 | + const id = idMatch?.[1] ?? slugify(text); | |
| 40 | + if (!id) continue; | |
| 41 | + out.push({ level, text, id }); | |
| 42 | + } | |
| 43 | + return out; | |
| 44 | +}; | |
src/c32_sama_verify.test.ts
+137
−0
| @@ -0,0 +1,137 @@ | ||
| 1 | +import { test, expect } from "bun:test"; | |
| 2 | +import { verifySama } from "./c32_sama_verify.ts"; | |
| 3 | + | |
| 4 | +const baseInput = { | |
| 5 | + repoOwner: "test", | |
| 6 | + repoName: "repo", | |
| 7 | + defaultBranch: "main", | |
| 8 | + srcPaths: [] as string[], | |
| 9 | + contents: new Map<string, string>(), | |
| 10 | +}; | |
| 11 | + | |
| 12 | +test("empty input: all checks pass, sorted has a 'no SAMA files' note", () => { | |
| 13 | + const r = verifySama(baseInput); | |
| 14 | + expect(r.overallPassed).toBe(true); | |
| 15 | + const sorted = r.checks.find((c) => c.letter === "S")!; | |
| 16 | + expect(sorted.passed).toBe(true); | |
| 17 | + expect(sorted.examined).toBe(0); | |
| 18 | + expect(sorted.note).toMatch(/no cXX_\*\.ts files found/); | |
| 19 | +}); | |
| 20 | + | |
| 21 | +test("Sorted: c14 importing c51 is flagged", () => { | |
| 22 | + const r = verifySama({ | |
| 23 | + ...baseInput, | |
| 24 | + srcPaths: ["c14_io.ts", "c51_render.ts"], | |
| 25 | + contents: new Map([ | |
| 26 | + ["c14_io.ts", `import { x } from "./c51_render.ts";`], | |
| 27 | + ["c51_render.ts", "export const x = 1;"], | |
| 28 | + ]), | |
| 29 | + }); | |
| 30 | + const sorted = r.checks.find((c) => c.letter === "S")!; | |
| 31 | + expect(sorted.passed).toBe(false); | |
| 32 | + expect(sorted.violations[0]?.file).toBe("c14_io.ts"); | |
| 33 | + expect(sorted.violations[0]?.detail).toMatch(/UI/); | |
| 34 | +}); | |
| 35 | + | |
| 36 | +test("Sorted: c21 importing c51 is NOT flagged (handlers may compose UI)", () => { | |
| 37 | + const r = verifySama({ | |
| 38 | + ...baseInput, | |
| 39 | + srcPaths: ["c21_handler.ts", "c51_render.ts"], | |
| 40 | + contents: new Map([ | |
| 41 | + ["c21_handler.ts", `import { x } from "./c51_render.ts";`], | |
| 42 | + ["c51_render.ts", "export const x = 1;"], | |
| 43 | + ]), | |
| 44 | + }); | |
| 45 | + const sorted = r.checks.find((c) => c.letter === "S")!; | |
| 46 | + expect(sorted.passed).toBe(true); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +test("Sorted: c31 importing c32 (sibling layer, non-UI) is NOT flagged", () => { | |
| 50 | + const r = verifySama({ | |
| 51 | + ...baseInput, | |
| 52 | + srcPaths: ["c31_model.ts", "c32_logic.ts"], | |
| 53 | + contents: new Map([ | |
| 54 | + ["c31_model.ts", `import { x } from "./c32_logic.ts";`], | |
| 55 | + ["c32_logic.ts", "export const x = 1;"], | |
| 56 | + ]), | |
| 57 | + }); | |
| 58 | + const sorted = r.checks.find((c) => c.letter === "S")!; | |
| 59 | + expect(sorted.passed).toBe(true); | |
| 60 | +}); | |
| 61 | + | |
| 62 | +test("Architecture: unknown prefix is flagged", () => { | |
| 63 | + const r = verifySama({ | |
| 64 | + ...baseInput, | |
| 65 | + srcPaths: ["c99_thing.ts"], | |
| 66 | + contents: new Map([["c99_thing.ts", "export const x = 1;"]]), | |
| 67 | + }); | |
| 68 | + const arch = r.checks.find((c) => c.property === "Architecture")!; | |
| 69 | + expect(arch.passed).toBe(false); | |
| 70 | + expect(arch.violations[0]?.detail).toMatch(/unknown layer prefix/); | |
| 71 | +}); | |
| 72 | + | |
| 73 | +test("Modeled: c32 file without sibling test is flagged; c31 without sibling is informational", () => { | |
| 74 | + const r = verifySama({ | |
| 75 | + ...baseInput, | |
| 76 | + srcPaths: ["c32_logic.ts", "c31_model.ts"], | |
| 77 | + contents: new Map([ | |
| 78 | + ["c32_logic.ts", "export const x = 1;"], | |
| 79 | + ["c31_model.ts", "export const y = 2;"], | |
| 80 | + ]), | |
| 81 | + }); | |
| 82 | + const modeled = r.checks.find((c) => c.property === "Modeled")!; | |
| 83 | + expect(modeled.passed).toBe(false); | |
| 84 | + expect(modeled.violations.length).toBe(1); | |
| 85 | + expect(modeled.violations[0]?.file).toBe("c32_logic.ts"); | |
| 86 | + expect(modeled.note).toMatch(/c31_\* file/); | |
| 87 | +}); | |
| 88 | + | |
| 89 | +test("Modeled: c32 file with sibling test passes", () => { | |
| 90 | + const r = verifySama({ | |
| 91 | + ...baseInput, | |
| 92 | + srcPaths: ["c32_logic.ts", "c32_logic.test.ts"], | |
| 93 | + contents: new Map([ | |
| 94 | + ["c32_logic.ts", "export const x = 1;"], | |
| 95 | + ["c32_logic.test.ts", "test('x', () => { expect(true).toBe(true); });"], | |
| 96 | + ]), | |
| 97 | + }); | |
| 98 | + const modeled = r.checks.find((c) => c.property === "Modeled")!; | |
| 99 | + expect(modeled.passed).toBe(true); | |
| 100 | +}); | |
| 101 | + | |
| 102 | +test("Atomic: file over 700 lines is flagged", () => { | |
| 103 | + const big = "// line\n".repeat(800); | |
| 104 | + const r = verifySama({ | |
| 105 | + ...baseInput, | |
| 106 | + srcPaths: ["c21_huge.ts"], | |
| 107 | + contents: new Map([["c21_huge.ts", big]]), | |
| 108 | + }); | |
| 109 | + const atomic = r.checks.find((c) => c.property === "Atomic")!; | |
| 110 | + expect(atomic.passed).toBe(false); | |
| 111 | + expect(atomic.violations[0]?.detail).toMatch(/line/); | |
| 112 | +}); | |
| 113 | + | |
| 114 | +test("Atomic: placeholder test (zero expect calls) is flagged", () => { | |
| 115 | + const placeholderFixture = `test("does nothing", () => { /* TODO */ })`; | |
| 116 | + const r = verifySama({ | |
| 117 | + ...baseInput, | |
| 118 | + srcPaths: ["c32_x.test.ts"], | |
| 119 | + contents: new Map([["c32_x.test.ts", placeholderFixture]]), | |
| 120 | + }); | |
| 121 | + const atomic = r.checks.find((c) => c.property === "Atomic")!; | |
| 122 | + expect(atomic.passed).toBe(false); | |
| 123 | + expect(atomic.violations[0]?.detail).toMatch(/placeholder test/); | |
| 124 | +}); | |
| 125 | + | |
| 126 | +test("overallPassed reflects every check passing", () => { | |
| 127 | + const r = verifySama({ | |
| 128 | + ...baseInput, | |
| 129 | + srcPaths: ["c31_model.ts", "c32_logic.ts", "c32_logic.test.ts"], | |
| 130 | + contents: new Map([ | |
| 131 | + ["c31_model.ts", "export const x = 1;"], | |
| 132 | + ["c32_logic.ts", `import { x } from "./c31_model.ts";\nexport const y = x + 1;`], | |
| 133 | + ["c32_logic.test.ts", `import { y } from "./c32_logic.ts";\ntest("y", () => { expect(y).toBe(2); });`], | |
| 134 | + ]), | |
| 135 | + }); | |
| 136 | + expect(r.overallPassed).toBe(true); | |
| 137 | +}); | |
src/c32_sama_verify.ts
+80
−5
| @@ -44,6 +44,56 @@ export interface SamaVerifyInput { | ||
| 44 | 44 | |
| 45 | 45 | const SAMA_PREFIX = /^c(\d{2})_/; |
| 46 | 46 | |
| 47 | +// Strip JS string literals and comments from source, preserving | |
| 48 | +// position/length by replacing each character with whitespace. This | |
| 49 | +// is the cheapest reliable fix for the test-fixture false-positive: | |
| 50 | +// import strings and `test(...)` patterns inside literals/comments | |
| 51 | +// would otherwise trigger Sorted/Atomic violations. | |
| 52 | +const stripStringsAndComments = (src: string): string => { | |
| 53 | + let out = ""; | |
| 54 | + let i = 0; | |
| 55 | + while (i < src.length) { | |
| 56 | + const c = src[i]; | |
| 57 | + const n = src[i + 1]; | |
| 58 | + if (c === "/" && n === "/") { | |
| 59 | + out += " "; | |
| 60 | + i += 2; | |
| 61 | + while (i < src.length && src[i] !== "\n") { | |
| 62 | + out += " "; | |
| 63 | + i++; | |
| 64 | + } | |
| 65 | + } else if (c === "/" && n === "*") { | |
| 66 | + out += " "; | |
| 67 | + i += 2; | |
| 68 | + while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) { | |
| 69 | + out += src[i] === "\n" ? "\n" : " "; | |
| 70 | + i++; | |
| 71 | + } | |
| 72 | + out += " "; | |
| 73 | + i += 2; | |
| 74 | + } else if (c === '"' || c === "'" || c === "`") { | |
| 75 | + const quote = c; | |
| 76 | + out += " "; | |
| 77 | + i++; | |
| 78 | + while (i < src.length && src[i] !== quote) { | |
| 79 | + if (src[i] === "\\" && i + 1 < src.length) { | |
| 80 | + out += " "; | |
| 81 | + i += 2; | |
| 82 | + continue; | |
| 83 | + } | |
| 84 | + out += src[i] === "\n" ? "\n" : " "; | |
| 85 | + i++; | |
| 86 | + } | |
| 87 | + out += " "; | |
| 88 | + i++; | |
| 89 | + } else { | |
| 90 | + out += c ?? ""; | |
| 91 | + i++; | |
| 92 | + } | |
| 93 | + } | |
| 94 | + return out; | |
| 95 | +}; | |
| 96 | + | |
| 47 | 97 | const isSamaFile = (p: string): boolean => SAMA_PREFIX.test(p) && p.endsWith(".ts"); |
| 48 | 98 | const isTestFile = (p: string): boolean => p.endsWith(".test.ts"); |
| 49 | 99 | |
| @@ -55,14 +105,32 @@ const layerOf = (filename: string): number | null => { | ||
| 55 | 105 | |
| 56 | 106 | // Pull import targets out of a TypeScript source. Recognizes both |
| 57 | 107 | // static `import ... from "./x.ts"` and dynamic `import("./x.ts")`. |
| 58 | -// We only care about relative imports (the cross-layer ones). | |
| 108 | +// We only care about relative imports (the cross-layer ones). We | |
| 109 | +// scan against a stripped source (string literals + comments | |
| 110 | +// blanked out) so test fixtures that quote import statements as | |
| 111 | +// data don't cause false positives. | |
| 59 | 112 | const collectRelativeImports = (source: string): string[] => { |
| 60 | 113 | const out: string[] = []; |
| 61 | - const staticRe = /\bfrom\s+["']\s*(\.\/[^"']+)["']/g; | |
| 62 | - const dynRe = /\bimport\s*\(\s*["']\s*(\.\/[^"']+)["']/g; | |
| 114 | + // Match against the original source so the captured import path | |
| 115 | + // text is preserved; but only accept matches whose start position | |
| 116 | + // is NOT inside a string-literal/comment region (we test that by | |
| 117 | + // checking the stripped source's character at the path-open quote). | |
| 118 | + const stripped = stripStringsAndComments(source); | |
| 119 | + const staticRe = /\bfrom\s+(["'])\s*(\.\/[^"']+)\1/g; | |
| 120 | + const dynRe = /\bimport\s*\(\s*(["'])\s*(\.\/[^"']+)\1/g; | |
| 63 | 121 | let m: RegExpExecArray | null; |
| 64 | - while ((m = staticRe.exec(source)) !== null) if (m[1]) out.push(m[1]); | |
| 65 | - while ((m = dynRe.exec(source)) !== null) if (m[1]) out.push(m[1]); | |
| 122 | + const pushIfReal = (mm: RegExpExecArray, pathIdx: number) => { | |
| 123 | + // Check the start of the match (the `f` of `from` or `i` of | |
| 124 | + // `import`). If that keyword is inside a string literal/comment | |
| 125 | + // in the original source, the stripped version replaces it with | |
| 126 | + // whitespace and we skip the match. The PATH itself is always | |
| 127 | + // inside quotes (that's how imports are written), so we never | |
| 128 | + // gate on the path's position — only the keyword's. | |
| 129 | + if (stripped[mm.index] === " " || stripped[mm.index] === "\n") return; | |
| 130 | + out.push(mm[pathIdx]!); | |
| 131 | + }; | |
| 132 | + while ((m = staticRe.exec(source)) !== null) pushIfReal(m, 2); | |
| 133 | + while ((m = dynRe.exec(source)) !== null) pushIfReal(m, 2); | |
| 66 | 134 | return out; |
| 67 | 135 | }; |
| 68 | 136 | |
| @@ -185,9 +253,16 @@ const checkModeled = (input: SamaVerifyInput): SamaCheckResult => { | ||
| 185 | 253 | // testing surface that Atomic owns. |
| 186 | 254 | const findPlaceholderTestsLite = (file: string, content: string): SamaViolation[] => { |
| 187 | 255 | const out: SamaViolation[] = []; |
| 256 | + // Same string/comment-aware trick as collectRelativeImports: only | |
| 257 | + // count test()/it() calls whose `test`/`it` keyword is real code, | |
| 258 | + // not a literal in a fixture. | |
| 259 | + const stripped = stripStringsAndComments(content); | |
| 188 | 260 | const re = /\b(test|it)\s*\(\s*(["'`])((?:\\.|(?!\2).)*)\2\s*,\s*(?:async\s+)?(?:\([^)]*\)|[^=()]*?)\s*=>\s*\{/g; |
| 189 | 261 | let m: RegExpExecArray | null; |
| 190 | 262 | while ((m = re.exec(content)) !== null) { |
| 263 | + // Skip matches whose `test`/`it` keyword is inside a string literal | |
| 264 | + // or comment (the stripped version replaces those with whitespace). | |
| 265 | + if (stripped[m.index] === " " || stripped[m.index] === "\n") continue; | |
| 191 | 266 | const name = m[3] ?? ""; |
| 192 | 267 | const startBrace = re.lastIndex - 1; |
| 193 | 268 | let depth = 1; |
src/c51_render_docs_layout.ts
+135
−0
| @@ -0,0 +1,135 @@ | ||
| 1 | +// c51 (docs-layout) — UI: GitBook-style chrome around the existing | |
| 2 | +// renderPage. Wraps content with a left sidebar (sections from | |
| 3 | +// SITE_NAV), a right "on this page" anchor rail (h2/h3 from the | |
| 4 | +// rendered body), an edit-on-GitHub link at the top of content, and | |
| 5 | +// a prev/next navigator at the bottom. Per SAMA: imports c31 (data), | |
| 6 | +// c32 (logic), and c51_render_layout (chrome). No I/O of its own. | |
| 7 | + | |
| 8 | +import { marked } from "marked"; | |
| 9 | +import { | |
| 10 | + SITE_NAV, | |
| 11 | + resolveDocsLocation, | |
| 12 | + type DocsNavLink, | |
| 13 | + type DocsNavSection, | |
| 14 | + type ResolvedDocsLocation, | |
| 15 | +} from "./c31_docs_nav.ts"; | |
| 16 | +import { extractAnchors, type Anchor } from "./c32_anchor_extract.ts"; | |
| 17 | +import { | |
| 18 | + renderPage, | |
| 19 | + escape, | |
| 20 | + type PageOptions, | |
| 21 | +} from "./c51_render_layout.ts"; | |
| 22 | + | |
| 23 | +const REPO_EDIT_BASE = "https://github.com/syntaxai/tdd.md/edit/main"; | |
| 24 | + | |
| 25 | +export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> { | |
| 26 | + // The route path the user is on, e.g. "/sama/sorted". Used to | |
| 27 | + // highlight the active sidebar entry and compute prev/next. | |
| 28 | + pathForDocs: string; | |
| 29 | + // Optional override of which file the "edit on GitHub" link | |
| 30 | + // targets, when the body isn't a content/<section>/<slug>.md. | |
| 31 | + // Defaults to the editPath from the resolved nav location. | |
| 32 | + editPathOverride?: string | null; | |
| 33 | +} | |
| 34 | + | |
| 35 | +const sidebarLink = (link: DocsNavLink, current: string): string => { | |
| 36 | + const cls = link.href === current ? "docs-side-link docs-side-link-active" : "docs-side-link"; | |
| 37 | + return `<li><a class="${cls}" href="${link.href}">${escape(link.label)}</a></li>`; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +const renderSidebar = (currentPath: string): string => { | |
| 41 | + const sections = SITE_NAV.map((section: DocsNavSection) => { | |
| 42 | + const items = section.links.map((l) => sidebarLink(l, currentPath)).join(""); | |
| 43 | + const sectionCls = section.links.some((l) => l.href === currentPath) | |
| 44 | + ? "docs-side-section docs-side-section-active" | |
| 45 | + : "docs-side-section"; | |
| 46 | + return `<div class="${sectionCls}"> | |
| 47 | + <p class="docs-side-title"><a href="${section.rootHref}">${escape(section.title)}</a></p> | |
| 48 | + <ul class="docs-side-list">${items}</ul> | |
| 49 | +</div>`; | |
| 50 | + }).join("\n"); | |
| 51 | + return `<aside class="docs-sidebar" aria-label="documentation navigation"> | |
| 52 | +${sections} | |
| 53 | +</aside>`; | |
| 54 | +}; | |
| 55 | + | |
| 56 | +const renderAnchorRail = (anchors: Anchor[]): string => { | |
| 57 | + if (anchors.length === 0) return ""; | |
| 58 | + const items = anchors | |
| 59 | + .map((a) => { | |
| 60 | + const cls = a.level === 3 ? "docs-rail-link docs-rail-link-h3" : "docs-rail-link"; | |
| 61 | + return `<li><a class="${cls}" href="#${escape(a.id)}">${escape(a.text)}</a></li>`; | |
| 62 | + }) | |
| 63 | + .join(""); | |
| 64 | + return `<aside class="docs-rail" aria-label="on this page"> | |
| 65 | + <p class="docs-rail-title">on this page</p> | |
| 66 | + <ul class="docs-rail-list">${items}</ul> | |
| 67 | +</aside>`; | |
| 68 | +}; | |
| 69 | + | |
| 70 | +const renderEditLink = (editPath: string | null): string => { | |
| 71 | + if (!editPath) return ""; | |
| 72 | + const url = `${REPO_EDIT_BASE}/${editPath}`; | |
| 73 | + return `<p class="docs-edit"><a href="${escape(url)}" rel="noopener" target="_blank">edit this page on GitHub →</a></p>`; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +const renderPrevNext = (loc: ResolvedDocsLocation | null): string => { | |
| 77 | + if (!loc) return ""; | |
| 78 | + const prev = loc.prev | |
| 79 | + ? `<a class="docs-pn-prev" href="${loc.prev.href}"><span class="docs-pn-arrow">←</span><span class="docs-pn-label">${escape(loc.prev.label)}</span></a>` | |
| 80 | + : `<span class="docs-pn-spacer"></span>`; | |
| 81 | + const next = loc.next | |
| 82 | + ? `<a class="docs-pn-next" href="${loc.next.href}"><span class="docs-pn-label">${escape(loc.next.label)}</span><span class="docs-pn-arrow">→</span></a>` | |
| 83 | + : `<span class="docs-pn-spacer"></span>`; | |
| 84 | + return `<nav class="docs-prev-next" aria-label="previous and next page">${prev}${next}</nav>`; | |
| 85 | +}; | |
| 86 | + | |
| 87 | +// Wrap a heading element with an anchor link for hover-click access. | |
| 88 | +// Also injects an `id` if marked didn't (rare with our config but | |
| 89 | +// possible). Operates on the rendered HTML before composing the page. | |
| 90 | +const enrichHeadings = (html: string): string => | |
| 91 | + html.replace( | |
| 92 | + /<h([23])(\s+[^>]*)?>([\s\S]*?)<\/h\1>/g, | |
| 93 | + (_full, level, attrs, inner) => { | |
| 94 | + const idMatch = /\bid="([^"]+)"/.exec(attrs ?? ""); | |
| 95 | + const id = idMatch?.[1] ?? inner.replace(/<[^>]*>/g, "").toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-"); | |
| 96 | + const finalAttrs = idMatch ? attrs : `${attrs ?? ""} id="${id}"`; | |
| 97 | + return `<h${level}${finalAttrs}><a class="docs-h-anchor" href="#${id}" aria-label="link to this section">#</a>${inner}</h${level}>`; | |
| 98 | + }, | |
| 99 | + ); | |
| 100 | + | |
| 101 | +export const renderDocsPage = async (opts: DocsPageOptions): Promise<string> => { | |
| 102 | + const rawHtml = opts.bodyMarkdown | |
| 103 | + ? await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false }) | |
| 104 | + : ""; | |
| 105 | + const enriched = enrichHeadings(rawHtml); | |
| 106 | + const anchors = extractAnchors(enriched); | |
| 107 | + const loc = resolveDocsLocation(opts.pathForDocs); | |
| 108 | + const editPath = opts.editPathOverride !== undefined ? opts.editPathOverride : loc?.current.editPath ?? null; | |
| 109 | + | |
| 110 | + const sidebar = renderSidebar(opts.pathForDocs); | |
| 111 | + const rail = renderAnchorRail(anchors); | |
| 112 | + const editLink = renderEditLink(editPath); | |
| 113 | + const prevNext = renderPrevNext(loc); | |
| 114 | + | |
| 115 | + const composed = `<div class="docs-layout"> | |
| 116 | +${sidebar} | |
| 117 | +<article class="docs-content"> | |
| 118 | +${editLink} | |
| 119 | +${enriched} | |
| 120 | +${prevNext} | |
| 121 | +</article> | |
| 122 | +${rail} | |
| 123 | +</div>`; | |
| 124 | + | |
| 125 | + return renderPage({ | |
| 126 | + title: opts.title, | |
| 127 | + bodyHtml: composed, | |
| 128 | + description: opts.description, | |
| 129 | + ogPath: opts.ogPath, | |
| 130 | + active: opts.active, | |
| 131 | + noindex: opts.noindex, | |
| 132 | + jsonLd: opts.jsonLd, | |
| 133 | + bodyClass: "docs-body", | |
| 134 | + }); | |
| 135 | +}; | |
src/c51_render_layout.ts
+10
−3
| @@ -15,12 +15,18 @@ export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderb | ||
| 15 | 15 | |
| 16 | 16 | export interface PageOptions { |
| 17 | 17 | title: string; |
| 18 | - bodyMarkdown: string; | |
| 18 | + // Provide either bodyMarkdown (parsed by marked) or bodyHtml | |
| 19 | + // (passed through as-is). bodyHtml is what the docs layout uses | |
| 20 | + // when it has already done its own marked.parse and wrapped the | |
| 21 | + // result in sidebar/content/anchor-rail chrome. | |
| 22 | + bodyMarkdown?: string; | |
| 23 | + bodyHtml?: string; | |
| 19 | 24 | description?: string; |
| 20 | 25 | ogPath?: string; |
| 21 | 26 | active?: Section; |
| 22 | 27 | noindex?: boolean; |
| 23 | 28 | jsonLd?: Record<string, unknown>; |
| 29 | + bodyClass?: string; | |
| 24 | 30 | } |
| 25 | 31 | |
| 26 | 32 | 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 => { | ||
| 36 | 42 | const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`; |
| 37 | 43 | |
| 38 | 44 | export const renderPage = async (opts: PageOptions): Promise<string> => { |
| 39 | - const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false }); | |
| 45 | + const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false }); | |
| 40 | 46 | const description = opts.description ?? SITE_DESCRIPTION; |
| 47 | + const bodyClassAttr = opts.bodyClass ? ` class="${escape(opts.bodyClass)}"` : ""; | |
| 41 | 48 | const ogPath = opts.ogPath ?? "https://tdd.md"; |
| 42 | 49 | const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : ""; |
| 43 | 50 | const jsonLd = opts.jsonLd |
| @@ -67,7 +74,7 @@ ${robots}<link rel="canonical" href="${escape(ogPath)}"> | ||
| 67 | 74 | <title>${escape(opts.title)}</title> |
| 68 | 75 | ${jsonLd}<style>${css}</style> |
| 69 | 76 | </head> |
| 70 | -<body> | |
| 77 | +<body${bodyClassAttr}> | |
| 71 | 78 | ${nav(opts.active)} |
| 72 | 79 | <main class="md"> |
| 73 | 80 | ${body} |