de4091196cfbc279ebe47c5a419e22fbb4fe6ea7 diff --git a/bun.lock b/bun.lock index dae0f333547d6966d3eb55959543766ad6d77676..12f0567b392ca74dfb4b470f6b75e3ae3dc0d304 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "tdd.md", "dependencies": { "marked": "^14.1.4", + "node-html-parser": "^7.0.1", }, "devDependencies": { "@playwright/test": "^1.59.1", @@ -20,12 +21,34 @@ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "marked": ["marked@14.1.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="], + "node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], diff --git a/content/home.md b/content/home.md index c5a2ab984ad1a551d77405646ce9f57203062fed..bcc2fc6b4e9980a9d2cc9e90af076a46d74eba73 100644 --- a/content/home.md +++ b/content/home.md @@ -1,96 +1,41 @@ -# tdd.md +# SAMA -> Test-driven development for agentic coding. Practice on scored katas. The judge replays your AI agent's commits against hidden tests it owns, and posts a public verdict — not a grade for life, a snapshot of the discipline you showed on this run. +> The architectural standard for AI-agent codebases. -**Using a specific agent? Go straight to the workflow:** +SAMA is to agent-written code what Conventional Commits is to git history: a small, named, verifiable standard your CI enforces so your AI coding agents stop drifting. -- [TDD with **Claude Code** →](/guides/claude-code) · setup, prompts, common mistakes -- [TDD with **Cursor** →](/guides/cursor) · Composer-per-phase, project rules, agent-mode caveats -- [TDD with **Aider** →](/guides/aider) · auto-commit phase tags, --auto-test gotchas +**Four pillars. One verifier. Zero ambiguity for your agent.** -See what a real verdict looks like: [tdd.md/demo/string-calc →](/demo/string-calc). Posts: [Tweag's agentic TDD handbook needs a judge →](/blog/tweag-handbook-tdd) · [Claude Code does not do TDD by default →](/blog/claude-code-tdd) · [Cursor knows how to do TDD →](/blog/cursor-tdd) · [Aider is the closest to TDD on rails →](/blog/aider-tdd). +## The four pillars ---- +- **[S — Sorted.](/sama/sorted)** Lexicographic file order equals import direction. The dependency graph is the file tree. +- **[A — Architecture.](/sama/architecture)** Every file's prefix maps to one layer with explicit allowed/forbidden contents. No rogue files. +- **[M — Modeled.](/sama/modeled)** Every behavior file has a sibling test. Every external input is parsed at the boundary, never cast. +- **[A — Atomic.](/sama/atomic)** Files cap at ~700 lines. Split per domain, never via barrel re-exports. -## premise +Read the full discussion in [/sama](/sama). The standalone, language-agnostic [v1.0 specification](https://tdd.md/sama) lives in its own repo so other ecosystems can adopt SAMA without depending on this site. -Agentic coding is here. The interesting question isn't *can* an AI agent ship code (it can). It's whether your agent can do it *well*: writing the test first, keeping the impl honest, refactoring without regression. tdd.md is the place to practice and prove that — with a judge strict enough to be useful, and modes flexible enough to match how you actually work. +## What SAMA is not -## why - -Strict TDD isn't always right. It is right when: - -- **Behavior matters more than code shape** — libraries, business rules, parsers, anything that'll be called often and has to keep working. -- **Regressions are expensive** — a bug in production costs more than the test took. -- **The interface is unclear** — writing the test first forces design from the caller's view, not the implementer's. - -It's not always right: - -- **You're spiking.** Exploring how an unknown library or API behaves. Tests come *after* the spike, when you know what you're looking for. -- **Visual or interactive design dominates.** UI tweaks need eyes, not assertions. -- **The work is throwaway.** Research scripts, one-shots, prototypes you'll discard. - -tdd.md grades you on the discipline. It doesn't claim every line of code in your career should be reached this way. It claims: when behavior matters, this is how you prove your agent did the engineering, not just the typing. - -That's why three modes exist. Pick the one that matches what you're trying to prove. - -## modes - -| mode | use when | judge behaviour | +| | What it does | Where SAMA differs | |---|---|---| -| **strict** | demonstrating discipline | full rules, full penalties; combined red+green is rejected | -| **pragmatic** | doing real work, Kent-Beck-circa-2018 style | combined red+green is allowed (single commit OK), penalties softened | -| **learning** | new to TDD or to this agent | no negative scores, only positive credit + explanations of what you missed | - -Set the mode in your repo with a one-line `tdd.config.json`: - -``` -{ "mode": "pragmatic" } -``` - -Default is `strict`. - -## principles (strict mode) - -What strict-mode TDD actually requires — and what each principle costs if you skip it: - -1. **Test first.** No code without a failing test driving it. Red commits whose tests already pass mean the impl was earlier. -2. **Honest green.** The simplest code that passes. Green commits whose tests still fail aren't honest. -3. **Authoritative verification.** Your own tests aren't enough — they could be tautological. tdd.md owns hidden tests per kata step and runs them against your impl after green. -4. **Tests don't disappear.** Once written, they stay. Refactors don't delete them. -5. **Refactor without regression.** Refactor commits run against the existing tests. Green-stays-green. -6. **Phases machine-tagged.** Commit messages start with `red:`, `green:`, `refactor:`, or `spike:` (optionally with `(step)`). The judge replays from the git log alone. -7. **Public, replayable verdicts.** Every run is a permanent URL at `tdd.md//`. Anyone can audit; nothing hidden. - -Pragmatic mode keeps 3, 4, 5, 6, 7 strict and softens 1, 2. Learning mode keeps the same checks but never punishes — only annotates. - -## the cycle +| [SWE-bench](https://www.swebench.com/) | Scores agents on real GitHub issues | SAMA scores **codebases**, not agents | +| [AGENTS.md](https://agents.md/) | Tells the agent what to do, in markdown | SAMA constrains what the **code** can be | +| [Factory.ai Agent Readiness](https://factory.ai/news/agent-readiness) | 8-pillar repo maturity scorecard | SAMA enforces **four** rules with a binary CI gate | +| [Tweag Agentic Handbook](https://tweag.github.io/agentic-coding-handbook/) | Describes patterns that work | SAMA **prescribes** — and verifies | -| phase | rule | -|---|---| -| **red** | Write a test that fails for the right reason. | -| **green** | Write the simplest code that makes it pass. | -| **refactor** | Improve the code without breaking the test. | -| `spike` | Explore freely. Spike commits don't score and don't penalize — they leave a trail of what you tried before the discipline kicked in. | +SAMA composes with all of them. Use AGENTS.md to instruct the agent and SAMA to shape the code; use Factory's scorecard for breadth and SAMA for depth on the architectural pillar; run SWE-bench to grade the agent and SAMA to grade what the agent left behind. -## scoring (strict mode) +## Why this matters -``` -+20 step verified — red fails, green passes, hidden tests pass - +5 refactor commit, tests stay green - 0 spike commit (exploration acknowledged, not graded) - 0 hidden tests catch a tautological green - -5 red passes already (impl was earlier) or green still fails - -5 refactor breaks tests --20 test count drops between red and green (deletion) -``` +LLMs degrade as input context grows. Chroma's [Context Rot research](https://research.trychroma.com/context-rot) shows the effect across all 18 frontier models tested, well within their advertised windows. Aider's [repo-map](https://aider.chat/docs/repomap.html) — structural, not semantic — operates at 4–7% context utilization while semantic indexers spend 14%+ on the same task. Multiple practitioner studies converge on a 150–500 LOC sweet spot per file for AI editors. -Pragmatic mode halves the negatives and accepts combined red+green commits. Learning mode floors all negatives at 0 and adds an explanation per step. +SAMA bundles those findings into four constraints a CI job can enforce. *Sorted* makes structural retrieval cheap. *Atomic* keeps every file inside the agent's working set. *Modeled* makes every change reviewable by its sibling test. *Architecture* lets the agent answer "where does this go?" without re-deriving the tree each session. -## play +## See it in practice -1. [Sign in with GitHub →](/you) — registers a new agent on your first visit, signs you back in to your dashboard on returns -2. [Pick a kata →](/games) — start with `string-calc` -3. Push commits tagged `red:` / `green:` / `refactor:` and watch your verdict land at `tdd.md//` +- **[Pick a kata →](/games)** — small codebases that get scored against SAMA, with public verdicts per agent run. +- **[Leaderboard →](/leaderboard)** — current standings across registered agents. +- **[Blog →](/blog)** — what the runs revealed about Claude Code, Cursor, and Aider. -Using a specific tool? Read the agent-specific walkthroughs in [/guides](/guides): [Claude Code](/guides/claude-code), [Cursor](/guides/cursor), [Aider](/guides/aider). +Agent-specific walkthroughs: [Claude Code](/guides/claude-code) · [Cursor](/guides/cursor) · [Aider](/guides/aider). diff --git a/package.json b/package.json index 81bddb6a559c27b9e3b3c287ad616cc08b02d44e..eec2b6fe54cc64350f9114befa3abe265ca28a5c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "e2e:headed": "playwright test --headed" }, "dependencies": { - "marked": "^14.1.4" + "marked": "^14.1.4", + "node-html-parser": "^7.0.1" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/public/style.css b/public/style.css index 9a61701fb4bc9bc534cd4f59f8fdcabdf18e8722..59154cc310c403e655b9b211d1777afe0c838ba0 100644 --- a/public/style.css +++ b/public/style.css @@ -509,10 +509,9 @@ main.md table.test-stability td.test-stab-num { .project-form-error strong { color: var(--red); } /* ----------------------------------------------------------------- - Docs layout — GitBook-style sidebar + content + on-this-page rail. + Docs layout — content + on-this-page rail. Used by /sama/*, /guides/*, /blog/* via renderDocsPage. Mobile - stacks vertically; sidebar collapses behind a details/summary on - narrow viewports. + drops the rail entirely. ----------------------------------------------------------------- */ .docs-body main.md { @@ -522,15 +521,14 @@ main.md table.test-stability td.test-stab-num { .docs-layout { display: grid; - grid-template-columns: 240px minmax(0, 1fr) 220px; + grid-template-columns: minmax(0, 1fr) 220px; gap: 2rem; - max-width: 1400px; + max-width: 1100px; margin: 0 auto; padding: 1rem 1.5rem 4rem; align-items: start; } -.docs-sidebar, .docs-rail { position: sticky; top: 1rem; @@ -540,49 +538,6 @@ main.md table.test-stability td.test-stab-num { 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; @@ -680,24 +635,10 @@ main.md table.test-stability td.test-stab-num { .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; - } + .docs-rail { display: none; } } /* ----------------------------------------------------------------- @@ -1005,3 +946,259 @@ main.md table.test-stability td.test-stab-num { .commit-meta { grid-template-columns: 1fr; gap: 0.1rem; } .commit-meta dt { margin-top: 0.4rem; } } + +/* ---- /GIT///tree|blob/... ---- */ +.repo-tree-table { + width: 100%; + border-collapse: collapse; + margin: 0.4rem 0 1rem; + font-size: 0.88rem; +} +.repo-tree-row td { + padding: 0.35rem 0.6rem; + border-bottom: 1px solid color-mix(in srgb, var(--muted) 14%, transparent); +} +.repo-tree-row:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); } +.repo-tree-icon { + width: 1.6rem; + text-align: center; + user-select: none; +} +.repo-tree-name a { text-decoration: none; } +.repo-tree-name a:hover { text-decoration: underline; } +.repo-tree-row-tree .repo-tree-name a { font-weight: 600; } +.repo-tree-sha { + color: var(--muted); + text-align: right; + width: 5rem; +} +.repo-tree-row-up { background: color-mix(in srgb, var(--muted) 5%, transparent); } + +.repo-blob-header { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.8rem; + background: color-mix(in srgb, var(--muted) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent); + border-radius: 6px 6px 0 0; + border-bottom: none; + font-size: 0.85rem; + flex-wrap: wrap; +} +.repo-blob-path { font-weight: 600; } +.repo-blob-meta { color: var(--muted); font-size: 0.82rem; } +.repo-blob-actions { margin-left: auto; } +.repo-blob-source { + margin: 0; + padding: 0.8rem; + border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent); + border-radius: 0 0 6px 6px; + background: color-mix(in srgb, var(--muted) 5%, transparent); + font-family: var(--font-mono, ui-monospace, "SF Mono", monospace); + font-size: 0.8rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-x: auto; + max-height: 75vh; +} +.repo-blob-rendered { + padding: 1rem 1.2rem; + border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent); + border-radius: 0 0 6px 6px; + border-top: none; +} + +.commit-meta-pill { + display: inline-block; + padding: 0.05rem 0.4rem; + border-radius: 3px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + text-decoration: none; +} + +/* ----------------------------------------------------------------- + admin sxdoc block editor (Fase 2 of podman→tdd.md CMS port) + ----------------------------------------------------------------- */ + +.admin-form { + display: grid; + gap: 0.9rem; + max-width: 60rem; +} +.admin-field { display: grid; gap: 0.25rem; font-size: 0.85rem; } +.admin-field > span { color: var(--muted); } +.admin-field input, +.admin-field select, +.admin-field textarea { + font: inherit; + padding: 0.35rem 0.55rem; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; +} +.admin-field textarea { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85rem; } +.admin-row { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.75rem; } +.admin-actions { display: flex; gap: 0.5rem; align-items: center; } +.admin-cancel { color: var(--muted); } +.admin-error { + padding: 0.5rem 0.75rem; + background: color-mix(in srgb, #ff5555 12%, transparent); + border: 1px solid color-mix(in srgb, #ff5555 50%, transparent); + border-radius: 4px; + color: #ff8080; + font-size: 0.85rem; +} +.admin-delete { + background: transparent; + color: #ff8080; + border: 1px solid color-mix(in srgb, #ff5555 40%, transparent); + padding: 0.35rem 0.7rem; + border-radius: 4px; + cursor: pointer; +} + +.block-editor { + display: grid; + gap: 0.3rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background: color-mix(in srgb, var(--bg) 85%, var(--fg) 4%); + min-height: 12rem; +} +.block-editor-mode { + margin-bottom: 0.4rem; + background: transparent; + color: var(--muted); + border: 1px solid var(--border); + padding: 0.2rem 0.5rem; + border-radius: 3px; + font-size: 0.75rem; + cursor: pointer; +} +.block-editor-raw { font-family: ui-monospace, monospace; } +.block { + display: grid; + grid-template-columns: 1.5rem 1fr auto; + gap: 0.4rem; + padding: 0.2rem 0.3rem; + border-radius: 3px; + align-items: start; +} +.block:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); } +.block-handle { color: var(--muted); cursor: grab; user-select: none; padding-top: 0.4rem; } +.block-body :is(p, h1, h2, h3, h4, h5, h6, blockquote, li) { + margin: 0; + outline: none; + min-height: 1.4em; +} +.block-body :is(p, h1, h2, h3, h4, h5, h6, blockquote, li)[data-placeholder]:empty::before { + content: attr(data-placeholder); + color: color-mix(in srgb, var(--muted) 60%, transparent); +} +.block-actions { opacity: 0; transition: opacity 100ms; } +.block:hover .block-actions { opacity: 1; } +.block-delete { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + width: 1.5rem; height: 1.5rem; + border-radius: 3px; + cursor: pointer; +} +.block-empty { color: var(--muted); padding: 0.5rem 0; } +.block-empty-hint { margin: 0 0 0.3rem; font-size: 0.85rem; } +.block-insert { + display: flex; + justify-content: flex-start; + padding-left: 1.9rem; + margin: -0.1rem 0; + height: 0; + overflow: visible; + opacity: 0; + transition: opacity 80ms; +} +.block-editor:hover .block-insert { opacity: 0.5; } +.block-insert:hover { opacity: 1; } +.block-insert-btn { + background: transparent; + border: 1px dashed var(--border); + color: var(--muted); + width: 1.5rem; height: 1.5rem; + border-radius: 3px; + cursor: pointer; +} + +.code-shell, .img-shell, .shortcode-shell, .hr-shell { display: grid; gap: 0.3rem; } +.code-shell .code-lang { font-size: 0.75rem; max-width: 14rem; } +.code-shell .code-src, +.html-shell { + font-family: ui-monospace, monospace; + font-size: 0.82rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.4rem 0.6rem; + color: var(--fg); + width: 100%; +} +.img-row { display: grid; grid-template-columns: 5rem 1fr; gap: 0.4rem; align-items: center; } +.img-row span { color: var(--muted); font-size: 0.75rem; } +.img-preview { max-width: 24rem; border: 1px solid var(--border); border-radius: 3px; } +.hr-shell hr { border: none; border-top: 1px solid var(--border); margin: 0.5rem 0; } + +.slash-menu { + position: fixed; + z-index: 1000; + min-width: 18rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + padding: 0.3rem; + display: grid; + gap: 0.2rem; +} +.slash-menu-filter { + font: inherit; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + padding: 0.3rem 0.4rem; + color: var(--fg); + outline: none; +} +.slash-menu-list { list-style: none; margin: 0; padding: 0; max-height: 16rem; overflow-y: auto; } +.slash-menu-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.5rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} +.slash-menu-item.highlighted { background: color-mix(in srgb, var(--accent) 15%, transparent); } +.slash-menu-label { color: var(--fg); } +.slash-menu-hint { color: var(--muted); font-size: 0.75rem; } +.slash-menu-empty { color: var(--muted); padding: 0.4rem 0.5rem; font-size: 0.85rem; } + +.block-editor-toast { + position: fixed; + bottom: 1rem; right: 1rem; + padding: 0.45rem 0.8rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.85rem; + opacity: 0; + transition: opacity 200ms; + pointer-events: none; + z-index: 999; +} +.block-editor-toast-show { opacity: 1; } +.block-editor-toast-ok { color: #6fdc6f; border-color: color-mix(in srgb, #6fdc6f 40%, transparent); } +.block-editor-toast-error { color: #ff8080; border-color: color-mix(in srgb, #ff5555 40%, transparent); } diff --git a/src/c13_database.ts b/src/c13_database.ts index 40d74d439f1749c390eb6150d0d9845ea5158dd4..ad42187237ba55b578d242491fe06586897f3888 100644 --- a/src/c13_database.ts +++ b/src/c13_database.ts @@ -1,5 +1,7 @@ import { Database } from "bun:sqlite"; import type { ProjectConfig, TestRunner } from "./c31_project_config.ts"; +import type { SxDocument } from "./c31_sxdoc.ts"; +import { SX_DOC_VERSION } from "./c31_sxdoc.ts"; const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:"; @@ -35,6 +37,23 @@ const getDb = (): Database => { ); CREATE INDEX IF NOT EXISTS idx_projects_registered_by ON projects(registered_by); + + CREATE TABLE IF NOT EXISTS sx_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + doc_json TEXT NOT NULL, + doc_version INTEGER NOT NULL, + hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'published', + primary_tag TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(slug, type) + ); + CREATE INDEX IF NOT EXISTS idx_sx_documents_type_updated + ON sx_documents(type, updated_at DESC); `); // Note: a `proposals` table existed in earlier versions of this CMS @@ -204,6 +223,153 @@ export const listActiveProjects = (): ProjectRow[] => { return rows.map(rowToProject); }; +// ─── sx_documents ──────────────────────────────────────────────────────── +// Canonical store for sxdoc-backed content (pages + posts). Sibling git +// commits in content/{slug}.{md,sxdoc.json} mirror this table for audit; +// the SQLite row is the source of truth (canon B, locked in plan.md). + +export interface SxDocumentRow { + id: number; + slug: string; + type: "page" | "post"; + title: string; + doc: SxDocument; + status: "published" | "draft"; + primaryTag: string | null; + createdAt: number; + updatedAt: number; +} + +export interface SxDocumentSummary { + id: number; + slug: string; + type: "page" | "post"; + title: string; + status: "published" | "draft"; + primaryTag: string | null; + updatedAt: number; +} + +interface SxDocumentDbRow { + id: number; + slug: string; + type: string; + title: string; + doc_json: string; + doc_version: number; + hash: string; + status: string; + primary_tag: string | null; + created_at: number; + updated_at: number; +} + +interface SxDocumentSummaryDbRow { + id: number; + slug: string; + type: string; + title: string; + status: string; + primary_tag: string | null; + updated_at: number; +} + +const hashDoc = (json: string): string => { + const h = new Bun.CryptoHasher("sha1"); + h.update(json); + return h.digest("hex").slice(0, 16); +}; + +const rowToSxDocument = (r: SxDocumentDbRow): SxDocumentRow => ({ + id: r.id, + slug: r.slug, + type: r.type === "post" ? "post" : "page", + title: r.title, + doc: JSON.parse(r.doc_json) as SxDocument, + status: r.status === "draft" ? "draft" : "published", + primaryTag: r.primary_tag, + createdAt: r.created_at, + updatedAt: r.updated_at, +}); + +// Upsert a sxdoc keyed by (slug, type). created_at is preserved on +// updates so we can sort by first-publish elsewhere if needed. +export const saveDocument = (input: { + slug: string; + type: "page" | "post"; + title: string; + doc: SxDocument; + status?: "published" | "draft"; + primaryTag?: string | null; +}): void => { + const now = Date.now(); + const json = JSON.stringify(input.doc); + const hash = hashDoc(json); + const status = input.status ?? "published"; + const primaryTag = input.primaryTag ?? null; + getDb().run( + `INSERT INTO sx_documents + (slug, type, title, doc_json, doc_version, hash, status, primary_tag, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(slug, type) DO UPDATE SET + title = excluded.title, + doc_json = excluded.doc_json, + doc_version = excluded.doc_version, + hash = excluded.hash, + status = excluded.status, + primary_tag = excluded.primary_tag, + updated_at = excluded.updated_at`, + [input.slug, input.type, input.title, json, SX_DOC_VERSION, hash, status, primaryTag, now, now], + ); +}; + +export const loadDocument = (slug: string, type: "page" | "post"): SxDocumentRow | null => { + const row = getDb() + .query( + `SELECT * FROM sx_documents WHERE slug = ? AND type = ?`, + ) + .get(slug, type); + return row ? rowToSxDocument(row) : null; +}; + +export const deleteDocument = (slug: string, type: "page" | "post"): number => { + const r = getDb().run( + `DELETE FROM sx_documents WHERE slug = ? AND type = ?`, + [slug, type], + ); + return r.changes; +}; + +// Summary rows for the admin list / archive pages. Excludes doc_json so +// listing a thousand documents doesn't drag a megabyte of JSON through +// the query layer. +export const listDocuments = (filter: { + type?: "page" | "post"; + status?: "published" | "draft"; +} = {}): SxDocumentSummary[] => { + const where: string[] = []; + const params: string[] = []; + if (filter.type) { where.push("type = ?"); params.push(filter.type); } + if (filter.status) { where.push("status = ?"); params.push(filter.status); } + const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : ""; + const rows = getDb() + .query( + `SELECT id, slug, type, title, status, primary_tag, updated_at + FROM sx_documents ${whereClause} + ORDER BY updated_at DESC`, + ) + .all(...params); + return rows.map((r) => ({ + id: r.id, + slug: r.slug, + type: r.type === "post" ? "post" : "page", + title: r.title, + status: r.status === "draft" ? "draft" : "published", + primaryTag: r.primary_tag, + updatedAt: r.updated_at, + })); +}; + // Latest verdict per (owner, repo) across all agents — drives the // leaderboard and the /agents index. export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { diff --git a/src/c14_client_bundle.ts b/src/c14_client_bundle.ts new file mode 100644 index 0000000000000000000000000000000000000000..75e014ef79801c340964aaa3bc272b0912fcf966 --- /dev/null +++ b/src/c14_client_bundle.ts @@ -0,0 +1,72 @@ +// c14 — secondary I/O: in-process Bun.build bundler for the admin +// client-side TS. Memoised so a route handler can call bundleAdminClient() +// on every request without repeating the build. +// +// SAMA placement: c14 (not c13) because Bun.build IS external-process +// I/O — it spawns transformers, reads/writes intermediate buffers. Not +// the same flavour as the c14_github/c14_forgejo HTTP clients but the +// same architectural concern: I/O against a non-SQLite subsystem. +// +// Knobs: +// TDD_DEV=1 — force a rebuild on every call (handy in `bun --hot`). + +import { join, dirname, resolve } from "node:path"; + +const isDev = process.env.TDD_DEV === "1"; + +let cached: { code: string; etag: string } | null = null; +let inFlight: Promise<{ code: string; etag: string }> | null = null; + +const ENTRYPOINT = "./src/client/blockeditor.ts"; + +const buildBundle = async (): Promise<{ code: string; etag: string }> => { + const result = await Bun.build({ + entrypoints: [ENTRYPOINT], + target: "browser", + format: "esm", + minify: false, + // We don't ship sourcemaps yet — the file is small enough to read + // directly when something goes wrong. + }); + if (!result.success) { + const msgs = result.logs.map((l) => l.message).join("; "); + throw new Error(`admin client bundle failed: ${msgs}`); + } + const first = result.outputs[0]; + if (!first) throw new Error("admin client bundle produced no output"); + const code = await first.text(); + // Cheap content-derived etag — Bun.CryptoHasher matches the pattern in + // c13_database.hashDoc. + const h = new Bun.CryptoHasher("sha1"); + h.update(code); + const etag = `"${h.digest("hex").slice(0, 16)}"`; + return { code, etag }; +}; + +export const bundleAdminClient = async (): Promise<{ code: string; etag: string }> => { + if (!isDev && cached) return cached; + // Coalesce concurrent callers so we don't run two builds in parallel + // (Bun.build is not free; the first request after boot triggers it). + if (inFlight) return inFlight; + inFlight = (async () => { + try { + const built = await buildBundle(); + cached = built; + return built; + } finally { + inFlight = null; + } + })(); + return inFlight; +}; + +// Drop the cache. Wired to nothing yet — useful as an internal endpoint +// later if we add a /admin/reload-bundle hook for the dev loop. +export const _resetAdminClientCache = (): void => { + cached = null; +}; + +// Resolve the entry-point path relative to the repo root so callers can +// verify the file exists. Kept here so c14 owns the on-disk pathing. +export const adminClientEntrypoint = (): string => + resolve(join(dirname(new URL(import.meta.url).pathname), "..", ENTRYPOINT)); diff --git a/src/c14_git.ts b/src/c14_git.ts index 8dfc4bbd24192bf1e46a8b4fc8b0a61397f3fcb9..94af6651ed3e29aa2ea6e569562e1526912110fb 100644 --- a/src/c14_git.ts +++ b/src/c14_git.ts @@ -101,6 +101,43 @@ export const readBlob = async (sha: string): Promise => { return await runGitOk(["cat-file", "-p", sha]); }; +// Read a file's contents at :. Returns null when the path +// doesn't exist at that ref. UTF-8 — c14_git doesn't try to handle +// binary content (the site is markdown-only). +export const readBlobAtRef = async (ref: string, path: string): Promise => { + const r = await runGit(["show", `${ref}:${path}`]); + if (r.exitCode !== 0) return null; + return r.stdout; +}; + +// List a directory at :. Empty string for path = root of tree. +// Returns null when the path doesn't exist at that ref. Each entry +// keeps the relative name (basename), not the full path — the caller +// builds full paths from `${path}/${entry.name}`. +export interface TreeEntry { + name: string; // basename, e.g. "skill.md" or "blog" + type: "blob" | "tree" | "commit"; + sha: string; + mode: string; +} +export const lsTree = async (ref: string, path: string): Promise => { + // `:` — git lists what's at that tree. For path="" it's + // the repo root. + const target = path === "" ? ref : `${ref}:${path}`; + const r = await runGit(["ls-tree", target]); + if (r.exitCode !== 0) return null; + return r.stdout + .split("\n") + .map((line) => parseLsTreeLine(line)) + .filter((e): e is NonNullable => e !== null) + .map((e) => ({ + name: e.path, // ls-tree without -r emits basename + type: e.type, + sha: e.sha, + mode: e.mode, + })); +}; + // Detail for a single commit (one parsed GitCommit). Returns null on // missing — same shape as c14_forgejo.getCommitDetail used to expose. export const getCommit = async (sha: string): Promise => { diff --git a/src/c21_app.ts b/src/c21_app.ts index f06f2615197ee06851490acc5916225208635bd3..b19f97fcbf40fa37efb61f8feb3c9be16b38e3a0 100644 --- a/src/c21_app.ts +++ b/src/c21_app.ts @@ -58,18 +58,30 @@ import { samaSlugHandler, } from "./c21_handlers_sama.ts"; import { editPageHandler } from "./c21_handlers_edit.ts"; +import { + adminListHandler, + adminNewHandler, + adminEditHandler, + adminDeleteHandler, +} from "./c21_handlers_admin.ts"; +import { bundleAdminClient } from "./c14_client_bundle.ts"; +import { publicPageHandler, renderPublicPage } from "./c21_handlers_content.ts"; import { rawSourceHandler } from "./c21_handlers_source.ts"; import { commitViewHandler } from "./c21_handlers_commit_view.ts"; +import { + parseRepoBrowsePath, + repoBrowseHandler, +} from "./c21_handlers_repo_browse.ts"; const HOME_MD = "./content/home.md"; const GAME_DIR = "./content/games"; const HOME_DESCRIPTION = - "Test-driven development for agentic coding. Your AI agent practices on scored katas; the judge replays its commits against hidden tests and posts a public verdict on the discipline."; + "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic: four pillars your CI verifier enforces so your AI coding agents stop drifting."; const homeBody = await Bun.file(HOME_MD).text(); const HOME_HTML = await renderPage({ - title: "tdd.md — TDD for agentic coding", + title: "SAMA — the architectural standard for AI-agent codebases", description: HOME_DESCRIPTION, bodyMarkdown: homeBody, active: "home", @@ -173,10 +185,42 @@ const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => { }; // Fallback handler — git-protocol proxy, bare-repo /:owner/:repo view, -// and /:owner/:repo.git redirects. Mounted as `fetch` on Bun.serve. +// admin multi-segment slugs, and /:owner/:repo.git redirects. Mounted as +// `fetch` on Bun.serve. const appFetch = async (req: Request): Promise => { const url = new URL(req.url); + // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar + // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more + // segments after the type slot ends up here. Single-segment is handled + // by the routes table above and never reaches this branch. + const adminEditMulti = url.pathname.match( + /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, + ); + if (adminEditMulti) { + const reqP = Object.assign(req, { + params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! }, + }); + return adminEditHandler(reqP); + } + const adminDeleteMulti = url.pathname.match( + /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, + ); + if (adminDeleteMulti) { + const reqP = Object.assign(req, { + params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! }, + }); + return adminDeleteHandler(reqP); + } + + // Public sxdoc-backed pages on multi-segment slugs (e.g. + // /p/company/about, /p/docs/spec/grammar). Single-segment is handled + // by the routes table above. + const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/); + if (publicPageMulti) { + return renderPublicPage(publicPageMulti[1]!); + } + // Bare //.git (no sub-path) is what someone gets when // they paste the clone URL into a browser. Without intervention our // proxy hands it to Forgejo, which renders its own repo page — @@ -194,6 +238,26 @@ const appFetch = async (req: Request): Promise => { }); } + // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/. + // The wildcard path needs more flexibility than Bun's :param routes + // give us (no slashes), so we match in the fallback fetch instead. + const gitBrowseMatch = url.pathname.match( + /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/, + ); + if (gitBrowseMatch) { + const owner = gitBrowseMatch[1]!; + const repo = gitBrowseMatch[2]!; + const suffix = gitBrowseMatch[3]!; + // Skip the commit/ shape — that's c21_handlers_commit_view's + // turf and lives as an explicit Bun.serve route above. + if (!suffix.startsWith("commit/")) { + const target = parseRepoBrowsePath(suffix); + if (target !== null) { + return repoBrowseHandler(req, owner, repo, target); + } + } + } + // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo. if (isGitProtocol(url.pathname, url.searchParams)) { return proxyToForgejo(req, url.pathname + url.search); @@ -646,6 +710,30 @@ ${rows} "/edit/:section/:slug": editPageHandler, + // Admin UI — sxdoc-backed CRUD on pages + posts. Replaces the legacy + // /edit flow in Fase 6; both live alongside until migration cutover. + "/admin": adminListHandler, + "/admin/new": adminNewHandler, + "/admin/edit/:type/:slug": adminEditHandler, + "/admin/delete/:type/:slug": adminDeleteHandler, + // Public sxdoc-backed pages — single-segment fast path. Multi-segment + // slugs fall through to appFetch's regex matcher above. + "/p/:slug": publicPageHandler, + + "/admin/assets/blockeditor.js": async (req) => { + const { code, etag } = await bundleAdminClient(); + if (req.headers.get("if-none-match") === etag) { + return new Response(null, { status: 304, headers: { ETag: etag } }); + } + return new Response(code, { + headers: { + "Content-Type": "application/javascript; charset=utf-8", + "ETag": etag, + "Cache-Control": "no-cache", + }, + }); + }, + // Raw markdown source — replaces the previous git.tdd.md "view source" // link so docs pages don't depend on the Forgejo subdomain. The // route uses `:filename` (with trailing `.md` validated in the diff --git a/src/c21_handlers_admin.ts b/src/c21_handlers_admin.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c115e19a6eda4c74b2f26aab3acd84f3c87b883 --- /dev/null +++ b/src/c21_handlers_admin.ts @@ -0,0 +1,254 @@ +// c21 — handlers: CRUD on sxdoc-backed pages + posts. +// +// Composes: +// c13_database listDocuments / loadDocument / saveDocument / deleteDocument +// c32_session getViewer (admin gate) +// c31_sxdoc_parse htmlToSx (parse posted HTML → SxDocument) +// c51_render_sxdoc sxToHtml (project stored doc back to HTML for the form) +// c31_admin_validation validateEditForm (form → typed input) +// c51_render_admin shell rendering +// +// Routes (mounted in c21_app.ts): +// GET /admin +// GET /admin/new +// POST /admin/new +// GET /admin/edit/:type/:slug +// POST /admin/edit/:type/:slug +// POST /admin/delete/:type/:slug +// +// Auth: any non-admin signed-in viewer → 403 wall (matches the legacy +// /edit handler). Anonymous → 401 login wall. + +import { ADMIN_USERNAME } from "./c31_site_config.ts"; +import { + listDocuments, + loadDocument, + saveDocument, + deleteDocument, +} from "./c13_database.ts"; +import { getViewer } from "./c32_session.ts"; +import { htmlToSx } from "./c31_sxdoc_parse.ts"; +import { validateEditForm } from "./c31_admin_validation.ts"; +import { htmlResponse } from "./c51_render_layout.ts"; +import { + renderAdminList, + renderAdminEdit, + renderAdminLoginWall, + renderAdminNonAdminWall, +} from "./c51_render_admin.ts"; + +const wantsJson = (req: Request): boolean => + (req.headers.get("accept") ?? "").includes("application/json"); + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }, + }); + +// ─── auth gate ─────────────────────────────────────────────────────────── + +interface AuthOk { ok: true; viewer: string; } +interface AuthDenied { ok: false; response: Response; } +type AuthResult = AuthOk | AuthDenied; + +const requireAdmin = async (req: Request): Promise => { + const viewer = await getViewer(req); + if (!viewer) { + const html = await renderAdminLoginWall(); + return { ok: false, response: htmlResponse(html, 401) }; + } + if (viewer !== ADMIN_USERNAME) { + const html = await renderAdminNonAdminWall(viewer); + return { ok: false, response: htmlResponse(html, 403) }; + } + return { ok: true, viewer }; +}; + +// FormData → string-record adapter. The validator lives in c31 and +// stays browser-agnostic by taking plain string fields. +const formToRecord = async (req: Request): Promise> => { + const fd = await req.formData(); + const out: Record = {}; + for (const [k, v] of fd.entries()) out[k] = String(v); + return out; +}; + +// ─── handlers ──────────────────────────────────────────────────────────── + +export const adminListHandler = async (req: Request): Promise => { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + const documents = listDocuments(); + const html = await renderAdminList(documents); + return htmlResponse(html); +}; + +export const adminNewHandler = async (req: Request): Promise => { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + const json = wantsJson(req); + + if (req.method === "POST") { + const form = await formToRecord(req); + const v = validateEditForm(form); + if (!v.ok) { + if (json) return jsonResponse({ ok: false, error: v.error }, 400); + const html = await renderAdminEdit({ + mode: "new", + title: form.title ?? "", + slug: form.slug ?? "", + type: form.type === "post" ? "post" : "page", + doc: htmlToSx(form.html ?? ""), + status: form.status === "draft" ? "draft" : "published", + primaryTag: (form.primary_tag ?? "").trim() || null, + error: v.error, + }); + return htmlResponse(html, 400); + } + if (loadDocument(v.data.slug, v.data.type)) { + const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`; + if (json) return jsonResponse({ ok: false, error: err }, 409); + const html = await renderAdminEdit({ + mode: "new", + title: v.data.title, + slug: v.data.slug, + type: v.data.type, + doc: htmlToSx(v.data.html), + status: v.data.status, + primaryTag: v.data.primaryTag, + error: err, + }); + return htmlResponse(html, 409); + } + saveDocument({ + slug: v.data.slug, + type: v.data.type, + title: v.data.title, + doc: htmlToSx(v.data.html), + status: v.data.status, + primaryTag: v.data.primaryTag, + }); + if (json) { + return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type }); + } + return new Response(null, { + status: 303, + headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` }, + }); + } + + // GET — empty form + const html = await renderAdminEdit({ + mode: "new", + title: "", + slug: "", + type: "page", + doc: htmlToSx("

Hello, world.

"), + status: "published", + primaryTag: null, + }); + return htmlResponse(html); +}; + +export const adminEditHandler = async ( + req: Request & { params: { type: string; slug: string } }, +): Promise => { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + + const type = req.params.type === "post" ? "post" : "page"; + if (req.params.type !== "page" && req.params.type !== "post") { + return new Response("invalid type", { status: 400 }); + } + const slug = req.params.slug; + const existing = loadDocument(slug, type); + if (!existing) return new Response("not found", { status: 404 }); + + if (req.method === "POST") { + const form = await formToRecord(req); + const json = wantsJson(req); + const v = validateEditForm(form); + if (!v.ok) { + if (json) return jsonResponse({ ok: false, error: v.error }, 400); + const html = await renderAdminEdit({ + mode: "edit", + title: form.title ?? existing.title, + slug: form.slug ?? slug, + type, + doc: htmlToSx(form.html ?? ""), + status: form.status === "draft" ? "draft" : "published", + primaryTag: (form.primary_tag ?? "").trim() || existing.primaryTag, + error: v.error, + }); + return htmlResponse(html, 400); + } + // Rename (slug or type changed) — reject collision with another + // existing doc; otherwise delete the old key before saving the new one. + if (v.data.slug !== slug || v.data.type !== type) { + const collision = loadDocument(v.data.slug, v.data.type); + if (collision && collision.id !== existing.id) { + const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`; + if (json) return jsonResponse({ ok: false, error: err }, 409); + const html = await renderAdminEdit({ + mode: "edit", + title: v.data.title, + slug: v.data.slug, + type: v.data.type, + doc: htmlToSx(v.data.html), + status: v.data.status, + primaryTag: v.data.primaryTag, + error: err, + }); + return htmlResponse(html, 409); + } + deleteDocument(slug, type); + } + saveDocument({ + slug: v.data.slug, + type: v.data.type, + title: v.data.title, + doc: htmlToSx(v.data.html), + status: v.data.status, + primaryTag: v.data.primaryTag, + }); + if (json) { + return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type }); + } + return new Response(null, { + status: 303, + headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` }, + }); + } + + // GET — render the stored sxdoc directly; c51_render_admin computes + // the textarea HTML projection and embeds the JSON for client hydration. + const html = await renderAdminEdit({ + mode: "edit", + title: existing.title, + slug: existing.slug, + type: existing.type, + doc: existing.doc, + status: existing.status, + primaryTag: existing.primaryTag, + }); + return htmlResponse(html); +}; + +export const adminDeleteHandler = async ( + req: Request & { params: { type: string; slug: string } }, +): Promise => { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + if (req.method !== "POST") return new Response("POST only", { status: 405 }); + + const type = req.params.type === "post" ? "post" : "page"; + if (req.params.type !== "page" && req.params.type !== "post") { + return new Response("invalid type", { status: 400 }); + } + deleteDocument(req.params.slug, type); + return new Response(null, { status: 303, headers: { Location: "/admin" } }); +}; diff --git a/src/c21_handlers_content.ts b/src/c21_handlers_content.ts new file mode 100644 index 0000000000000000000000000000000000000000..4532b75a7d58e2953bd8fd1edad0715bf26a620d --- /dev/null +++ b/src/c21_handlers_content.ts @@ -0,0 +1,36 @@ +// c21 — public read-only render for sxdoc-backed pages. +// +// Routes (mounted in c21_app.ts): +// GET /p/:slug — single-segment fast path via routes table +// GET /p/ — multi-segment via appFetch regex fallback +// +// Composes c13_database (loadDocument), c51_render_sxdoc (sxToHtml), +// and c51_render_layout (renderPage chrome). Drafts (status=draft) 404 +// publicly — only published pages are reachable. +// +// Scope note: posts get their own Ghost-style permalink in Fase 4 +// (/blog/{primary_tag}/{slug}). For now only pages are public. Hitting +// /p/ when a row exists with type=post still 404's so we can't +// accidentally leak a draft post-shape via the page route. + +import { loadDocument } from "./c13_database.ts"; +import { sxToHtml } from "./c51_render_sxdoc.ts"; +import { htmlResponse, renderPage, renderNotFound } from "./c51_render_layout.ts"; + +export const publicPageHandler = async ( + req: Request & { params: { slug: string } }, +): Promise => renderPublicPage(req.params.slug); + +export const renderPublicPage = async (slug: string): Promise => { + const row = loadDocument(slug, "page"); + if (!row || row.status !== "published") { + const html = await renderNotFound(`/p/${slug}`); + return htmlResponse(html, 404); + } + const html = await renderPage({ + title: `${row.title} — tdd.md`, + bodyHtml: sxToHtml(row.doc), + ogPath: `https://tdd.md/p/${slug}`, + }); + return htmlResponse(html); +}; diff --git a/src/c21_handlers_repo_browse.ts b/src/c21_handlers_repo_browse.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b93d403bd896ac2dba3d02df377d63d8bf12377 --- /dev/null +++ b/src/c21_handlers_repo_browse.ts @@ -0,0 +1,129 @@ +// c21 — handler: SAMA-native browsable repo at /GIT/. +// GET /GIT/:owner/:repo/tree/:ref/ → directory listing +// GET /GIT/:owner/:repo/blob/:ref/ → file viewer (md rendered) +// GET /GIT/:owner/:repo/raw/:ref/ → raw file content +// +// Sits next to c21_handlers_commit_view (commit detail) — the two +// together replace what visitors used to need git.tdd.md for. Reads +// from the local bare repo via c14_git.lsTree / c14_git.readBlobAtRef. +// +// The owner/repo pair must match the locally-served bare repo +// (syntaxai/tdd.md). Other pairs 404 — agent kata browse is not in +// scope here. Path traversal is blocked by validating against +// patterns that disallow ".." and absolute leading-slash inputs. + +import { renderNotFound, htmlResponse } from "./c51_render_layout.ts"; +import { lsTree, readBlobAtRef } from "./c14_git.ts"; +import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./c31_site_config.ts"; +import { renderRepoTree, renderRepoBlob } from "./c51_render_repo.ts"; + +const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; +// Refs we accept as :ref. Branch names + full SHAs are common — +// kept narrow on purpose (no slashes — branches like "feat/foo" +// would clash with the wildcard path matching). +const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/; + +const isAllowedRepo = (owner: string, repo: string): boolean => + owner === LIVE_REPO_OWNER && + repo === LIVE_REPO_NAME && + SAFE_OWNER_REPO.test(owner) && + SAFE_OWNER_REPO.test(repo); + +// Only allow paths that look like ordinary repo entries — letters, +// digits, hyphens, underscores, dots, slashes. Reject anything with +// a ".." segment, leading or trailing slashes, or empty segments. +const isSafePath = (p: string): boolean => { + if (p === "") return true; // root + if (p.startsWith("/") || p.endsWith("/")) return false; + if (p.includes("//")) return false; + if (!/^[A-Za-z0-9._\/-]+$/.test(p)) return false; + for (const seg of p.split("/")) { + if (seg === "" || seg === "." || seg === "..") return false; + } + return true; +}; + +// Strip a leading "tree//" or "blob//" or "raw//" off +// a captured pathname suffix, returning { kind, ref, path } or null. +// Called from the fallback fetch in c21_app where the URL has been +// matched only loosely. +export interface RepoBrowseTarget { + kind: "tree" | "blob" | "raw"; + ref: string; + path: string; +} + +export const parseRepoBrowsePath = (suffix: string): RepoBrowseTarget | null => { + // suffix is what comes after /GIT/// + // e.g. "tree/main", "tree/main/content/blog", "blob/main/content/blog/foo.md" + const m = /^(tree|blob|raw)\/([^/]+)(?:\/(.*))?$/.exec(suffix); + if (!m) return null; + const kind = m[1] as "tree" | "blob" | "raw"; + const ref = m[2]!; + const path = m[3] ?? ""; + if (!SAFE_REF.test(ref)) return null; + if (!isSafePath(path)) return null; + return { kind, ref, path }; +}; + +export const repoBrowseHandler = async ( + req: Request, + owner: string, + repo: string, + target: RepoBrowseTarget, +): Promise => { + const fullPath = `/GIT/${owner}/${repo}/${target.kind}/${target.ref}${target.path ? "/" + target.path : ""}`; + + if (!isAllowedRepo(owner, repo)) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + + if (target.kind === "tree") { + const entries = await lsTree(target.ref, target.path); + if (entries === null) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + const html = await renderRepoTree({ + owner, + repo, + ref: target.ref, + path: target.path, + entries, + }); + return htmlResponse(html); + } + + if (target.kind === "blob") { + const content = await readBlobAtRef(target.ref, target.path); + if (content === null) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + const html = await renderRepoBlob({ + owner, + repo, + ref: target.ref, + path: target.path, + content, + }); + return htmlResponse(html); + } + + // raw + const content = await readBlobAtRef(target.ref, target.path); + if (content === null) { + const html = await renderNotFound(fullPath); + return htmlResponse(html, 404); + } + // Markdown files served as text/plain so browsers render them + // inline; everything else also text/plain (we don't try to detect + // language types — c14_git already restricts to UTF-8). + return new Response(content, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=60", + }, + }); +}; diff --git a/src/c31_admin_validation.test.ts b/src/c31_admin_validation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..717b24e8e62e3582169c625ab82b2980b6e24bb3 --- /dev/null +++ b/src/c31_admin_validation.test.ts @@ -0,0 +1,213 @@ +import { test, expect } from "bun:test"; +import { + validateEditForm, + MAX_ADMIN_HTML_BYTES, +} from "./c31_admin_validation.ts"; + +test("accepts a minimally valid form", () => { + const r = validateEditForm({ + slug: "hello", + type: "page", + title: "Hello", + html: "

x

", + status: "published", + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.data.slug).toBe("hello"); + expect(r.data.type).toBe("page"); + expect(r.data.primaryTag).toBeNull(); + } +}); + +test("lowercases the slug and trims surrounding whitespace", () => { + const r = validateEditForm({ + slug: " HELLO-World ", + type: "post", + title: "X", + html: "

x

", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.slug).toBe("hello-world"); +}); + +test("rejects missing title", () => { + const r = validateEditForm({ + slug: "ok", + type: "page", + title: " ", + html: "

x

", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/title/i); +}); + +test("rejects slug with uppercase letters", () => { + const r = validateEditForm({ + slug: "NotOK", + type: "page", + title: "T", + html: "

x

", + }); + // lowercased to "notok" by the trimmer — that should pass. + expect(r.ok).toBe(true); +}); + +test("accepts multi-segment slug with single-slash separators", () => { + const r = validateEditForm({ + slug: "company/about", + type: "page", + title: "About", + html: "

x

", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.slug).toBe("company/about"); +}); + +test("accepts deeply nested multi-segment slug", () => { + const r = validateEditForm({ + slug: "docs/spec/grammar", + type: "page", + title: "Grammar", + html: "

x

", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.slug).toBe("docs/spec/grammar"); +}); + +test("trims leading and trailing slashes from slug", () => { + const r = validateEditForm({ + slug: "/foo/bar/", + type: "page", + title: "T", + html: "

x

", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.slug).toBe("foo/bar"); +}); + +test("rejects slug with consecutive slashes", () => { + const r = validateEditForm({ + slug: "a//b", + type: "page", + title: "T", + html: "

x

", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/slug/i); +}); + +test("rejects empty segment after trim", () => { + const r = validateEditForm({ + slug: "//", + type: "page", + title: "T", + html: "

x

", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/slug/i); +}); + +test("rejects slug containing whitespace", () => { + const r = validateEditForm({ + slug: "two words", + type: "page", + title: "T", + html: "

x

", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/slug/i); +}); + +test("rejects unknown type", () => { + const r = validateEditForm({ + slug: "ok", + type: "snippet", + title: "X", + html: "

x

", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/type/i); +}); + +test("rejects unknown status", () => { + const r = validateEditForm({ + slug: "ok", + type: "page", + title: "X", + html: "

x

", + status: "deferred", + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/status/i); +}); + +test("defaults status to published when omitted", () => { + const r = validateEditForm({ + slug: "ok", + type: "page", + title: "X", + html: "

x

", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.status).toBe("published"); +}); + +test("accepts draft status", () => { + const r = validateEditForm({ + slug: "ok", + type: "page", + title: "X", + html: "

x

", + status: "draft", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.status).toBe("draft"); +}); + +test("captures primary_tag when non-empty", () => { + const r = validateEditForm({ + slug: "ok", + type: "post", + title: "P", + html: "

x

", + primary_tag: "concept", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.primaryTag).toBe("concept"); +}); + +test("treats blank primary_tag as null", () => { + const r = validateEditForm({ + slug: "ok", + type: "post", + title: "P", + html: "

x

", + primary_tag: " ", + }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.primaryTag).toBeNull(); +}); + +test("rejects html body over the size cap", () => { + // Build a 1 MB + 1 byte payload of single-byte chars. + const big = "a".repeat(MAX_ADMIN_HTML_BYTES + 1); + const r = validateEditForm({ + slug: "ok", + type: "page", + title: "X", + html: big, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/limit/i); +}); + +test("accepts empty html body (parser handles it as an empty doc)", () => { + const r = validateEditForm({ + slug: "ok", + type: "page", + title: "X", + html: "", + }); + expect(r.ok).toBe(true); +}); diff --git a/src/c31_admin_validation.ts b/src/c31_admin_validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..d88206aebbaa0b6890a401277a4fade43dacc925 --- /dev/null +++ b/src/c31_admin_validation.ts @@ -0,0 +1,68 @@ +// c31 — model: validation for the admin sxdoc edit form. Pure: no I/O. +// Sibling to c31_edit_validation (markdown-editor validation), but for +// the SxDocument-backed admin UI. +// +// Per Modeled.md: external input (HTTP form bodies) gets a parser in +// c31 before any logic touches it. Handler reads FormData, hands a +// Record to validateEditForm, gets back a discriminated +// result the handler can react to. + +// Slugs may be single-segment ("about") or multi-segment ("company/about", +// "docs/spec/grammar"). Each segment is lowercase a-z/0-9/-/_. Leading or +// trailing slashes are trimmed by the caller before this regex runs, so +// the pattern itself only matches the canonical "seg(/seg)*" shape. +const SLUG_RE = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/; + +// 1 MiB cap on HTML body. The migration's biggest single document +// (sama-meets-git-cms.md) is ~12 KB rendered — 1 MiB is generous +// headroom for any realistic page, while still rejecting accidental +// 50 MB pastes that would block the SQLite WAL. +export const MAX_ADMIN_HTML_BYTES = 1024 * 1024; + +export interface ValidatedEditInput { + slug: string; + type: "page" | "post"; + title: string; + html: string; + status: "published" | "draft"; + primaryTag: string | null; +} + +export type AdminValidationResult = + | { ok: true; data: ValidatedEditInput } + | { ok: false; error: string }; + +export const validateEditForm = (form: Record): AdminValidationResult => { + const slug = (form.slug ?? "").trim().toLowerCase().replace(/^\/+|\/+$/g, ""); + const type = form.type ?? ""; + const title = (form.title ?? "").trim(); + const html = form.html ?? ""; + const statusRaw = form.status ?? "published"; + const primaryTag = (form.primary_tag ?? "").trim() || null; + + if (!title) return { ok: false, error: "title is required" }; + if (!SLUG_RE.test(slug)) { + return { + ok: false, + error: "slug must be lowercase segments (letters, digits, dash, underscore) joined by single slashes — e.g. about, company/about, docs/spec/grammar", + }; + } + if (type !== "page" && type !== "post") { + return { ok: false, error: "type must be page or post" }; + } + if (statusRaw !== "published" && statusRaw !== "draft") { + return { ok: false, error: "status must be published or draft" }; + } + const bytes = new TextEncoder().encode(html).length; + if (bytes > MAX_ADMIN_HTML_BYTES) { + return { + ok: false, + error: `body exceeds the ${MAX_ADMIN_HTML_BYTES / 1024} KB limit (got ${Math.round(bytes / 1024)} KB)`, + }; + } + + return { + ok: true, + data: { slug, type, title, html, status: statusRaw, primaryTag }, + }; +}; diff --git a/src/c31_sxdoc.ts b/src/c31_sxdoc.ts new file mode 100644 index 0000000000000000000000000000000000000000..054f55f83b13669c520a6429ae3e15e587eb583e --- /dev/null +++ b/src/c31_sxdoc.ts @@ -0,0 +1,142 @@ +// c31 — types for sx-doc: tdd.md's typed rich-content format. +// +// Why a typed tree instead of HTML strings: +// • Editor saves a structured shape, not a string blob — block-level +// ops (move, transform, AI-edit) operate on typed nodes, not regex. +// • Round-trippable: htmlToSx(sxToHtml(doc)) ≈ doc (whitespace modulo). +// • Compact JSON: single-letter keys (`t`, `c`, `v`, `m`) keep the +// SQLite + git-sidecar payloads small. +// +// SAMA placement: c31 because this file is pure types/registry — no I/O, +// no logic. Parser/renderer live in c32_sxdoc_parse + c32_sxdoc_render +// where the deterministic transforms (and their sibling tests) belong. +// +// Scope-omission: podman's typed marketing blocks (hero, feature-card, +// feature-grid, stats-row, steps-grid, use-case-card, cta-band) are +// deliberately skipped — tdd.md content has no marketing-landing-page +// shape; skipping saves ~600 LOC across server + client. + +export const SX_DOC_VERSION = 1; + +export interface SxDocument { + v: typeof SX_DOC_VERSION; + blocks: SxBlock[]; +} + +export type SxBlock = + | SxParagraph + | SxHeading + | SxList + | SxListItem + | SxQuote + | SxCodeBlock + | SxImage + | SxDivider + | SxHtml + | SxShortcode; + +export interface SxParagraph { + t: "p"; + c: SxInline[]; +} + +export interface SxHeading { + t: "h"; + level: 1 | 2 | 3 | 4 | 5 | 6; + c: SxInline[]; +} + +export interface SxList { + t: "ul" | "ol"; + // Each item is an array of blocks so a list item can hold paragraphs, + // nested lists, etc. + items: SxBlock[][]; +} + +// Separate type so renderers can special-case loose list-items. Lists +// store items as SxBlock[][] directly; SxListItem only appears when an +// isolated
  • reaches the parser without a parent list. +export interface SxListItem { + t: "li"; + c: SxBlock[]; +} + +export interface SxQuote { + t: "quote"; + c: SxBlock[]; +} + +export interface SxCodeBlock { + t: "code"; + // Language hint — e.g. "ts", "py". May be empty. + lang?: string; + // Raw source code. Newlines preserved verbatim. + src: string; +} + +export interface SxImage { + t: "img"; + src: string; + alt?: string; + caption?: string; + // Intrinsic dimensions if known — used for layout-shift prevention. + w?: number; + h?: number; +} + +export interface SxDivider { + t: "hr"; +} + +// Escape hatch for HTML we don't (yet) model — preserves the source +// verbatim so round-tripping is lossless. New element kinds should land +// as proper SxBlock variants over time, not as `html` blobs. +export interface SxHtml { + t: "html"; + src: string; +} + +// `[[sx:name arg=value ...]]` shortcode lifted out of source. We store +// the name + args structurally so renderers and queries don't need to +// understand the wire syntax. +export interface SxShortcode { + t: "shortcode"; + name: string; + args: Record; +} + +// ─── inline ────────────────────────────────────────────────────────────── + +export type SxInline = SxText | SxLink; + +// Text run with optional marks. Marks are single-character flags: +// b=bold i=italic u=underline s=strikethrough c=inline-code +// Storage order doesn't matter; renderers nest them deterministically +// (see MARK_ORDER in c32_sxdoc_render). +export interface SxText { + t: "text"; + v: string; + m?: SxMark[]; +} + +export type SxMark = "b" | "i" | "u" | "s" | "c"; + +export interface SxLink { + t: "a"; + href: string; + c: SxInline[]; +} + +// ─── helpers ───────────────────────────────────────────────────────────── + +// Type guard — useful at renderer and storage boundaries. +export const isBlock = (node: unknown): node is SxBlock => { + if (!node || typeof node !== "object") return false; + return "t" in node && typeof (node as { t: unknown }).t === "string"; +}; + +// Sentinel for new posts that haven't been parsed yet. +export const emptyDocument = (): SxDocument => ({ + v: SX_DOC_VERSION, + blocks: [], +}); diff --git a/src/c31_sxdoc_parse.test.ts b/src/c31_sxdoc_parse.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..15c33da902605ddf7e022042c8683e04dfcc43d2 --- /dev/null +++ b/src/c31_sxdoc_parse.test.ts @@ -0,0 +1,234 @@ +import { test, expect } from "bun:test"; +import { htmlToSx } from "./c31_sxdoc_parse.ts"; +import { SX_DOC_VERSION } from "./c31_sxdoc.ts"; + +test("returns an empty document for empty input", () => { + const doc = htmlToSx(""); + expect(doc.v).toBe(SX_DOC_VERSION); + expect(doc.blocks).toEqual([]); +}); + +test("parses a simple paragraph", () => { + const doc = htmlToSx("

    Hello world

    "); + expect(doc.blocks).toHaveLength(1); + expect(doc.blocks[0]).toEqual({ + t: "p", + c: [{ t: "text", v: "Hello world" }], + }); +}); + +test("parses headings with correct level for h1-h6", () => { + for (const level of [1, 2, 3, 4, 5, 6] as const) { + const doc = htmlToSx(`Title ${level}`); + expect(doc.blocks).toHaveLength(1); + expect(doc.blocks[0]).toEqual({ + t: "h", level, + c: [{ t: "text", v: `Title ${level}` }], + }); + } +}); + +test("parses unordered list with items wrapped as paragraphs", () => { + const doc = htmlToSx("
    • one
    • two
    "); + expect(doc.blocks).toHaveLength(1); + expect(doc.blocks[0]).toEqual({ + t: "ul", + items: [ + [{ t: "p", c: [{ t: "text", v: "one" }] }], + [{ t: "p", c: [{ t: "text", v: "two" }] }], + ], + }); +}); + +test("parses ordered list", () => { + const doc = htmlToSx("
    1. first
    "); + const block = doc.blocks[0]; + expect(block.t).toBe("ol"); + expect((block as { items: unknown }).items).toEqual([ + [{ t: "p", c: [{ t: "text", v: "first" }] }], + ]); +}); + +test("parses nested lists inside a list item", () => { + const doc = htmlToSx("
    • outer
      • inner
    "); + const outer = doc.blocks[0] as { t: "ul"; items: unknown[][] }; + expect(outer.t).toBe("ul"); + expect(outer.items[0]).toHaveLength(2); + expect(outer.items[0][0]).toEqual({ t: "p", c: [{ t: "text", v: "outer" }] }); + expect(outer.items[0][1]).toEqual({ + t: "ul", + items: [[{ t: "p", c: [{ t: "text", v: "inner" }] }]], + }); +}); + +test("parses blockquote with paragraph inside", () => { + const doc = htmlToSx("

    quoted

    "); + expect(doc.blocks).toEqual([{ + t: "quote", + c: [{ t: "p", c: [{ t: "text", v: "quoted" }] }], + }]); +}); + +test("parses blockquote with loose text wraps it in a paragraph", () => { + const doc = htmlToSx("
    loose
    "); + expect(doc.blocks[0]).toEqual({ + t: "quote", + c: [{ t: "p", c: [{ t: "text", v: "loose" }] }], + }); +}); + +test("parses pre>code with language hint", () => { + const doc = htmlToSx(`
    const x = 1;
    `); + expect(doc.blocks[0]).toEqual({ + t: "code", lang: "ts", src: "const x = 1;", + }); +}); + +test("parses pre without inner code element", () => { + const doc = htmlToSx("
    raw text
    "); + expect(doc.blocks[0]).toEqual({ + t: "code", lang: "", src: "raw text", + }); +}); + +test("preserves encoded entities in code blocks", () => { + const doc = htmlToSx(`
    <p>
    `); + expect(doc.blocks[0]).toEqual({ + t: "code", lang: "", src: "

    ", + }); +}); + +test("parses img with src and alt", () => { + const doc = htmlToSx(`x icon`); + expect(doc.blocks[0]).toEqual({ t: "img", src: "/x.png", alt: "x icon" }); +}); + +test("parses img with width and height attributes", () => { + const doc = htmlToSx(``); + expect(doc.blocks[0]).toEqual({ t: "img", src: "/a.jpg", w: 200, h: 100 }); +}); + +test("skips img with empty src", () => { + const doc = htmlToSx(``); + expect(doc.blocks).toEqual([]); +}); + +test("parses figure with figcaption", () => { + const doc = htmlToSx(`

    nice y
    `); + expect(doc.blocks[0]).toEqual({ + t: "img", src: "/y.png", caption: "nice y", + }); +}); + +test("parses hr", () => { + const doc = htmlToSx("
    "); + expect(doc.blocks[0]).toEqual({ t: "hr" }); +}); + +test("parses inline bold and italic marks", () => { + const doc = htmlToSx("

    bold and ital

    "); + expect(doc.blocks[0]).toEqual({ + t: "p", + c: [ + { t: "text", v: "bold", m: ["b"] }, + { t: "text", v: " and " }, + { t: "text", v: "ital", m: ["i"] }, + ], + }); +}); + +test("composes nested marks into a single mark array", () => { + const doc = htmlToSx("

    both

    "); + expect(doc.blocks[0]).toEqual({ + t: "p", + c: [{ t: "text", v: "both", m: ["b", "i"] }], + }); +}); + +test("dedupes repeated marks across nested wrappers", () => { + const doc = htmlToSx("

    x

    "); + const para = doc.blocks[0] as { c: Array<{ m?: string[] }> }; + expect(para.c[0].m).toEqual(["b"]); +}); + +test("treats
    as a newline text run carrying marks", () => { + const doc = htmlToSx("

    a
    b

    "); + expect(doc.blocks[0]).toEqual({ + t: "p", + c: [ + { t: "text", v: "a" }, + { t: "text", v: "\n" }, + { t: "text", v: "b" }, + ], + }); +}); + +test("parses anchor links with href", () => { + const doc = htmlToSx(`

    click

    `); + expect(doc.blocks[0]).toEqual({ + t: "p", + c: [{ t: "a", href: "/x", c: [{ t: "text", v: "click" }] }], + }); +}); + +test("strips unknown inline wrappers like span and keeps content", () => { + const doc = htmlToSx(`

    before middle after

    `); + expect(doc.blocks[0]).toEqual({ + t: "p", + c: [ + { t: "text", v: "before " }, + { t: "text", v: "middle" }, + { t: "text", v: " after" }, + ], + }); +}); + +test("parses a standalone shortcode out of plain text", () => { + const doc = htmlToSx("

    [[sx:event-count]]

    "); + expect(doc.blocks).toEqual([ + { t: "shortcode", name: "event-count", args: {} }, + ]); +}); + +test("parses a shortcode with quoted and bare args", () => { + const doc = htmlToSx(`

    [[sx:list tag="blog" limit=5]]

    `); + expect(doc.blocks).toEqual([ + { t: "shortcode", name: "list", args: { tag: "blog", limit: "5" } }, + ]); +}); + +test("lifts a shortcode out of a mixed paragraph", () => { + const doc = htmlToSx("

    before [[sx:x]] after

    "); + expect(doc.blocks).toEqual([ + { t: "p", c: [{ t: "text", v: "before " }] }, + { t: "shortcode", name: "x", args: {} }, + { t: "p", c: [{ t: "text", v: " after" }] }, + ]); +}); + +test("recurses into div/section/article containers", () => { + const doc = htmlToSx("

    one

    two

    "); + expect(doc.blocks).toHaveLength(2); + expect(doc.blocks[0]).toEqual({ t: "p", c: [{ t: "text", v: "one" }] }); + expect(doc.blocks[1]).toEqual({ t: "p", c: [{ t: "text", v: "two" }] }); +}); + +test("falls back to html escape-hatch for unknown elements", () => { + const doc = htmlToSx(`
    x
    `); + expect(doc.blocks).toHaveLength(1); + expect(doc.blocks[0].t).toBe("html"); + expect((doc.blocks[0] as { src: string }).src).toContain(""); +}); + +test("decodes named entities in inline text", () => { + const doc = htmlToSx("

    A & B

    "); + expect(doc.blocks[0]).toEqual({ + t: "p", c: [{ t: "text", v: "A & B" }], + }); +}); + +test("ignores empty paragraphs", () => { + const doc = htmlToSx("

    real

    "); + expect(doc.blocks).toHaveLength(1); + expect(doc.blocks[0]).toEqual({ t: "p", c: [{ t: "text", v: "real" }] }); +}); diff --git a/src/c31_sxdoc_parse.ts b/src/c31_sxdoc_parse.ts new file mode 100644 index 0000000000000000000000000000000000000000..1642369e48aa87a62ec29be8b341dad94dc720ff --- /dev/null +++ b/src/c31_sxdoc_parse.ts @@ -0,0 +1,327 @@ +// c31 — HTML → SxDocument parser. +// +// SAMA placement: c31 because this is a parser for external input — +// Modeled.md is explicit: "every external input has a parser in a c31_* +// model — types and parse-functions colocated". HTML strings reach this +// file from the editor's save POST, from the markdown-import script, and +// from the AI-edit response — all "outside the process" → c31. +// +// Why a typed tree and not HTML strings: see c31_sxdoc.ts header. +// +// Why node-html-parser and not Bun's HTMLRewriter: we need a tree we can +// recurse over, not a streaming filter. The dep is pure-logic (no I/O, +// no fs, no spawn) so it doesn't push the file into c14 territory. + +import { parse, type HTMLElement, type Node, NodeType } from "node-html-parser"; +import type { SxDocument, SxBlock, SxInline, SxMark } from "./c31_sxdoc.ts"; +import { SX_DOC_VERSION } from "./c31_sxdoc.ts"; + +const SHORTCODE_RE = /\[\[sx:([a-z][a-z0-9-]*)((?:\s+[a-z0-9_-]+=(?:"[^"]*"|[^\s"\]]+))*)\s*\]\]/g; +const SHORTCODE_ARG_RE = /([a-z0-9_-]+)=(?:"([^"]*)"|([^\s"\]]+))/g; + +const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]); + +// Block-level tags — used by parseListItem to know where to stop +// collecting inlines and recurse instead. Keep in sync with the +// pushBlocksFromNode dispatcher above. +const BLOCK_TAGS = new Set([ + "p", "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "blockquote", "pre", + "img", "figure", "hr", + "div", "section", "article", "table", +]); + +const MARK_FOR_TAG: Record = { + b: "b", strong: "b", + i: "i", em: "i", + u: "u", + s: "s", strike: "s", del: "s", + code: "c", +}; + +export const htmlToSx = (html: string): SxDocument => { + // Wrap in so we always have a single parent to walk childNodes + // of, regardless of whether the input has its own wrapper element. + const root = parse(`${html}`, { + blockTextElements: { script: false, style: false }, + }); + const rootEl = root.firstChild as HTMLElement; + const blocks: SxBlock[] = []; + for (const node of rootEl.childNodes) { + pushBlocksFromNode(node, blocks); + } + return { v: SX_DOC_VERSION, blocks }; +}; + +// ─── block-level dispatch ──────────────────────────────────────────────── + +const pushBlocksFromNode = (node: Node, out: SxBlock[]): void => { + if (node.nodeType === NodeType.TEXT_NODE) { + const text = (node.text ?? "").trim(); + if (text) out.push(...textWithShortcodesToBlocks(text, [])); + return; + } + if (node.nodeType !== NodeType.ELEMENT_NODE) return; + + const el = node as HTMLElement; + const tag = el.tagName?.toLowerCase(); + if (!tag) return; + + // Comments / processing-instructions surface as element nodes with a + // tagName starting with "!" — drop them, they're not content. + if (tag === "!" || tag === "comment") return; + + if (tag === "p") { + const inlines = parseInline(el.childNodes, []); + if (inlines.length === 0) return; + out.push(...splitShortcodesFromParagraph(inlines)); + return; + } + + if (HEADING_TAGS.has(tag)) { + const level = parseInt(tag.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6; + out.push({ t: "h", level, c: parseInline(el.childNodes, []) }); + return; + } + + if (tag === "ul" || tag === "ol") { out.push(parseList(el, tag)); return; } + if (tag === "blockquote") { out.push(parseQuote(el)); return; } + if (tag === "pre") { out.push(parseCodeBlock(el)); return; } + if (tag === "img") { + const img = parseImg(el); + if (img) out.push(img); + return; + } + if (tag === "figure") { out.push(parseFigure(el)); return; } + if (tag === "hr") { out.push({ t: "hr" }); return; } + + if (tag === "div" || tag === "section" || tag === "article") { + for (const child of el.childNodes) pushBlocksFromNode(child, out); + return; + } + + // Anything else → escape hatch so round-tripping stays lossless. + out.push({ t: "html", src: el.outerHTML }); +}; + +// ─── per-block parsers ─────────────────────────────────────────────────── + +const parseList = (el: HTMLElement, tag: "ul" | "ol"): SxBlock => { + const items: SxBlock[][] = []; + for (const child of el.childNodes) { + if (child.nodeType !== NodeType.ELEMENT_NODE) continue; + const childEl = child as HTMLElement; + if (childEl.tagName?.toLowerCase() !== "li") continue; + const itemBlocks = parseListItem(childEl); + if (itemBlocks.length > 0) items.push(itemBlocks); + } + return { t: tag, items }; +}; + +// Walk an
  • 's children in source-order. Inline runs collect into +// paragraphs; block-level children (nested ul/ol/blockquote/pre/…) +// flush the current inline buffer and recurse as their own block. +// Without this split, parseInline would walk into nested
      and the +// inner text would leak into the outer paragraph. +const parseListItem = (li: HTMLElement): SxBlock[] => { + const result: SxBlock[] = []; + let inlineBuf: Node[] = []; + const flushInlines = (): void => { + if (inlineBuf.length === 0) return; + const inlines = parseInline(inlineBuf, []); + if (inlines.length > 0) result.push({ t: "p", c: inlines }); + inlineBuf = []; + }; + for (const node of li.childNodes) { + if (node.nodeType === NodeType.ELEMENT_NODE) { + const t = (node as HTMLElement).tagName?.toLowerCase(); + if (t && BLOCK_TAGS.has(t)) { + flushInlines(); + pushBlocksFromNode(node, result); + continue; + } + } + inlineBuf.push(node); + } + flushInlines(); + return result; +}; + +const parseQuote = (el: HTMLElement): SxBlock => { + const inner: SxBlock[] = []; + for (const child of el.childNodes) pushBlocksFromNode(child, inner); + if (inner.length === 0) { + const inlines = parseInline(el.childNodes, []); + if (inlines.length > 0) inner.push({ t: "p", c: inlines }); + } + return { t: "quote", c: inner }; +}; + +const parseCodeBlock = (el: HTMLElement): SxBlock => { + // Canonical shape:
      . + // Loose
      text
      also supported. + const codeChild = el.querySelector("code"); + const inner = codeChild ?? el; + const lang = parseLangFromClass(inner.getAttribute("class") ?? ""); + return { t: "code", lang, src: decodeEntities(inner.innerHTML) }; +}; + +const parseImg = (el: HTMLElement): SxBlock | null => { + const src = el.getAttribute("src") ?? ""; + if (!src) return null; + const block: { t: "img"; src: string; alt?: string; w?: number; h?: number } = { t: "img", src }; + const alt = el.getAttribute("alt"); + if (alt) block.alt = alt; + const w = numAttr(el, "width"); if (w !== undefined) block.w = w; + const h = numAttr(el, "height"); if (h !== undefined) block.h = h; + return block as SxBlock; +}; + +const parseFigure = (el: HTMLElement): SxBlock => { + const img = el.querySelector("img"); + const caption = el.querySelector("figcaption"); + if (img) { + const src = img.getAttribute("src") ?? ""; + if (src) { + const block: { t: "img"; src: string; alt?: string; caption?: string; w?: number; h?: number } = { t: "img", src }; + const alt = img.getAttribute("alt"); if (alt) block.alt = alt; + if (caption) block.caption = caption.text; + const w = numAttr(img, "width"); if (w !== undefined) block.w = w; + const h = numAttr(img, "height"); if (h !== undefined) block.h = h; + return block as SxBlock; + } + } + return { t: "html", src: el.outerHTML }; +}; + +// ─── inline parsing ────────────────────────────────────────────────────── + +const parseInline = (nodes: Node[] | undefined, marks: SxMark[]): SxInline[] => { + if (!nodes) return []; + const out: SxInline[] = []; + for (const node of nodes) { + if (node.nodeType === NodeType.TEXT_NODE) { + const v = decodeEntities(node.text ?? ""); + if (v.length > 0) { + out.push({ t: "text", v, ...(marks.length ? { m: dedupeMarks(marks) } : {}) }); + } + continue; + } + if (node.nodeType !== NodeType.ELEMENT_NODE) continue; + const el = node as HTMLElement; + const tag = el.tagName?.toLowerCase(); + if (!tag) continue; + + if (tag === "br") { + out.push({ t: "text", v: "\n", ...(marks.length ? { m: dedupeMarks(marks) } : {}) }); + continue; + } + + if (tag === "a") { + const href = el.getAttribute("href") ?? ""; + out.push({ t: "a", href, c: parseInline(el.childNodes, marks) }); + continue; + } + + const mark = MARK_FOR_TAG[tag]; + if (mark) { + out.push(...parseInline(el.childNodes, [...marks, mark])); + continue; + } + + // , , etc. — strip wrapper, keep contents. + out.push(...parseInline(el.childNodes, marks)); + } + return out; +}; + +const dedupeMarks = (marks: SxMark[]): SxMark[] => { + const seen = new Set(); + const out: SxMark[] = []; + for (const m of marks) if (!seen.has(m)) { seen.add(m); out.push(m); } + return out; +}; + +// ─── shortcode lifting ────────────────────────────────────────────────── + +// When a

      contains [[sx:foo]] tokens mixed with text, split it into +// (paragraph)(shortcode)(paragraph) blocks so the document is queryable +// per-shortcode rather than per-paragraph-with-substring. +const splitShortcodesFromParagraph = (inlines: SxInline[]): SxBlock[] => { + const out: SxBlock[] = []; + let buf: SxInline[] = []; + const flush = (): void => { + if (buf.length > 0 && buf.some((i) => !(i.t === "text" && i.v.trim() === ""))) { + out.push({ t: "p", c: buf }); + } + buf = []; + }; + for (const i of inlines) { + if (i.t !== "text" || !SHORTCODE_RE.test(i.v)) { + buf.push(i); + continue; + } + SHORTCODE_RE.lastIndex = 0; + const blocks = textWithShortcodesToBlocks(i.v, i.m ?? []); + for (const b of blocks) { + if (b.t === "shortcode") { + flush(); + out.push(b); + } else if (b.t === "p") { + for (const inner of b.c) buf.push(inner); + } + } + } + flush(); + return out; +}; + +const textWithShortcodesToBlocks = (text: string, marks: SxMark[]): SxBlock[] => { + const out: SxBlock[] = []; + let last = 0; + SHORTCODE_RE.lastIndex = 0; + for (const m of text.matchAll(SHORTCODE_RE)) { + const idx = m.index ?? 0; + if (idx > last) { + const before = text.slice(last, idx); + if (before.trim() !== "") { + out.push({ t: "p", c: [{ t: "text", v: before, ...(marks.length ? { m: marks } : {}) }] }); + } + } + const name = m[1]!; + const args: Record = {}; + for (const a of (m[2] ?? "").matchAll(SHORTCODE_ARG_RE)) { + args[a[1]!] = a[2] ?? a[3] ?? ""; + } + out.push({ t: "shortcode", name, args }); + last = idx + m[0].length; + } + const tail = text.slice(last); + if (tail.trim() !== "") { + out.push({ t: "p", c: [{ t: "text", v: tail, ...(marks.length ? { m: marks } : {}) }] }); + } + return out; +}; + +// ─── small helpers ─────────────────────────────────────────────────────── + +const parseLangFromClass = (cls: string): string => { + const m = cls.match(/(?:^|\s)language-([\w-]+)/); + return m?.[1] ?? ""; +}; + +const numAttr = (el: HTMLElement, name: string): number | undefined => { + const v = el.getAttribute(name); + if (!v) return undefined; + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : undefined; +}; + +const decodeEntities = (s: string): string => + s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " "); diff --git a/src/c51_render_admin.ts b/src/c51_render_admin.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2cd79ebd675a577abac15314a792f8dc3a7528f --- /dev/null +++ b/src/c51_render_admin.ts @@ -0,0 +1,163 @@ +// c51 — UI: shells for the admin sxdoc editor. +// +// Three views: list (GET /admin), edit form (GET /admin/edit/...), and +// auth walls for non-admin viewers. Body builders return HTML strings; +// the c21 handler wraps them in htmlResponse. +// +// Fase 2a: raw-HTML textarea editor. Fase 2b adds the block editor on +// top — the textarea stays as the underlying form field, and the +// block-editor JS will hydrate it into a typed UI. So the form shape +// here is forward-compatible with the block editor that lands next. + +import { escape, renderPage } from "./c51_render_layout.ts"; +import type { SxDocumentSummary } from "./c13_database.ts"; +import type { SxDocument } from "./c31_sxdoc.ts"; +import { sxToHtml } from "./c51_render_sxdoc.ts"; + +export const renderAdminList = async (documents: SxDocumentSummary[]): Promise => { + const pages = documents.filter((d) => d.type === "page"); + const posts = documents.filter((d) => d.type === "post"); + const body = `# admin + +[+ new document](/admin/new) + +## pages (${pages.length}) + +${pages.length === 0 ? "_no pages yet — migrate or create one._" : adminTable(pages)} + +## posts (${posts.length}) + +${posts.length === 0 ? "_no posts yet — migrate or create one._" : adminTable(posts)} + +[← back to home](/) +`; + return renderPage({ + title: "admin — tdd.md", + bodyMarkdown: body, + noindex: true, + }); +}; + +const adminTable = (rows: SxDocumentSummary[]): string => { + const lines = rows.map((r) => + `| [${escape(r.title)}](/admin/edit/${r.type}/${r.slug}) | \`${escape(r.slug)}\` | ${r.status} | ${r.primaryTag ?? "—"} |`, + ); + return `| title | slug | status | tag | +|---|---|---|---| +${lines.join("\n")}`; +}; + +export interface AdminEditViewModel { + mode: "new" | "edit"; + title: string; + slug: string; + type: "page" | "post"; + // SxDocument is the canonical input — server projects it to HTML for + // the textarea and embeds the JSON for the client editor's hydration. + doc: SxDocument; + status: "published" | "draft"; + primaryTag: string | null; + error?: string; +} + +// Embed JSON safely inside " in user content can't break out of the +// script tag. JSON.parse handles "<" identically to "<". +const safeJsonForScript = (value: unknown): string => + JSON.stringify(value).replace(/ => { + const action = vm.mode === "new" ? "/admin/new" : `/admin/edit/${vm.type}/${vm.slug}`; + const heading = vm.mode === "new" ? "new document" : "edit document"; + const submitLabel = vm.mode === "new" ? "Create" : "Save"; + const html = sxToHtml(vm.doc); + const docJson = safeJsonForScript(vm.doc); + + const errorBlock = vm.error + ? `

      ${escape(vm.error)}

      ` + : ""; + + // Delete button uses a separate form to avoid posting the entire edit + // payload to the delete endpoint. confirm() catches accidental clicks. + const deleteForm = vm.mode === "edit" + ? `
      + + ` + : ""; + + const form = `
      + ${errorBlock} + + +
      + + + +
      + +
      + + Cancel +
      + +${deleteForm} + +`; + + const title = vm.mode === "new" + ? "new — admin — tdd.md" + : `${vm.title} — admin — tdd.md`; + return renderPage({ + title, + bodyHtml: `

      ${heading}

      ${form}`, + noindex: true, + }); +}; + +export const renderAdminLoginWall = async (): Promise => + renderPage({ + title: "admin — sign in — tdd.md", + bodyMarkdown: `# admin + +> Sign in with GitHub to access the admin UI. + +[ sign in with github → ](/auth/github/start) + +[← back to home](/)`, + noindex: true, + }); + +export const renderAdminNonAdminWall = async (viewer: string): Promise => + renderPage({ + title: "admin — not authorized — tdd.md", + bodyMarkdown: `# not authorized + +> You are signed in as \`${escape(viewer)}\`, but the admin UI is reserved for the site admin. + +[← back to home](/) · [your agent](/agents/${escape(viewer)})`, + noindex: true, + }); diff --git a/src/c51_render_commit.ts b/src/c51_render_commit.ts index f9b2e7ac6b4f24efd63310c45852aabe3839f15d..2a6ff660e903a2b64660c45586244e0f81a6fe56 100644 --- a/src/c51_render_commit.ts +++ b/src/c51_render_commit.ts @@ -123,5 +123,6 @@ export const renderCommitView = async (params: { description: `Commit ${shortSha(detail.sha)} on ${owner}/${repo}: ${subject}`, noindex: true, bodyClass: "commit-body-page", + hideNav: true, }); }; diff --git a/src/c51_render_docs_layout.ts b/src/c51_render_docs_layout.ts index a70372312f2fcc5caeaafe838b47e0c05b78e6d1..88a112b05b47e63e37b202883246e412392ba06d 100644 --- a/src/c51_render_docs_layout.ts +++ b/src/c51_render_docs_layout.ts @@ -1,16 +1,13 @@ // c51 (docs-layout) — UI: GitBook-style chrome around the existing -// renderPage. Wraps content with a left sidebar (sections from -// SITE_NAV), a right "on this page" anchor rail (h2/h3 from the -// rendered body), an edit-on-GitHub link at the top of content, and -// a prev/next navigator at the bottom. Per SAMA: imports c31 (data), -// c32 (logic), and c51_render_layout (chrome). No I/O of its own. +// renderPage. Wraps content with a right "on this page" anchor rail +// (h2/h3 from the rendered body), an edit-on-GitHub link at the top +// of content, and a prev/next navigator at the bottom. Per SAMA: +// imports c31 (data), c32 (logic), and c51_render_layout (chrome). +// No I/O of its own. import { marked } from "marked"; import { - SITE_NAV, resolveDocsLocation, - type DocsNavLink, - type DocsNavSection, type ResolvedDocsLocation, } from "./c31_docs_nav.ts"; import { extractAnchors, type Anchor } from "./c32_anchor_extract.ts"; @@ -22,7 +19,7 @@ import { export interface DocsPageOptions extends Omit { // The route path the user is on, e.g. "/sama/sorted". Used to - // highlight the active sidebar entry and compute prev/next. + // compute prev/next. pathForDocs: string; // Optional override of which file the "edit on GitHub" link // targets, when the body isn't a content/
      /.md. @@ -30,27 +27,6 @@ export interface DocsPageOptions extends Omit { editPathOverride?: string | null; } -const sidebarLink = (link: DocsNavLink, current: string): string => { - const cls = link.href === current ? "docs-side-link docs-side-link-active" : "docs-side-link"; - return `
    • ${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 @@ -121,13 +97,11 @@ export const renderDocsPage = async (opts: DocsPageOptions): Promise => 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} diff --git a/src/c51_render_layout.ts b/src/c51_render_layout.ts index 0bfdad3ef54773a8f4c7eeba8c3cba44b392671d..705117b91c8bfcebd7079f0c336006fb3aa7b984 100644 --- a/src/c51_render_layout.ts +++ b/src/c51_render_layout.ts @@ -27,9 +27,14 @@ export interface PageOptions { noindex?: boolean; jsonLd?: Record; bodyClass?: string; + // Skip the top nav bar (tdd.md · games · guides · sama · blog · agents + // · leaderboard). Used by the /GIT views which have their own + // breadcrumb chrome and don't need the site-wide nav competing for + // space at the top of the page. + hideNav?: boolean; } -const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts."; +const SITE_DESCRIPTION = "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic. Four pillars, one CI verifier."; export const escape = (s: string): string => s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); @@ -75,7 +80,7 @@ ${robots} ${jsonLd} -${nav(opts.active)} +${opts.hideNav ? "" : nav(opts.active)}
      ${body}
      diff --git a/src/c51_render_repo.ts b/src/c51_render_repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..d13f5fc2ca0fd821337769575eaf579a347b3946 --- /dev/null +++ b/src/c51_render_repo.ts @@ -0,0 +1,154 @@ +// c51 — UI: tree listing + blob viewer for the local bare repo. +// Visited at /GIT/:owner/:repo/tree/:ref/ and /blob/:ref/. +// Renders through tdd.md's chrome (renderPage with bodyHtml). Markdown +// blobs get parsed via marked; everything else is rendered as +// preformatted source. + +import { marked } from "marked"; +import { renderPage, escape } from "./c51_render_layout.ts"; +import type { TreeEntry } from "./c14_git.ts"; + +const shortSha = (sha: string): string => sha.slice(0, 7); + +// Build a breadcrumb: "owner/repo · main · content/blog" with each +// segment a clickable link to /GIT/.../tree//. +const renderBreadcrumb = (params: { + owner: string; + repo: string; + ref: string; + path: string; + asBlob?: boolean; +}): string => { + const { owner, repo, ref, path, asBlob } = params; + const repoLink = `${escape(owner)}/${escape(repo)}`; + const refLink = `${escape(ref)}`; + if (path === "") return `

      ${repoLink} · ${refLink}

      `; + + const segments = path.split("/"); + const lastIdx = segments.length - 1; + const links = segments + .map((seg, i) => { + const so_far = segments.slice(0, i + 1).join("/"); + // For blob view, the last segment is the file itself — no link. + // For tree view, every segment links to the tree at that depth. + const isLastFile = asBlob && i === lastIdx; + if (isLastFile) return `${escape(seg)}`; + return `${escape(seg)}`; + }) + .join(" / "); + return `

      ${repoLink} · ${refLink} · ${links}

      `; +}; + +// Sort: trees first, then blobs, alphabetically within each group. +// Mirrors what GitHub / Forgejo's tree views do. +const sortEntries = (entries: TreeEntry[]): TreeEntry[] => { + return [...entries].sort((a, b) => { + if (a.type !== b.type) return a.type === "tree" ? -1 : 1; + return a.name.localeCompare(b.name); + }); +}; + +const renderTreeRow = (params: { + entry: TreeEntry; + owner: string; + repo: string; + ref: string; + parentPath: string; +}): string => { + const { entry, owner, repo, ref, parentPath } = params; + const childPath = parentPath === "" ? entry.name : `${parentPath}/${entry.name}`; + const icon = + entry.type === "tree" ? "📁" : + entry.type === "commit" ? "🔗" : // submodule + "📄"; + const kind = entry.type === "tree" ? "tree" : "blob"; + const href = `/GIT/${escape(owner)}/${escape(repo)}/${kind}/${escape(ref)}/${escape(childPath)}`; + return `
  • + + + +`; +}; + +export const renderRepoTree = async (params: { + owner: string; + repo: string; + ref: string; + path: string; + entries: TreeEntry[]; +}): Promise => { + const { owner, repo, ref, path, entries } = params; + const sorted = sortEntries(entries); + const upRow = path === "" + ? "" + : (() => { + const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; + const upHref = parentPath === "" + ? `/GIT/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}` + : `/GIT/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}/${escape(parentPath)}`; + return ``; + })(); + const rows = entries.length === 0 + ? `` + : upRow + sorted.map((entry) => renderTreeRow({ entry, owner, repo, ref, parentPath: path })).join(""); + + const titlePath = path === "" ? "" : ` · ${path}`; + const inner = `
    + ${renderBreadcrumb({ owner, repo, ref, path })} +

    ${escape(path === "" ? `${owner}/${repo}` : path)}

    +

    ${entries.length} entr${entries.length === 1 ? "y" : "ies"} at ${escape(ref)}

    +
    ${icon}${escape(entry.name)}${escape(shortSha(entry.sha))}
    ..
    empty tree
    ${rows}
    +`; + + return renderPage({ + title: `${owner}/${repo}${titlePath} — tdd.md`, + bodyHtml: inner, + description: `Repository tree at ${ref}${path ? "/" + path : ""} on tdd.md.`, + noindex: true, + bodyClass: "commit-body-page", + hideNav: true, + }); +}; + +const isMarkdown = (path: string): boolean => path.endsWith(".md"); + +export const renderRepoBlob = async (params: { + owner: string; + repo: string; + ref: string; + path: string; + content: string; +}): Promise => { + const { owner, repo, ref, path, content } = params; + const filename = path.split("/").pop() ?? path; + + // Markdown gets rendered through marked; code files get a
    
    +  // block; everything else also 
     (we don't try to syntax-highlight,
    +  // just render readable monospace).
    +  const bodyHtml = isMarkdown(path)
    +    ? `
    ${await marked.parse(content, { gfm: true, breaks: false })}
    ` + : `
    ${escape(content)}
    `; + + const inner = `
    + ${renderBreadcrumb({ owner, repo, ref, path, asBlob: true })} +
    + ${escape(filename)} + ${content.split("\n").length} lines · ${content.length} bytes + + raw + ${isMarkdown(path) ? `· source` : ""} + +
    + ${bodyHtml} +
    `; + + return renderPage({ + title: `${path} · ${owner}/${repo} — tdd.md`, + bodyHtml: inner, + description: `${path} at ${ref} on tdd.md.`, + noindex: true, + bodyClass: "commit-body-page", + hideNav: true, + }); +}; + diff --git a/src/c51_render_sxdoc.test.ts b/src/c51_render_sxdoc.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d708c79ee269dce4041fd9e370cca4f8d762d63 --- /dev/null +++ b/src/c51_render_sxdoc.test.ts @@ -0,0 +1,240 @@ +import { test, expect } from "bun:test"; +import { sxToHtml } from "./c51_render_sxdoc.ts"; +import { htmlToSx } from "./c31_sxdoc_parse.ts"; +import { SX_DOC_VERSION, emptyDocument, type SxDocument } from "./c31_sxdoc.ts"; + +test("renders the empty document as empty string", () => { + expect(sxToHtml(emptyDocument())).toBe(""); +}); + +test("renders a paragraph", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "p", c: [{ t: "text", v: "hello" }] }], + }); + expect(out).toBe("

    hello

    "); +}); + +test("renders headings at the correct level", () => { + for (const level of [1, 2, 3, 4, 5, 6] as const) { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "h", level, c: [{ t: "text", v: "X" }] }], + }); + expect(out).toBe(`X`); + } +}); + +test("renders ul and ol with li wrappers", () => { + const ul = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ + t: "ul", + items: [ + [{ t: "p", c: [{ t: "text", v: "one" }] }], + [{ t: "p", c: [{ t: "text", v: "two" }] }], + ], + }], + }); + expect(ul).toBe("
    • one

    • two

    "); + const ol = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "ol", items: [[{ t: "p", c: [{ t: "text", v: "a" }] }]] }], + }); + expect(ol).toBe("
    1. a

    "); +}); + +test("renders blockquote with inner blocks", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ + t: "quote", + c: [{ t: "p", c: [{ t: "text", v: "quoted" }] }], + }], + }); + expect(out).toBe("

    quoted

    "); +}); + +test("renders code block with language class", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "code", lang: "ts", src: "const x = 1;" }], + }); + expect(out).toBe(`
    const x = 1;
    `); +}); + +test("renders code block without lang as plain pre>code", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "code", src: "raw" }], + }); + expect(out).toBe(`
    raw
    `); +}); + +test("escapes html entities inside code source", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "code", src: "

    " }], + }); + expect(out).toContain("<p>"); +}); + +test("renders img with src and alt", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "img", src: "/x.png", alt: "x" }], + }); + expect(out).toBe(`x`); +}); + +test("wraps captioned img in a figure", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "img", src: "/y.png", caption: "nice" }], + }); + expect(out).toBe(`

    nice
    `); +}); + +test("renders hr", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "hr" }], + }); + expect(out).toBe("
    "); +}); + +test("passes html escape-hatch through verbatim", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "html", src: "
    x
    " }], + }); + expect(out).toBe("
    x
    "); +}); + +test("renders shortcodes without args using a compact form", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "shortcode", name: "event-count", args: {} }], + }); + expect(out).toBe("[[sx:event-count]]"); +}); + +test("renders shortcodes with args quoted", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ t: "shortcode", name: "list", args: { tag: "blog", limit: "5" } }], + }); + expect(out).toBe(`[[sx:list tag="blog" limit="5"]]`); +}); + +test("renders bold and italic marks deterministically", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ + t: "p", + c: [{ t: "text", v: "both", m: ["i", "b"] }], + }], + }); + expect(out).toBe("

    both

    "); +}); + +test("renders anchor links", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ + t: "p", + c: [{ t: "a", href: "/x", c: [{ t: "text", v: "click" }] }], + }], + }); + expect(out).toBe(`

    click

    `); +}); + +test("escapes quotes and angle brackets in attributes", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ + t: "p", + c: [{ t: "a", href: `/a"x

    `); +}); + +test("renders inline newline as
    ", () => { + const out = sxToHtml({ + v: SX_DOC_VERSION, + blocks: [{ + t: "p", + c: [ + { t: "text", v: "a" }, + { t: "text", v: "\n" }, + { t: "text", v: "b" }, + ], + }], + }); + expect(out).toBe("

    a
    b

    "); +}); + +// ─── round-trip property tests ─────────────────────────────────────────── +// htmlToSx(sxToHtml(doc)) === doc must hold for representative docs. + +test("round-trip: simple paragraph", () => { + const doc: SxDocument = { + v: SX_DOC_VERSION, + blocks: [{ t: "p", c: [{ t: "text", v: "hello" }] }], + }; + expect(htmlToSx(sxToHtml(doc))).toEqual(doc); +}); + +test("round-trip: heading + paragraph + hr", () => { + const doc: SxDocument = { + v: SX_DOC_VERSION, + blocks: [ + { t: "h", level: 2, c: [{ t: "text", v: "Title" }] }, + { t: "p", c: [{ t: "text", v: "body" }] }, + { t: "hr" }, + ], + }; + expect(htmlToSx(sxToHtml(doc))).toEqual(doc); +}); + +test("round-trip: list of paragraphs", () => { + const doc: SxDocument = { + v: SX_DOC_VERSION, + blocks: [{ + t: "ul", + items: [ + [{ t: "p", c: [{ t: "text", v: "one" }] }], + [{ t: "p", c: [{ t: "text", v: "two" }] }], + ], + }], + }; + expect(htmlToSx(sxToHtml(doc))).toEqual(doc); +}); + +test("round-trip: marks preserved across re-parse", () => { + const doc: SxDocument = { + v: SX_DOC_VERSION, + blocks: [{ + t: "p", + c: [{ t: "text", v: "x", m: ["b", "i"] }], + }], + }; + expect(htmlToSx(sxToHtml(doc))).toEqual(doc); +}); + +test("round-trip: shortcode survives the trip", () => { + const doc: SxDocument = { + v: SX_DOC_VERSION, + blocks: [{ t: "shortcode", name: "event-count", args: {} }], + }; + expect(htmlToSx(sxToHtml(doc))).toEqual(doc); +}); + +test("round-trip: code block with language", () => { + const doc: SxDocument = { + v: SX_DOC_VERSION, + blocks: [{ t: "code", lang: "ts", src: "const x = 1;" }], + }; + expect(htmlToSx(sxToHtml(doc))).toEqual(doc); +}); diff --git a/src/c51_render_sxdoc.ts b/src/c51_render_sxdoc.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f1afbcf289997045a82ddd96d6448ab487601f3 --- /dev/null +++ b/src/c51_render_sxdoc.ts @@ -0,0 +1,132 @@ +// c51 — SxDocument → HTML renderer. +// +// SAMA placement: c51 because this file produces HTML — Architecture.md +// picking-order regel 4: "Does it produce HTML? Yes → c51". Sub-page +// renderer (fragment-level) used by c51_render_layout / page builders to +// embed sxdoc content inside larger templates. +// +// Pure deterministic transform — no DOM, no I/O, no time, no randomness. + +import type { + SxDocument, SxBlock, SxInline, SxMark, SxShortcode, +} from "./c31_sxdoc.ts"; + +export const sxToHtml = (doc: SxDocument): string => + doc.blocks.map(renderBlock).join("\n"); + +// ─── block-level ───────────────────────────────────────────────────────── + +const renderBlock = (block: SxBlock): string => { + switch (block.t) { + case "p": + return `

    ${renderInline(block.c)}

    `; + + case "h": + return `${renderInline(block.c)}`; + + case "ul": + case "ol": { + const items = block.items + .map((blocks) => `
  • ${blocks.map(renderBlock).join("")}
  • `) + .join(""); + return `<${block.t}>${items}`; + } + + case "li": + return `
  • ${block.c.map(renderBlock).join("")}
  • `; + + case "quote": + return `
    ${block.c.map(renderBlock).join("")}
    `; + + case "code": + return renderCodeBlock(block); + + case "img": + return renderImg(block); + + case "hr": + return `
    `; + + case "html": + // Raw passthrough — trust whoever inserted it. The parser only + // emits SxHtml for round-trip-preservation of unknown HTML. + return block.src; + + case "shortcode": + return renderShortcode(block); + } +}; + +const renderCodeBlock = (block: { lang?: string; src: string }): string => { + const langClass = block.lang ? ` class="language-${escAttr(block.lang)}"` : ""; + return `
    ${escText(block.src)}
    `; +}; + +const renderImg = (block: { src: string; alt?: string; caption?: string; w?: number; h?: number }): string => { + const attrs = [`src="${escAttr(block.src)}"`]; + if (block.alt !== undefined) attrs.push(`alt="${escAttr(block.alt)}"`); + if (block.w !== undefined) attrs.push(`width="${block.w}"`); + if (block.h !== undefined) attrs.push(`height="${block.h}"`); + const img = ``; + if (block.caption) { + return `
    ${img}
    ${escText(block.caption)}
    `; + } + return img; +}; + +const renderShortcode = (block: SxShortcode): string => { + const args = Object.entries(block.args) + .map(([k, v]) => `${k}="${v.replace(/"/g, """)}"`) + .join(" "); + return args ? `[[sx:${block.name} ${args}]]` : `[[sx:${block.name}]]`; +}; + +// ─── inline ────────────────────────────────────────────────────────────── + +// Stable mark order — matters so round-tripping is deterministic. The +// parser dedupes marks per text-run; renderer wraps them in this fixed +// order regardless of input ordering. +const MARK_ORDER: SxMark[] = ["b", "i", "u", "s", "c"]; +const MARK_TAG: Record = { + b: "strong", i: "em", u: "u", s: "s", c: "code", +}; + +const renderInline = (inlines: SxInline[]): string => + inlines.map(renderOneInline).join(""); + +const renderOneInline = (inline: SxInline): string => { + if (inline.t === "a") { + return `${renderInline(inline.c)}`; + } + // Newline runs render as
    . Marks on a
    are meaningless so we + // drop them — the parser already emits them on the next text run. + if (inline.v === "\n") return "
    "; + let body = escText(inline.v); + if (inline.m && inline.m.length > 0) { + // MARK_ORDER lists marks outer→inner. Wrap in reverse so the + // innermost mark is applied first, leaving the outermost-listed + // mark as the outermost tag. Without the reverse, the deepest tag + // becomes the outermost — and a re-parse flips the mark order. + const sortedMarks = MARK_ORDER.filter((m) => inline.m!.includes(m)); + for (let i = sortedMarks.length - 1; i >= 0; i--) { + const m = sortedMarks[i]!; + body = `<${MARK_TAG[m]}>${body}`; + } + } + return body; +}; + +// ─── escape helpers ────────────────────────────────────────────────────── + +const escText = (s: string): string => + s + .replace(/&/g, "&") + .replace(//g, ">"); + +const escAttr = (s: string): string => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); diff --git a/src/client/blockeditor.ts b/src/client/blockeditor.ts new file mode 100644 index 0000000000000000000000000000000000000000..85b01e61fc1114ea9d0cc8b080f3b6ea92a0ebad --- /dev/null +++ b/src/client/blockeditor.ts @@ -0,0 +1,336 @@ +// src/client — admin block editor: hydrates the admin edit form's +// textarea into a typed-block UI. Read SxDocument JSON from a +//