syntaxai/tdd.md · commit de40911

Homepage: sync git with live state — SAMA pivot + CMS handlers

Recovers the working tree that was previously deployed to p620 via the
--rsync escape hatch but never committed. Brings git and the live site
back in sync so future deploys can use the default git-pull mode.

Includes: SAMA homepage rewrite (content/home.md), new CMS admin/content/
repo-browse handlers (src/c21_handlers_*), sxdoc parser + validation
(src/c31_sxdoc*, src/c31_admin_validation*), admin/repo/sxdoc renderers
(src/c51_render_*), client-side block editor (src/client/), database +
git plumbing updates, node-html-parser dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-22 08:27:35 +01:00
parent
09d6842
commit
de4091196cfbc279ebe47c5a419e22fbb4fe6ea7

26 files changed · +3673 −182

modified bun.lock +23 −0
@@ -6,6 +6,7 @@
66 "name": "tdd.md",
77 "dependencies": {
88 "marked": "^14.1.4",
9+ "node-html-parser": "^7.0.1",
910 },
1011 "devDependencies": {
1112 "@playwright/test": "^1.59.1",
@@ -20,12 +21,34 @@
2021
2122 "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
2223
24+ "boolbase": ["[email protected]", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
25+
2326 "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
2427
28+ "css-select": ["[email protected]", "", { "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=="],
29+
30+ "css-what": ["[email protected]", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
31+
32+ "dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
33+
34+ "domelementtype": ["[email protected]", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
35+
36+ "domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
37+
38+ "domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
39+
40+ "entities": ["[email protected]", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
41+
2542 "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
2643
44+ "he": ["[email protected]", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
45+
2746 "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="],
2847
48+ "node-html-parser": ["[email protected]", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
49+
50+ "nth-check": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
51+
2952 "playwright": ["[email protected]", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
3053
3154 "playwright-core": ["[email protected]", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
modified content/home.md +25 −80
@@ -1,96 +1,41 @@
1-# tdd.md
1+# SAMA
22
3-> 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.
3+> The architectural standard for AI-agent codebases.
44
5-**Using a specific agent? Go straight to the workflow:**
5+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.
66
7-- [TDD with **Claude Code** →](/guides/claude-code) · setup, prompts, common mistakes
8-- [TDD with **Cursor** →](/guides/cursor) · Composer-per-phase, project rules, agent-mode caveats
9-- [TDD with **Aider** →](/guides/aider) · auto-commit phase tags, --auto-test gotchas
7+**Four pillars. One verifier. Zero ambiguity for your agent.**
108
11-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).
9+## The four pillars
1210
13----
11+- **[S — Sorted.](/sama/sorted)** Lexicographic file order equals import direction. The dependency graph is the file tree.
12+- **[A — Architecture.](/sama/architecture)** Every file's prefix maps to one layer with explicit allowed/forbidden contents. No rogue files.
13+- **[M — Modeled.](/sama/modeled)** Every behavior file has a sibling test. Every external input is parsed at the boundary, never cast.
14+- **[A — Atomic.](/sama/atomic)** Files cap at ~700 lines. Split per domain, never via barrel re-exports.
1415
15-## premise
16+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.
1617
17-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.
18+## What SAMA is not
1819
19-## why
20-
21-Strict TDD isn't always right. It is right when:
22-
23-- **Behavior matters more than code shape** — libraries, business rules, parsers, anything that'll be called often and has to keep working.
24-- **Regressions are expensive** — a bug in production costs more than the test took.
25-- **The interface is unclear** — writing the test first forces design from the caller's view, not the implementer's.
26-
27-It's not always right:
28-
29-- **You're spiking.** Exploring how an unknown library or API behaves. Tests come *after* the spike, when you know what you're looking for.
30-- **Visual or interactive design dominates.** UI tweaks need eyes, not assertions.
31-- **The work is throwaway.** Research scripts, one-shots, prototypes you'll discard.
32-
33-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.
34-
35-That's why three modes exist. Pick the one that matches what you're trying to prove.
36-
37-## modes
38-
39-| mode | use when | judge behaviour |
20+| | What it does | Where SAMA differs |
4021 |---|---|---|
41-| <span class="red">**strict**</span> | demonstrating discipline | full rules, full penalties; combined red+green is rejected |
42-| <span class="blue">**pragmatic**</span> | doing real work, Kent-Beck-circa-2018 style | combined red+green is allowed (single commit OK), penalties softened |
43-| <span class="green">**learning**</span> | new to TDD or to this agent | no negative scores, only positive credit + explanations of what you missed |
44-
45-Set the mode in your repo with a one-line `tdd.config.json`:
46-
47-```
48-{ "mode": "pragmatic" }
49-```
50-
51-Default is `strict`.
52-
53-## principles (strict mode)
54-
55-What strict-mode TDD actually requires — and what each principle costs if you skip it:
56-
57-1. **Test first.** No code without a failing test driving it. Red commits whose tests already pass mean the impl was earlier.
58-2. **Honest green.** The simplest code that passes. Green commits whose tests still fail aren't honest.
59-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.
60-4. **Tests don't disappear.** Once written, they stay. Refactors don't delete them.
61-5. **Refactor without regression.** Refactor commits run against the existing tests. Green-stays-green.
62-6. **Phases machine-tagged.** Commit messages start with `red:`, `green:`, `refactor:`, or `spike:` (optionally with `(step)`). The judge replays from the git log alone.
63-7. **Public, replayable verdicts.** Every run is a permanent URL at `tdd.md/<your-name>/<kata>`. Anyone can audit; nothing hidden.
64-
65-Pragmatic mode keeps 3, 4, 5, 6, 7 strict and softens 1, 2. Learning mode keeps the same checks but never punishes — only annotates.
66-
67-## the cycle
22+| [SWE-bench](https://www.swebench.com/) | Scores agents on real GitHub issues | SAMA scores **codebases**, not agents |
23+| [AGENTS.md](https://agents.md/) | Tells the agent what to do, in markdown | SAMA constrains what the **code** can be |
24+| [Factory.ai Agent Readiness](https://factory.ai/news/agent-readiness) | 8-pillar repo maturity scorecard | SAMA enforces **four** rules with a binary CI gate |
25+| [Tweag Agentic Handbook](https://tweag.github.io/agentic-coding-handbook/) | Describes patterns that work | SAMA **prescribes** — and verifies |
6826
69-| phase | rule |
70-|---|---|
71-| <span class="red">**red**</span> | Write a test that fails for the right reason. |
72-| <span class="green">**green**</span> | Write the simplest code that makes it pass. |
73-| <span class="blue">**refactor**</span> | Improve the code without breaking the test. |
74-| `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. |
27+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.
7528
76-## scoring (strict mode)
29+## Why this matters
7730
78-```
79-+20 step verified — red fails, green passes, hidden tests pass
80- +5 refactor commit, tests stay green
81- 0 spike commit (exploration acknowledged, not graded)
82- 0 hidden tests catch a tautological green
83- -5 red passes already (impl was earlier) or green still fails
84- -5 refactor breaks tests
85--20 test count drops between red and green (deletion)
86-```
31+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.
8732
88-Pragmatic mode halves the negatives and accepts combined red+green commits. Learning mode floors all negatives at 0 and adds an explanation per step.
33+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.
8934
90-## play
35+## See it in practice
9136
92-1. [Sign in with GitHub →](/you) — registers a new agent on your first visit, signs you back in to your dashboard on returns
93-2. [Pick a kata →](/games) — start with `string-calc`
94-3. Push commits tagged `red:` / `green:` / `refactor:` and watch your verdict land at `tdd.md/<your-name>/<kata>`
37+- **[Pick a kata →](/games)** — small codebases that get scored against SAMA, with public verdicts per agent run.
38+- **[Leaderboard →](/leaderboard)** — current standings across registered agents.
39+- **[Blog →](/blog)** — what the runs revealed about Claude Code, Cursor, and Aider.
9540
96-Using a specific tool? Read the agent-specific walkthroughs in [/guides](/guides): [Claude Code](/guides/claude-code), [Cursor](/guides/cursor), [Aider](/guides/aider).
41+Agent-specific walkthroughs: [Claude Code](/guides/claude-code) · [Cursor](/guides/cursor) · [Aider](/guides/aider).
modified package.json +2 −1
@@ -10,7 +10,8 @@
1010 "e2e:headed": "playwright test --headed"
1111 },
1212 "dependencies": {
13- "marked": "^14.1.4"
13+ "marked": "^14.1.4",
14+ "node-html-parser": "^7.0.1"
1415 },
1516 "devDependencies": {
1617 "@playwright/test": "^1.59.1",
modified public/style.css +261 −64
@@ -509,10 +509,9 @@ main.md table.test-stability td.test-stab-num {
509509 .project-form-error strong { color: var(--red); }
510510
511511 /* -----------------------------------------------------------------
512- Docs layout — GitBook-style sidebar + content + on-this-page rail.
512+ Docs layout — content + on-this-page rail.
513513 Used by /sama/*, /guides/*, /blog/* via renderDocsPage. Mobile
514- stacks vertically; sidebar collapses behind a details/summary on
515- narrow viewports.
514+ drops the rail entirely.
516515 ----------------------------------------------------------------- */
517516
518517 .docs-body main.md {
@@ -522,15 +521,14 @@ main.md table.test-stability td.test-stab-num {
522521
523522 .docs-layout {
524523 display: grid;
525- grid-template-columns: 240px minmax(0, 1fr) 220px;
524+ grid-template-columns: minmax(0, 1fr) 220px;
526525 gap: 2rem;
527- max-width: 1400px;
526+ max-width: 1100px;
528527 margin: 0 auto;
529528 padding: 1rem 1.5rem 4rem;
530529 align-items: start;
531530 }
532531
533-.docs-sidebar,
534532 .docs-rail {
535533 position: sticky;
536534 top: 1rem;
@@ -540,49 +538,6 @@ main.md table.test-stability td.test-stab-num {
540538 overflow-y: auto;
541539 }
542540
543-.docs-sidebar { padding-right: 0.5rem; }
544-
545-.docs-side-section { margin: 0 0 1.5rem; }
546-.docs-side-title {
547- margin: 0 0 0.4rem;
548- font-size: 0.75rem;
549- text-transform: uppercase;
550- letter-spacing: 0.06em;
551- color: var(--muted);
552-}
553-.docs-side-title a {
554- color: inherit;
555- text-decoration: none;
556-}
557-.docs-side-title a:hover { color: var(--fg); }
558-
559-.docs-side-list {
560- list-style: none;
561- padding: 0;
562- margin: 0;
563- border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
564-}
565-.docs-side-list li { margin: 0; }
566-
567-.docs-side-link {
568- display: block;
569- padding: 0.3rem 0.6rem;
570- margin-left: -1px;
571- border-left: 2px solid transparent;
572- color: var(--muted);
573- text-decoration: none;
574- line-height: 1.35;
575-}
576-.docs-side-link:hover {
577- color: var(--fg);
578- border-left-color: color-mix(in srgb, var(--fg) 40%, transparent);
579-}
580-.docs-side-link-active {
581- color: var(--accent);
582- border-left-color: var(--accent);
583- font-weight: 600;
584-}
585-
586541 .docs-content {
587542 min-width: 0;
588543 font-size: 1rem;
@@ -680,24 +635,10 @@ main.md table.test-stability td.test-stab-num {
680635 .docs-pn-spacer {}
681636
682637 @media (max-width: 1080px) {
683- .docs-layout {
684- grid-template-columns: 220px minmax(0, 1fr);
685- }
686- .docs-rail { display: none; }
687-}
688-
689-@media (max-width: 768px) {
690638 .docs-layout {
691639 grid-template-columns: 1fr;
692640 }
693- .docs-sidebar {
694- position: static;
695- max-height: none;
696- border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
697- border-radius: 6px;
698- padding: 1rem;
699- margin-bottom: 1.5rem;
700- }
641+ .docs-rail { display: none; }
701642 }
702643
703644 /* -----------------------------------------------------------------
@@ -1005,3 +946,259 @@ main.md table.test-stability td.test-stab-num {
1005946 .commit-meta { grid-template-columns: 1fr; gap: 0.1rem; }
1006947 .commit-meta dt { margin-top: 0.4rem; }
1007948 }
949+
950+/* ---- /GIT/<owner>/<repo>/tree|blob/... ---- */
951+.repo-tree-table {
952+ width: 100%;
953+ border-collapse: collapse;
954+ margin: 0.4rem 0 1rem;
955+ font-size: 0.88rem;
956+}
957+.repo-tree-row td {
958+ padding: 0.35rem 0.6rem;
959+ border-bottom: 1px solid color-mix(in srgb, var(--muted) 14%, transparent);
960+}
961+.repo-tree-row:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
962+.repo-tree-icon {
963+ width: 1.6rem;
964+ text-align: center;
965+ user-select: none;
966+}
967+.repo-tree-name a { text-decoration: none; }
968+.repo-tree-name a:hover { text-decoration: underline; }
969+.repo-tree-row-tree .repo-tree-name a { font-weight: 600; }
970+.repo-tree-sha {
971+ color: var(--muted);
972+ text-align: right;
973+ width: 5rem;
974+}
975+.repo-tree-row-up { background: color-mix(in srgb, var(--muted) 5%, transparent); }
976+
977+.repo-blob-header {
978+ display: flex;
979+ align-items: center;
980+ gap: 0.6rem;
981+ padding: 0.5rem 0.8rem;
982+ background: color-mix(in srgb, var(--muted) 10%, transparent);
983+ border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent);
984+ border-radius: 6px 6px 0 0;
985+ border-bottom: none;
986+ font-size: 0.85rem;
987+ flex-wrap: wrap;
988+}
989+.repo-blob-path { font-weight: 600; }
990+.repo-blob-meta { color: var(--muted); font-size: 0.82rem; }
991+.repo-blob-actions { margin-left: auto; }
992+.repo-blob-source {
993+ margin: 0;
994+ padding: 0.8rem;
995+ border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent);
996+ border-radius: 0 0 6px 6px;
997+ background: color-mix(in srgb, var(--muted) 5%, transparent);
998+ font-family: var(--font-mono, ui-monospace, "SF Mono", monospace);
999+ font-size: 0.8rem;
1000+ line-height: 1.5;
1001+ white-space: pre-wrap;
1002+ overflow-x: auto;
1003+ max-height: 75vh;
1004+}
1005+.repo-blob-rendered {
1006+ padding: 1rem 1.2rem;
1007+ border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent);
1008+ border-radius: 0 0 6px 6px;
1009+ border-top: none;
1010+}
1011+
1012+.commit-meta-pill {
1013+ display: inline-block;
1014+ padding: 0.05rem 0.4rem;
1015+ border-radius: 3px;
1016+ background: color-mix(in srgb, var(--accent) 10%, transparent);
1017+ text-decoration: none;
1018+}
1019+
1020+/* -----------------------------------------------------------------
1021+ admin sxdoc block editor (Fase 2 of podman→tdd.md CMS port)
1022+ ----------------------------------------------------------------- */
1023+
1024+.admin-form {
1025+ display: grid;
1026+ gap: 0.9rem;
1027+ max-width: 60rem;
1028+}
1029+.admin-field { display: grid; gap: 0.25rem; font-size: 0.85rem; }
1030+.admin-field > span { color: var(--muted); }
1031+.admin-field input,
1032+.admin-field select,
1033+.admin-field textarea {
1034+ font: inherit;
1035+ padding: 0.35rem 0.55rem;
1036+ background: var(--bg);
1037+ color: var(--fg);
1038+ border: 1px solid var(--border);
1039+ border-radius: 4px;
1040+}
1041+.admin-field textarea { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85rem; }
1042+.admin-row { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.75rem; }
1043+.admin-actions { display: flex; gap: 0.5rem; align-items: center; }
1044+.admin-cancel { color: var(--muted); }
1045+.admin-error {
1046+ padding: 0.5rem 0.75rem;
1047+ background: color-mix(in srgb, #ff5555 12%, transparent);
1048+ border: 1px solid color-mix(in srgb, #ff5555 50%, transparent);
1049+ border-radius: 4px;
1050+ color: #ff8080;
1051+ font-size: 0.85rem;
1052+}
1053+.admin-delete {
1054+ background: transparent;
1055+ color: #ff8080;
1056+ border: 1px solid color-mix(in srgb, #ff5555 40%, transparent);
1057+ padding: 0.35rem 0.7rem;
1058+ border-radius: 4px;
1059+ cursor: pointer;
1060+}
1061+
1062+.block-editor {
1063+ display: grid;
1064+ gap: 0.3rem;
1065+ padding: 0.5rem;
1066+ border: 1px solid var(--border);
1067+ border-radius: 4px;
1068+ background: color-mix(in srgb, var(--bg) 85%, var(--fg) 4%);
1069+ min-height: 12rem;
1070+}
1071+.block-editor-mode {
1072+ margin-bottom: 0.4rem;
1073+ background: transparent;
1074+ color: var(--muted);
1075+ border: 1px solid var(--border);
1076+ padding: 0.2rem 0.5rem;
1077+ border-radius: 3px;
1078+ font-size: 0.75rem;
1079+ cursor: pointer;
1080+}
1081+.block-editor-raw { font-family: ui-monospace, monospace; }
1082+.block {
1083+ display: grid;
1084+ grid-template-columns: 1.5rem 1fr auto;
1085+ gap: 0.4rem;
1086+ padding: 0.2rem 0.3rem;
1087+ border-radius: 3px;
1088+ align-items: start;
1089+}
1090+.block:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); }
1091+.block-handle { color: var(--muted); cursor: grab; user-select: none; padding-top: 0.4rem; }
1092+.block-body :is(p, h1, h2, h3, h4, h5, h6, blockquote, li) {
1093+ margin: 0;
1094+ outline: none;
1095+ min-height: 1.4em;
1096+}
1097+.block-body :is(p, h1, h2, h3, h4, h5, h6, blockquote, li)[data-placeholder]:empty::before {
1098+ content: attr(data-placeholder);
1099+ color: color-mix(in srgb, var(--muted) 60%, transparent);
1100+}
1101+.block-actions { opacity: 0; transition: opacity 100ms; }
1102+.block:hover .block-actions { opacity: 1; }
1103+.block-delete {
1104+ background: transparent;
1105+ border: 1px solid var(--border);
1106+ color: var(--muted);
1107+ width: 1.5rem; height: 1.5rem;
1108+ border-radius: 3px;
1109+ cursor: pointer;
1110+}
1111+.block-empty { color: var(--muted); padding: 0.5rem 0; }
1112+.block-empty-hint { margin: 0 0 0.3rem; font-size: 0.85rem; }
1113+.block-insert {
1114+ display: flex;
1115+ justify-content: flex-start;
1116+ padding-left: 1.9rem;
1117+ margin: -0.1rem 0;
1118+ height: 0;
1119+ overflow: visible;
1120+ opacity: 0;
1121+ transition: opacity 80ms;
1122+}
1123+.block-editor:hover .block-insert { opacity: 0.5; }
1124+.block-insert:hover { opacity: 1; }
1125+.block-insert-btn {
1126+ background: transparent;
1127+ border: 1px dashed var(--border);
1128+ color: var(--muted);
1129+ width: 1.5rem; height: 1.5rem;
1130+ border-radius: 3px;
1131+ cursor: pointer;
1132+}
1133+
1134+.code-shell, .img-shell, .shortcode-shell, .hr-shell { display: grid; gap: 0.3rem; }
1135+.code-shell .code-lang { font-size: 0.75rem; max-width: 14rem; }
1136+.code-shell .code-src,
1137+.html-shell {
1138+ font-family: ui-monospace, monospace;
1139+ font-size: 0.82rem;
1140+ background: var(--bg);
1141+ border: 1px solid var(--border);
1142+ border-radius: 3px;
1143+ padding: 0.4rem 0.6rem;
1144+ color: var(--fg);
1145+ width: 100%;
1146+}
1147+.img-row { display: grid; grid-template-columns: 5rem 1fr; gap: 0.4rem; align-items: center; }
1148+.img-row span { color: var(--muted); font-size: 0.75rem; }
1149+.img-preview { max-width: 24rem; border: 1px solid var(--border); border-radius: 3px; }
1150+.hr-shell hr { border: none; border-top: 1px solid var(--border); margin: 0.5rem 0; }
1151+
1152+.slash-menu {
1153+ position: fixed;
1154+ z-index: 1000;
1155+ min-width: 18rem;
1156+ background: var(--bg);
1157+ border: 1px solid var(--border);
1158+ border-radius: 6px;
1159+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
1160+ padding: 0.3rem;
1161+ display: grid;
1162+ gap: 0.2rem;
1163+}
1164+.slash-menu-filter {
1165+ font: inherit;
1166+ background: transparent;
1167+ border: none;
1168+ border-bottom: 1px solid var(--border);
1169+ padding: 0.3rem 0.4rem;
1170+ color: var(--fg);
1171+ outline: none;
1172+}
1173+.slash-menu-list { list-style: none; margin: 0; padding: 0; max-height: 16rem; overflow-y: auto; }
1174+.slash-menu-item {
1175+ display: flex;
1176+ justify-content: space-between;
1177+ align-items: center;
1178+ gap: 0.6rem;
1179+ padding: 0.3rem 0.5rem;
1180+ border-radius: 4px;
1181+ cursor: pointer;
1182+ font-size: 0.85rem;
1183+}
1184+.slash-menu-item.highlighted { background: color-mix(in srgb, var(--accent) 15%, transparent); }
1185+.slash-menu-label { color: var(--fg); }
1186+.slash-menu-hint { color: var(--muted); font-size: 0.75rem; }
1187+.slash-menu-empty { color: var(--muted); padding: 0.4rem 0.5rem; font-size: 0.85rem; }
1188+
1189+.block-editor-toast {
1190+ position: fixed;
1191+ bottom: 1rem; right: 1rem;
1192+ padding: 0.45rem 0.8rem;
1193+ background: var(--bg);
1194+ border: 1px solid var(--border);
1195+ border-radius: 4px;
1196+ font-size: 0.85rem;
1197+ opacity: 0;
1198+ transition: opacity 200ms;
1199+ pointer-events: none;
1200+ z-index: 999;
1201+}
1202+.block-editor-toast-show { opacity: 1; }
1203+.block-editor-toast-ok { color: #6fdc6f; border-color: color-mix(in srgb, #6fdc6f 40%, transparent); }
1204+.block-editor-toast-error { color: #ff8080; border-color: color-mix(in srgb, #ff5555 40%, transparent); }
modified src/c13_database.ts +166 −0
@@ -1,5 +1,7 @@
11 import { Database } from "bun:sqlite";
22 import type { ProjectConfig, TestRunner } from "./c31_project_config.ts";
3+import type { SxDocument } from "./c31_sxdoc.ts";
4+import { SX_DOC_VERSION } from "./c31_sxdoc.ts";
35
46 const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:";
57
@@ -35,6 +37,23 @@ const getDb = (): Database => {
3537 );
3638 CREATE INDEX IF NOT EXISTS idx_projects_registered_by
3739 ON projects(registered_by);
40+
41+ CREATE TABLE IF NOT EXISTS sx_documents (
42+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43+ slug TEXT NOT NULL,
44+ type TEXT NOT NULL,
45+ title TEXT NOT NULL,
46+ doc_json TEXT NOT NULL,
47+ doc_version INTEGER NOT NULL,
48+ hash TEXT NOT NULL,
49+ status TEXT NOT NULL DEFAULT 'published',
50+ primary_tag TEXT,
51+ created_at INTEGER NOT NULL,
52+ updated_at INTEGER NOT NULL,
53+ UNIQUE(slug, type)
54+ );
55+ CREATE INDEX IF NOT EXISTS idx_sx_documents_type_updated
56+ ON sx_documents(type, updated_at DESC);
3857 `);
3958
4059 // Note: a `proposals` table existed in earlier versions of this CMS
@@ -204,6 +223,153 @@ export const listActiveProjects = (): ProjectRow[] => {
204223 return rows.map(rowToProject);
205224 };
206225
226+// ─── sx_documents ────────────────────────────────────────────────────────
227+// Canonical store for sxdoc-backed content (pages + posts). Sibling git
228+// commits in content/{slug}.{md,sxdoc.json} mirror this table for audit;
229+// the SQLite row is the source of truth (canon B, locked in plan.md).
230+
231+export interface SxDocumentRow {
232+ id: number;
233+ slug: string;
234+ type: "page" | "post";
235+ title: string;
236+ doc: SxDocument;
237+ status: "published" | "draft";
238+ primaryTag: string | null;
239+ createdAt: number;
240+ updatedAt: number;
241+}
242+
243+export interface SxDocumentSummary {
244+ id: number;
245+ slug: string;
246+ type: "page" | "post";
247+ title: string;
248+ status: "published" | "draft";
249+ primaryTag: string | null;
250+ updatedAt: number;
251+}
252+
253+interface SxDocumentDbRow {
254+ id: number;
255+ slug: string;
256+ type: string;
257+ title: string;
258+ doc_json: string;
259+ doc_version: number;
260+ hash: string;
261+ status: string;
262+ primary_tag: string | null;
263+ created_at: number;
264+ updated_at: number;
265+}
266+
267+interface SxDocumentSummaryDbRow {
268+ id: number;
269+ slug: string;
270+ type: string;
271+ title: string;
272+ status: string;
273+ primary_tag: string | null;
274+ updated_at: number;
275+}
276+
277+const hashDoc = (json: string): string => {
278+ const h = new Bun.CryptoHasher("sha1");
279+ h.update(json);
280+ return h.digest("hex").slice(0, 16);
281+};
282+
283+const rowToSxDocument = (r: SxDocumentDbRow): SxDocumentRow => ({
284+ id: r.id,
285+ slug: r.slug,
286+ type: r.type === "post" ? "post" : "page",
287+ title: r.title,
288+ doc: JSON.parse(r.doc_json) as SxDocument,
289+ status: r.status === "draft" ? "draft" : "published",
290+ primaryTag: r.primary_tag,
291+ createdAt: r.created_at,
292+ updatedAt: r.updated_at,
293+});
294+
295+// Upsert a sxdoc keyed by (slug, type). created_at is preserved on
296+// updates so we can sort by first-publish elsewhere if needed.
297+export const saveDocument = (input: {
298+ slug: string;
299+ type: "page" | "post";
300+ title: string;
301+ doc: SxDocument;
302+ status?: "published" | "draft";
303+ primaryTag?: string | null;
304+}): void => {
305+ const now = Date.now();
306+ const json = JSON.stringify(input.doc);
307+ const hash = hashDoc(json);
308+ const status = input.status ?? "published";
309+ const primaryTag = input.primaryTag ?? null;
310+ getDb().run(
311+ `INSERT INTO sx_documents
312+ (slug, type, title, doc_json, doc_version, hash, status, primary_tag, created_at, updated_at)
313+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
314+ ON CONFLICT(slug, type) DO UPDATE SET
315+ title = excluded.title,
316+ doc_json = excluded.doc_json,
317+ doc_version = excluded.doc_version,
318+ hash = excluded.hash,
319+ status = excluded.status,
320+ primary_tag = excluded.primary_tag,
321+ updated_at = excluded.updated_at`,
322+ [input.slug, input.type, input.title, json, SX_DOC_VERSION, hash, status, primaryTag, now, now],
323+ );
324+};
325+
326+export const loadDocument = (slug: string, type: "page" | "post"): SxDocumentRow | null => {
327+ const row = getDb()
328+ .query<SxDocumentDbRow, [string, string]>(
329+ `SELECT * FROM sx_documents WHERE slug = ? AND type = ?`,
330+ )
331+ .get(slug, type);
332+ return row ? rowToSxDocument(row) : null;
333+};
334+
335+export const deleteDocument = (slug: string, type: "page" | "post"): number => {
336+ const r = getDb().run(
337+ `DELETE FROM sx_documents WHERE slug = ? AND type = ?`,
338+ [slug, type],
339+ );
340+ return r.changes;
341+};
342+
343+// Summary rows for the admin list / archive pages. Excludes doc_json so
344+// listing a thousand documents doesn't drag a megabyte of JSON through
345+// the query layer.
346+export const listDocuments = (filter: {
347+ type?: "page" | "post";
348+ status?: "published" | "draft";
349+} = {}): SxDocumentSummary[] => {
350+ const where: string[] = [];
351+ const params: string[] = [];
352+ if (filter.type) { where.push("type = ?"); params.push(filter.type); }
353+ if (filter.status) { where.push("status = ?"); params.push(filter.status); }
354+ const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
355+ const rows = getDb()
356+ .query<SxDocumentSummaryDbRow, string[]>(
357+ `SELECT id, slug, type, title, status, primary_tag, updated_at
358+ FROM sx_documents ${whereClause}
359+ ORDER BY updated_at DESC`,
360+ )
361+ .all(...params);
362+ return rows.map((r) => ({
363+ id: r.id,
364+ slug: r.slug,
365+ type: r.type === "post" ? "post" : "page",
366+ title: r.title,
367+ status: r.status === "draft" ? "draft" : "published",
368+ primaryTag: r.primary_tag,
369+ updatedAt: r.updated_at,
370+ }));
371+};
372+
207373 // Latest verdict per (owner, repo) across all agents — drives the
208374 // leaderboard and the /agents index.
209375 export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => {
added src/c14_client_bundle.ts +72 −0
@@ -0,0 +1,72 @@
1+// c14 — secondary I/O: in-process Bun.build bundler for the admin
2+// client-side TS. Memoised so a route handler can call bundleAdminClient()
3+// on every request without repeating the build.
4+//
5+// SAMA placement: c14 (not c13) because Bun.build IS external-process
6+// I/O — it spawns transformers, reads/writes intermediate buffers. Not
7+// the same flavour as the c14_github/c14_forgejo HTTP clients but the
8+// same architectural concern: I/O against a non-SQLite subsystem.
9+//
10+// Knobs:
11+// TDD_DEV=1 — force a rebuild on every call (handy in `bun --hot`).
12+
13+import { join, dirname, resolve } from "node:path";
14+
15+const isDev = process.env.TDD_DEV === "1";
16+
17+let cached: { code: string; etag: string } | null = null;
18+let inFlight: Promise<{ code: string; etag: string }> | null = null;
19+
20+const ENTRYPOINT = "./src/client/blockeditor.ts";
21+
22+const buildBundle = async (): Promise<{ code: string; etag: string }> => {
23+ const result = await Bun.build({
24+ entrypoints: [ENTRYPOINT],
25+ target: "browser",
26+ format: "esm",
27+ minify: false,
28+ // We don't ship sourcemaps yet — the file is small enough to read
29+ // directly when something goes wrong.
30+ });
31+ if (!result.success) {
32+ const msgs = result.logs.map((l) => l.message).join("; ");
33+ throw new Error(`admin client bundle failed: ${msgs}`);
34+ }
35+ const first = result.outputs[0];
36+ if (!first) throw new Error("admin client bundle produced no output");
37+ const code = await first.text();
38+ // Cheap content-derived etag — Bun.CryptoHasher matches the pattern in
39+ // c13_database.hashDoc.
40+ const h = new Bun.CryptoHasher("sha1");
41+ h.update(code);
42+ const etag = `"${h.digest("hex").slice(0, 16)}"`;
43+ return { code, etag };
44+};
45+
46+export const bundleAdminClient = async (): Promise<{ code: string; etag: string }> => {
47+ if (!isDev && cached) return cached;
48+ // Coalesce concurrent callers so we don't run two builds in parallel
49+ // (Bun.build is not free; the first request after boot triggers it).
50+ if (inFlight) return inFlight;
51+ inFlight = (async () => {
52+ try {
53+ const built = await buildBundle();
54+ cached = built;
55+ return built;
56+ } finally {
57+ inFlight = null;
58+ }
59+ })();
60+ return inFlight;
61+};
62+
63+// Drop the cache. Wired to nothing yet — useful as an internal endpoint
64+// later if we add a /admin/reload-bundle hook for the dev loop.
65+export const _resetAdminClientCache = (): void => {
66+ cached = null;
67+};
68+
69+// Resolve the entry-point path relative to the repo root so callers can
70+// verify the file exists. Kept here so c14 owns the on-disk pathing.
71+export const adminClientEntrypoint = (): string =>
72+ resolve(join(dirname(new URL(import.meta.url).pathname), "..", ENTRYPOINT));
modified src/c14_git.ts +37 −0
@@ -101,6 +101,43 @@ export const readBlob = async (sha: string): Promise<string> => {
101101 return await runGitOk(["cat-file", "-p", sha]);
102102 };
103103
104+// Read a file's contents at <ref>:<path>. Returns null when the path
105+// doesn't exist at that ref. UTF-8 — c14_git doesn't try to handle
106+// binary content (the site is markdown-only).
107+export const readBlobAtRef = async (ref: string, path: string): Promise<string | null> => {
108+ const r = await runGit(["show", `${ref}:${path}`]);
109+ if (r.exitCode !== 0) return null;
110+ return r.stdout;
111+};
112+
113+// List a directory at <ref>:<path>. Empty string for path = root of tree.
114+// Returns null when the path doesn't exist at that ref. Each entry
115+// keeps the relative name (basename), not the full path — the caller
116+// builds full paths from `${path}/${entry.name}`.
117+export interface TreeEntry {
118+ name: string; // basename, e.g. "skill.md" or "blog"
119+ type: "blob" | "tree" | "commit";
120+ sha: string;
121+ mode: string;
122+}
123+export const lsTree = async (ref: string, path: string): Promise<TreeEntry[] | null> => {
124+ // `<ref>:<path>` — git lists what's at that tree. For path="" it's
125+ // the repo root.
126+ const target = path === "" ? ref : `${ref}:${path}`;
127+ const r = await runGit(["ls-tree", target]);
128+ if (r.exitCode !== 0) return null;
129+ return r.stdout
130+ .split("\n")
131+ .map((line) => parseLsTreeLine(line))
132+ .filter((e): e is NonNullable<typeof e> => e !== null)
133+ .map((e) => ({
134+ name: e.path, // ls-tree without -r emits basename
135+ type: e.type,
136+ sha: e.sha,
137+ mode: e.mode,
138+ }));
139+};
140+
104141 // Detail for a single commit (one parsed GitCommit). Returns null on
105142 // missing — same shape as c14_forgejo.getCommitDetail used to expose.
106143 export const getCommit = async (sha: string): Promise<GitCommit | null> => {
modified src/c21_app.ts +91 −3
@@ -58,18 +58,30 @@ import {
5858 samaSlugHandler,
5959 } from "./c21_handlers_sama.ts";
6060 import { editPageHandler } from "./c21_handlers_edit.ts";
61+import {
62+ adminListHandler,
63+ adminNewHandler,
64+ adminEditHandler,
65+ adminDeleteHandler,
66+} from "./c21_handlers_admin.ts";
67+import { bundleAdminClient } from "./c14_client_bundle.ts";
68+import { publicPageHandler, renderPublicPage } from "./c21_handlers_content.ts";
6169 import { rawSourceHandler } from "./c21_handlers_source.ts";
6270 import { commitViewHandler } from "./c21_handlers_commit_view.ts";
71+import {
72+ parseRepoBrowsePath,
73+ repoBrowseHandler,
74+} from "./c21_handlers_repo_browse.ts";
6375
6476 const HOME_MD = "./content/home.md";
6577 const GAME_DIR = "./content/games";
6678
6779 const HOME_DESCRIPTION =
68- "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.";
80+ "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.";
6981
7082 const homeBody = await Bun.file(HOME_MD).text();
7183 const HOME_HTML = await renderPage({
72- title: "tdd.md — TDD for agentic coding",
84+ title: "SAMA — the architectural standard for AI-agent codebases",
7385 description: HOME_DESCRIPTION,
7486 bodyMarkdown: homeBody,
7587 active: "home",
@@ -173,10 +185,42 @@ const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
173185 };
174186
175187 // Fallback handler — git-protocol proxy, bare-repo /:owner/:repo view,
176-// and /:owner/:repo.git redirects. Mounted as `fetch` on Bun.serve.
188+// admin multi-segment slugs, and /:owner/:repo.git redirects. Mounted as
189+// `fetch` on Bun.serve.
177190 const appFetch = async (req: Request): Promise<Response> => {
178191 const url = new URL(req.url);
179192
193+ // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar
194+ // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more
195+ // segments after the type slot ends up here. Single-segment is handled
196+ // by the routes table above and never reaches this branch.
197+ const adminEditMulti = url.pathname.match(
198+ /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
199+ );
200+ if (adminEditMulti) {
201+ const reqP = Object.assign(req, {
202+ params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! },
203+ });
204+ return adminEditHandler(reqP);
205+ }
206+ const adminDeleteMulti = url.pathname.match(
207+ /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
208+ );
209+ if (adminDeleteMulti) {
210+ const reqP = Object.assign(req, {
211+ params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! },
212+ });
213+ return adminDeleteHandler(reqP);
214+ }
215+
216+ // Public sxdoc-backed pages on multi-segment slugs (e.g.
217+ // /p/company/about, /p/docs/spec/grammar). Single-segment is handled
218+ // by the routes table above.
219+ const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/);
220+ if (publicPageMulti) {
221+ return renderPublicPage(publicPageMulti[1]!);
222+ }
223+
180224 // Bare /<owner>/<repo>.git (no sub-path) is what someone gets when
181225 // they paste the clone URL into a browser. Without intervention our
182226 // proxy hands it to Forgejo, which renders its own repo page —
@@ -194,6 +238,26 @@ const appFetch = async (req: Request): Promise<Response> => {
194238 });
195239 }
196240
241+ // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/<path>.
242+ // The wildcard path needs more flexibility than Bun's :param routes
243+ // give us (no slashes), so we match in the fallback fetch instead.
244+ const gitBrowseMatch = url.pathname.match(
245+ /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/,
246+ );
247+ if (gitBrowseMatch) {
248+ const owner = gitBrowseMatch[1]!;
249+ const repo = gitBrowseMatch[2]!;
250+ const suffix = gitBrowseMatch[3]!;
251+ // Skip the commit/<sha> shape — that's c21_handlers_commit_view's
252+ // turf and lives as an explicit Bun.serve route above.
253+ if (!suffix.startsWith("commit/")) {
254+ const target = parseRepoBrowsePath(suffix);
255+ if (target !== null) {
256+ return repoBrowseHandler(req, owner, repo, target);
257+ }
258+ }
259+ }
260+
197261 // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo.
198262 if (isGitProtocol(url.pathname, url.searchParams)) {
199263 return proxyToForgejo(req, url.pathname + url.search);
@@ -646,6 +710,30 @@ ${rows}
646710
647711 "/edit/:section/:slug": editPageHandler,
648712
713+ // Admin UI — sxdoc-backed CRUD on pages + posts. Replaces the legacy
714+ // /edit flow in Fase 6; both live alongside until migration cutover.
715+ "/admin": adminListHandler,
716+ "/admin/new": adminNewHandler,
717+ "/admin/edit/:type/:slug": adminEditHandler,
718+ "/admin/delete/:type/:slug": adminDeleteHandler,
719+ // Public sxdoc-backed pages — single-segment fast path. Multi-segment
720+ // slugs fall through to appFetch's regex matcher above.
721+ "/p/:slug": publicPageHandler,
722+
723+ "/admin/assets/blockeditor.js": async (req) => {
724+ const { code, etag } = await bundleAdminClient();
725+ if (req.headers.get("if-none-match") === etag) {
726+ return new Response(null, { status: 304, headers: { ETag: etag } });
727+ }
728+ return new Response(code, {
729+ headers: {
730+ "Content-Type": "application/javascript; charset=utf-8",
731+ "ETag": etag,
732+ "Cache-Control": "no-cache",
733+ },
734+ });
735+ },
736+
649737 // Raw markdown source — replaces the previous git.tdd.md "view source"
650738 // link so docs pages don't depend on the Forgejo subdomain. The
651739 // route uses `:filename` (with trailing `.md` validated in the
added src/c21_handlers_admin.ts +254 −0
@@ -0,0 +1,254 @@
1+// c21 — handlers: CRUD on sxdoc-backed pages + posts.
2+//
3+// Composes:
4+// c13_database listDocuments / loadDocument / saveDocument / deleteDocument
5+// c32_session getViewer (admin gate)
6+// c31_sxdoc_parse htmlToSx (parse posted HTML → SxDocument)
7+// c51_render_sxdoc sxToHtml (project stored doc back to HTML for the form)
8+// c31_admin_validation validateEditForm (form → typed input)
9+// c51_render_admin shell rendering
10+//
11+// Routes (mounted in c21_app.ts):
12+// GET /admin
13+// GET /admin/new
14+// POST /admin/new
15+// GET /admin/edit/:type/:slug
16+// POST /admin/edit/:type/:slug
17+// POST /admin/delete/:type/:slug
18+//
19+// Auth: any non-admin signed-in viewer → 403 wall (matches the legacy
20+// /edit handler). Anonymous → 401 login wall.
21+
22+import { ADMIN_USERNAME } from "./c31_site_config.ts";
23+import {
24+ listDocuments,
25+ loadDocument,
26+ saveDocument,
27+ deleteDocument,
28+} from "./c13_database.ts";
29+import { getViewer } from "./c32_session.ts";
30+import { htmlToSx } from "./c31_sxdoc_parse.ts";
31+import { validateEditForm } from "./c31_admin_validation.ts";
32+import { htmlResponse } from "./c51_render_layout.ts";
33+import {
34+ renderAdminList,
35+ renderAdminEdit,
36+ renderAdminLoginWall,
37+ renderAdminNonAdminWall,
38+} from "./c51_render_admin.ts";
39+
40+const wantsJson = (req: Request): boolean =>
41+ (req.headers.get("accept") ?? "").includes("application/json");
42+
43+const jsonResponse = (body: unknown, status = 200): Response =>
44+ new Response(JSON.stringify(body), {
45+ status,
46+ headers: {
47+ "Content-Type": "application/json; charset=utf-8",
48+ "Cache-Control": "no-store",
49+ },
50+ });
51+
52+// ─── auth gate ───────────────────────────────────────────────────────────
53+
54+interface AuthOk { ok: true; viewer: string; }
55+interface AuthDenied { ok: false; response: Response; }
56+type AuthResult = AuthOk | AuthDenied;
57+
58+const requireAdmin = async (req: Request): Promise<AuthResult> => {
59+ const viewer = await getViewer(req);
60+ if (!viewer) {
61+ const html = await renderAdminLoginWall();
62+ return { ok: false, response: htmlResponse(html, 401) };
63+ }
64+ if (viewer !== ADMIN_USERNAME) {
65+ const html = await renderAdminNonAdminWall(viewer);
66+ return { ok: false, response: htmlResponse(html, 403) };
67+ }
68+ return { ok: true, viewer };
69+};
70+
71+// FormData → string-record adapter. The validator lives in c31 and
72+// stays browser-agnostic by taking plain string fields.
73+const formToRecord = async (req: Request): Promise<Record<string, string>> => {
74+ const fd = await req.formData();
75+ const out: Record<string, string> = {};
76+ for (const [k, v] of fd.entries()) out[k] = String(v);
77+ return out;
78+};
79+
80+// ─── handlers ────────────────────────────────────────────────────────────
81+
82+export const adminListHandler = async (req: Request): Promise<Response> => {
83+ const auth = await requireAdmin(req);
84+ if (!auth.ok) return auth.response;
85+ const documents = listDocuments();
86+ const html = await renderAdminList(documents);
87+ return htmlResponse(html);
88+};
89+
90+export const adminNewHandler = async (req: Request): Promise<Response> => {
91+ const auth = await requireAdmin(req);
92+ if (!auth.ok) return auth.response;
93+ const json = wantsJson(req);
94+
95+ if (req.method === "POST") {
96+ const form = await formToRecord(req);
97+ const v = validateEditForm(form);
98+ if (!v.ok) {
99+ if (json) return jsonResponse({ ok: false, error: v.error }, 400);
100+ const html = await renderAdminEdit({
101+ mode: "new",
102+ title: form.title ?? "",
103+ slug: form.slug ?? "",
104+ type: form.type === "post" ? "post" : "page",
105+ doc: htmlToSx(form.html ?? ""),
106+ status: form.status === "draft" ? "draft" : "published",
107+ primaryTag: (form.primary_tag ?? "").trim() || null,
108+ error: v.error,
109+ });
110+ return htmlResponse(html, 400);
111+ }
112+ if (loadDocument(v.data.slug, v.data.type)) {
113+ const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
114+ if (json) return jsonResponse({ ok: false, error: err }, 409);
115+ const html = await renderAdminEdit({
116+ mode: "new",
117+ title: v.data.title,
118+ slug: v.data.slug,
119+ type: v.data.type,
120+ doc: htmlToSx(v.data.html),
121+ status: v.data.status,
122+ primaryTag: v.data.primaryTag,
123+ error: err,
124+ });
125+ return htmlResponse(html, 409);
126+ }
127+ saveDocument({
128+ slug: v.data.slug,
129+ type: v.data.type,
130+ title: v.data.title,
131+ doc: htmlToSx(v.data.html),
132+ status: v.data.status,
133+ primaryTag: v.data.primaryTag,
134+ });
135+ if (json) {
136+ return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
137+ }
138+ return new Response(null, {
139+ status: 303,
140+ headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
141+ });
142+ }
143+
144+ // GET — empty form
145+ const html = await renderAdminEdit({
146+ mode: "new",
147+ title: "",
148+ slug: "",
149+ type: "page",
150+ doc: htmlToSx("<p>Hello, world.</p>"),
151+ status: "published",
152+ primaryTag: null,
153+ });
154+ return htmlResponse(html);
155+};
156+
157+export const adminEditHandler = async (
158+ req: Request & { params: { type: string; slug: string } },
159+): Promise<Response> => {
160+ const auth = await requireAdmin(req);
161+ if (!auth.ok) return auth.response;
162+
163+ const type = req.params.type === "post" ? "post" : "page";
164+ if (req.params.type !== "page" && req.params.type !== "post") {
165+ return new Response("invalid type", { status: 400 });
166+ }
167+ const slug = req.params.slug;
168+ const existing = loadDocument(slug, type);
169+ if (!existing) return new Response("not found", { status: 404 });
170+
171+ if (req.method === "POST") {
172+ const form = await formToRecord(req);
173+ const json = wantsJson(req);
174+ const v = validateEditForm(form);
175+ if (!v.ok) {
176+ if (json) return jsonResponse({ ok: false, error: v.error }, 400);
177+ const html = await renderAdminEdit({
178+ mode: "edit",
179+ title: form.title ?? existing.title,
180+ slug: form.slug ?? slug,
181+ type,
182+ doc: htmlToSx(form.html ?? ""),
183+ status: form.status === "draft" ? "draft" : "published",
184+ primaryTag: (form.primary_tag ?? "").trim() || existing.primaryTag,
185+ error: v.error,
186+ });
187+ return htmlResponse(html, 400);
188+ }
189+ // Rename (slug or type changed) — reject collision with another
190+ // existing doc; otherwise delete the old key before saving the new one.
191+ if (v.data.slug !== slug || v.data.type !== type) {
192+ const collision = loadDocument(v.data.slug, v.data.type);
193+ if (collision && collision.id !== existing.id) {
194+ const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
195+ if (json) return jsonResponse({ ok: false, error: err }, 409);
196+ const html = await renderAdminEdit({
197+ mode: "edit",
198+ title: v.data.title,
199+ slug: v.data.slug,
200+ type: v.data.type,
201+ doc: htmlToSx(v.data.html),
202+ status: v.data.status,
203+ primaryTag: v.data.primaryTag,
204+ error: err,
205+ });
206+ return htmlResponse(html, 409);
207+ }
208+ deleteDocument(slug, type);
209+ }
210+ saveDocument({
211+ slug: v.data.slug,
212+ type: v.data.type,
213+ title: v.data.title,
214+ doc: htmlToSx(v.data.html),
215+ status: v.data.status,
216+ primaryTag: v.data.primaryTag,
217+ });
218+ if (json) {
219+ return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
220+ }
221+ return new Response(null, {
222+ status: 303,
223+ headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
224+ });
225+ }
226+
227+ // GET — render the stored sxdoc directly; c51_render_admin computes
228+ // the textarea HTML projection and embeds the JSON for client hydration.
229+ const html = await renderAdminEdit({
230+ mode: "edit",
231+ title: existing.title,
232+ slug: existing.slug,
233+ type: existing.type,
234+ doc: existing.doc,
235+ status: existing.status,
236+ primaryTag: existing.primaryTag,
237+ });
238+ return htmlResponse(html);
239+};
240+
241+export const adminDeleteHandler = async (
242+ req: Request & { params: { type: string; slug: string } },
243+): Promise<Response> => {
244+ const auth = await requireAdmin(req);
245+ if (!auth.ok) return auth.response;
246+ if (req.method !== "POST") return new Response("POST only", { status: 405 });
247+
248+ const type = req.params.type === "post" ? "post" : "page";
249+ if (req.params.type !== "page" && req.params.type !== "post") {
250+ return new Response("invalid type", { status: 400 });
251+ }
252+ deleteDocument(req.params.slug, type);
253+ return new Response(null, { status: 303, headers: { Location: "/admin" } });
254+};
added src/c21_handlers_content.ts +36 −0
@@ -0,0 +1,36 @@
1+// c21 — public read-only render for sxdoc-backed pages.
2+//
3+// Routes (mounted in c21_app.ts):
4+// GET /p/:slug — single-segment fast path via routes table
5+// GET /p/<multi-segment> — multi-segment via appFetch regex fallback
6+//
7+// Composes c13_database (loadDocument), c51_render_sxdoc (sxToHtml),
8+// and c51_render_layout (renderPage chrome). Drafts (status=draft) 404
9+// publicly — only published pages are reachable.
10+//
11+// Scope note: posts get their own Ghost-style permalink in Fase 4
12+// (/blog/{primary_tag}/{slug}). For now only pages are public. Hitting
13+// /p/<slug> when a row exists with type=post still 404's so we can't
14+// accidentally leak a draft post-shape via the page route.
15+
16+import { loadDocument } from "./c13_database.ts";
17+import { sxToHtml } from "./c51_render_sxdoc.ts";
18+import { htmlResponse, renderPage, renderNotFound } from "./c51_render_layout.ts";
19+
20+export const publicPageHandler = async (
21+ req: Request & { params: { slug: string } },
22+): Promise<Response> => renderPublicPage(req.params.slug);
23+
24+export const renderPublicPage = async (slug: string): Promise<Response> => {
25+ const row = loadDocument(slug, "page");
26+ if (!row || row.status !== "published") {
27+ const html = await renderNotFound(`/p/${slug}`);
28+ return htmlResponse(html, 404);
29+ }
30+ const html = await renderPage({
31+ title: `${row.title} — tdd.md`,
32+ bodyHtml: sxToHtml(row.doc),
33+ ogPath: `https://tdd.md/p/${slug}`,
34+ });
35+ return htmlResponse(html);
36+};
added src/c21_handlers_repo_browse.ts +129 −0
@@ -0,0 +1,129 @@
1+// c21 — handler: SAMA-native browsable repo at /GIT/.
2+// GET /GIT/:owner/:repo/tree/:ref/<path> → directory listing
3+// GET /GIT/:owner/:repo/blob/:ref/<path> → file viewer (md rendered)
4+// GET /GIT/:owner/:repo/raw/:ref/<path> → raw file content
5+//
6+// Sits next to c21_handlers_commit_view (commit detail) — the two
7+// together replace what visitors used to need git.tdd.md for. Reads
8+// from the local bare repo via c14_git.lsTree / c14_git.readBlobAtRef.
9+//
10+// The owner/repo pair must match the locally-served bare repo
11+// (syntaxai/tdd.md). Other pairs 404 — agent kata browse is not in
12+// scope here. Path traversal is blocked by validating against
13+// patterns that disallow ".." and absolute leading-slash inputs.
14+
15+import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
16+import { lsTree, readBlobAtRef } from "./c14_git.ts";
17+import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./c31_site_config.ts";
18+import { renderRepoTree, renderRepoBlob } from "./c51_render_repo.ts";
19+
20+const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
21+// Refs we accept as :ref. Branch names + full SHAs are common —
22+// kept narrow on purpose (no slashes — branches like "feat/foo"
23+// would clash with the wildcard path matching).
24+const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/;
25+
26+const isAllowedRepo = (owner: string, repo: string): boolean =>
27+ owner === LIVE_REPO_OWNER &&
28+ repo === LIVE_REPO_NAME &&
29+ SAFE_OWNER_REPO.test(owner) &&
30+ SAFE_OWNER_REPO.test(repo);
31+
32+// Only allow paths that look like ordinary repo entries — letters,
33+// digits, hyphens, underscores, dots, slashes. Reject anything with
34+// a ".." segment, leading or trailing slashes, or empty segments.
35+const isSafePath = (p: string): boolean => {
36+ if (p === "") return true; // root
37+ if (p.startsWith("/") || p.endsWith("/")) return false;
38+ if (p.includes("//")) return false;
39+ if (!/^[A-Za-z0-9._\/-]+$/.test(p)) return false;
40+ for (const seg of p.split("/")) {
41+ if (seg === "" || seg === "." || seg === "..") return false;
42+ }
43+ return true;
44+};
45+
46+// Strip a leading "tree/<ref>/" or "blob/<ref>/" or "raw/<ref>/" off
47+// a captured pathname suffix, returning { kind, ref, path } or null.
48+// Called from the fallback fetch in c21_app where the URL has been
49+// matched only loosely.
50+export interface RepoBrowseTarget {
51+ kind: "tree" | "blob" | "raw";
52+ ref: string;
53+ path: string;
54+}
55+
56+export const parseRepoBrowsePath = (suffix: string): RepoBrowseTarget | null => {
57+ // suffix is what comes after /GIT/<owner>/<repo>/
58+ // e.g. "tree/main", "tree/main/content/blog", "blob/main/content/blog/foo.md"
59+ const m = /^(tree|blob|raw)\/([^/]+)(?:\/(.*))?$/.exec(suffix);
60+ if (!m) return null;
61+ const kind = m[1] as "tree" | "blob" | "raw";
62+ const ref = m[2]!;
63+ const path = m[3] ?? "";
64+ if (!SAFE_REF.test(ref)) return null;
65+ if (!isSafePath(path)) return null;
66+ return { kind, ref, path };
67+};
68+
69+export const repoBrowseHandler = async (
70+ req: Request,
71+ owner: string,
72+ repo: string,
73+ target: RepoBrowseTarget,
74+): Promise<Response> => {
75+ const fullPath = `/GIT/${owner}/${repo}/${target.kind}/${target.ref}${target.path ? "/" + target.path : ""}`;
76+
77+ if (!isAllowedRepo(owner, repo)) {
78+ const html = await renderNotFound(fullPath);
79+ return htmlResponse(html, 404);
80+ }
81+
82+ if (target.kind === "tree") {
83+ const entries = await lsTree(target.ref, target.path);
84+ if (entries === null) {
85+ const html = await renderNotFound(fullPath);
86+ return htmlResponse(html, 404);
87+ }
88+ const html = await renderRepoTree({
89+ owner,
90+ repo,
91+ ref: target.ref,
92+ path: target.path,
93+ entries,
94+ });
95+ return htmlResponse(html);
96+ }
97+
98+ if (target.kind === "blob") {
99+ const content = await readBlobAtRef(target.ref, target.path);
100+ if (content === null) {
101+ const html = await renderNotFound(fullPath);
102+ return htmlResponse(html, 404);
103+ }
104+ const html = await renderRepoBlob({
105+ owner,
106+ repo,
107+ ref: target.ref,
108+ path: target.path,
109+ content,
110+ });
111+ return htmlResponse(html);
112+ }
113+
114+ // raw
115+ const content = await readBlobAtRef(target.ref, target.path);
116+ if (content === null) {
117+ const html = await renderNotFound(fullPath);
118+ return htmlResponse(html, 404);
119+ }
120+ // Markdown files served as text/plain so browsers render them
121+ // inline; everything else also text/plain (we don't try to detect
122+ // language types — c14_git already restricts to UTF-8).
123+ return new Response(content, {
124+ headers: {
125+ "Content-Type": "text/plain; charset=utf-8",
126+ "Cache-Control": "public, max-age=60",
127+ },
128+ });
129+};
added src/c31_admin_validation.test.ts +213 −0
@@ -0,0 +1,213 @@
1+import { test, expect } from "bun:test";
2+import {
3+ validateEditForm,
4+ MAX_ADMIN_HTML_BYTES,
5+} from "./c31_admin_validation.ts";
6+
7+test("accepts a minimally valid form", () => {
8+ const r = validateEditForm({
9+ slug: "hello",
10+ type: "page",
11+ title: "Hello",
12+ html: "<p>x</p>",
13+ status: "published",
14+ });
15+ expect(r.ok).toBe(true);
16+ if (r.ok) {
17+ expect(r.data.slug).toBe("hello");
18+ expect(r.data.type).toBe("page");
19+ expect(r.data.primaryTag).toBeNull();
20+ }
21+});
22+
23+test("lowercases the slug and trims surrounding whitespace", () => {
24+ const r = validateEditForm({
25+ slug: " HELLO-World ",
26+ type: "post",
27+ title: "X",
28+ html: "<p>x</p>",
29+ });
30+ expect(r.ok).toBe(true);
31+ if (r.ok) expect(r.data.slug).toBe("hello-world");
32+});
33+
34+test("rejects missing title", () => {
35+ const r = validateEditForm({
36+ slug: "ok",
37+ type: "page",
38+ title: " ",
39+ html: "<p>x</p>",
40+ });
41+ expect(r.ok).toBe(false);
42+ if (!r.ok) expect(r.error).toMatch(/title/i);
43+});
44+
45+test("rejects slug with uppercase letters", () => {
46+ const r = validateEditForm({
47+ slug: "NotOK",
48+ type: "page",
49+ title: "T",
50+ html: "<p>x</p>",
51+ });
52+ // lowercased to "notok" by the trimmer — that should pass.
53+ expect(r.ok).toBe(true);
54+});
55+
56+test("accepts multi-segment slug with single-slash separators", () => {
57+ const r = validateEditForm({
58+ slug: "company/about",
59+ type: "page",
60+ title: "About",
61+ html: "<p>x</p>",
62+ });
63+ expect(r.ok).toBe(true);
64+ if (r.ok) expect(r.data.slug).toBe("company/about");
65+});
66+
67+test("accepts deeply nested multi-segment slug", () => {
68+ const r = validateEditForm({
69+ slug: "docs/spec/grammar",
70+ type: "page",
71+ title: "Grammar",
72+ html: "<p>x</p>",
73+ });
74+ expect(r.ok).toBe(true);
75+ if (r.ok) expect(r.data.slug).toBe("docs/spec/grammar");
76+});
77+
78+test("trims leading and trailing slashes from slug", () => {
79+ const r = validateEditForm({
80+ slug: "/foo/bar/",
81+ type: "page",
82+ title: "T",
83+ html: "<p>x</p>",
84+ });
85+ expect(r.ok).toBe(true);
86+ if (r.ok) expect(r.data.slug).toBe("foo/bar");
87+});
88+
89+test("rejects slug with consecutive slashes", () => {
90+ const r = validateEditForm({
91+ slug: "a//b",
92+ type: "page",
93+ title: "T",
94+ html: "<p>x</p>",
95+ });
96+ expect(r.ok).toBe(false);
97+ if (!r.ok) expect(r.error).toMatch(/slug/i);
98+});
99+
100+test("rejects empty segment after trim", () => {
101+ const r = validateEditForm({
102+ slug: "//",
103+ type: "page",
104+ title: "T",
105+ html: "<p>x</p>",
106+ });
107+ expect(r.ok).toBe(false);
108+ if (!r.ok) expect(r.error).toMatch(/slug/i);
109+});
110+
111+test("rejects slug containing whitespace", () => {
112+ const r = validateEditForm({
113+ slug: "two words",
114+ type: "page",
115+ title: "T",
116+ html: "<p>x</p>",
117+ });
118+ expect(r.ok).toBe(false);
119+ if (!r.ok) expect(r.error).toMatch(/slug/i);
120+});
121+
122+test("rejects unknown type", () => {
123+ const r = validateEditForm({
124+ slug: "ok",
125+ type: "snippet",
126+ title: "X",
127+ html: "<p>x</p>",
128+ });
129+ expect(r.ok).toBe(false);
130+ if (!r.ok) expect(r.error).toMatch(/type/i);
131+});
132+
133+test("rejects unknown status", () => {
134+ const r = validateEditForm({
135+ slug: "ok",
136+ type: "page",
137+ title: "X",
138+ html: "<p>x</p>",
139+ status: "deferred",
140+ });
141+ expect(r.ok).toBe(false);
142+ if (!r.ok) expect(r.error).toMatch(/status/i);
143+});
144+
145+test("defaults status to published when omitted", () => {
146+ const r = validateEditForm({
147+ slug: "ok",
148+ type: "page",
149+ title: "X",
150+ html: "<p>x</p>",
151+ });
152+ expect(r.ok).toBe(true);
153+ if (r.ok) expect(r.data.status).toBe("published");
154+});
155+
156+test("accepts draft status", () => {
157+ const r = validateEditForm({
158+ slug: "ok",
159+ type: "page",
160+ title: "X",
161+ html: "<p>x</p>",
162+ status: "draft",
163+ });
164+ expect(r.ok).toBe(true);
165+ if (r.ok) expect(r.data.status).toBe("draft");
166+});
167+
168+test("captures primary_tag when non-empty", () => {
169+ const r = validateEditForm({
170+ slug: "ok",
171+ type: "post",
172+ title: "P",
173+ html: "<p>x</p>",
174+ primary_tag: "concept",
175+ });
176+ expect(r.ok).toBe(true);
177+ if (r.ok) expect(r.data.primaryTag).toBe("concept");
178+});
179+
180+test("treats blank primary_tag as null", () => {
181+ const r = validateEditForm({
182+ slug: "ok",
183+ type: "post",
184+ title: "P",
185+ html: "<p>x</p>",
186+ primary_tag: " ",
187+ });
188+ expect(r.ok).toBe(true);
189+ if (r.ok) expect(r.data.primaryTag).toBeNull();
190+});
191+
192+test("rejects html body over the size cap", () => {
193+ // Build a 1 MB + 1 byte payload of single-byte chars.
194+ const big = "a".repeat(MAX_ADMIN_HTML_BYTES + 1);
195+ const r = validateEditForm({
196+ slug: "ok",
197+ type: "page",
198+ title: "X",
199+ html: big,
200+ });
201+ expect(r.ok).toBe(false);
202+ if (!r.ok) expect(r.error).toMatch(/limit/i);
203+});
204+
205+test("accepts empty html body (parser handles it as an empty doc)", () => {
206+ const r = validateEditForm({
207+ slug: "ok",
208+ type: "page",
209+ title: "X",
210+ html: "",
211+ });
212+ expect(r.ok).toBe(true);
213+});
added src/c31_admin_validation.ts +68 −0
@@ -0,0 +1,68 @@
1+// c31 — model: validation for the admin sxdoc edit form. Pure: no I/O.
2+// Sibling to c31_edit_validation (markdown-editor validation), but for
3+// the SxDocument-backed admin UI.
4+//
5+// Per Modeled.md: external input (HTTP form bodies) gets a parser in
6+// c31 before any logic touches it. Handler reads FormData, hands a
7+// Record<string, string> to validateEditForm, gets back a discriminated
8+// result the handler can react to.
9+
10+// Slugs may be single-segment ("about") or multi-segment ("company/about",
11+// "docs/spec/grammar"). Each segment is lowercase a-z/0-9/-/_. Leading or
12+// trailing slashes are trimmed by the caller before this regex runs, so
13+// the pattern itself only matches the canonical "seg(/seg)*" shape.
14+const SLUG_RE = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
15+
16+// 1 MiB cap on HTML body. The migration's biggest single document
17+// (sama-meets-git-cms.md) is ~12 KB rendered — 1 MiB is generous
18+// headroom for any realistic page, while still rejecting accidental
19+// 50 MB pastes that would block the SQLite WAL.
20+export const MAX_ADMIN_HTML_BYTES = 1024 * 1024;
21+
22+export interface ValidatedEditInput {
23+ slug: string;
24+ type: "page" | "post";
25+ title: string;
26+ html: string;
27+ status: "published" | "draft";
28+ primaryTag: string | null;
29+}
30+
31+export type AdminValidationResult =
32+ | { ok: true; data: ValidatedEditInput }
33+ | { ok: false; error: string };
34+
35+export const validateEditForm = (form: Record<string, string>): AdminValidationResult => {
36+ const slug = (form.slug ?? "").trim().toLowerCase().replace(/^\/+|\/+$/g, "");
37+ const type = form.type ?? "";
38+ const title = (form.title ?? "").trim();
39+ const html = form.html ?? "";
40+ const statusRaw = form.status ?? "published";
41+ const primaryTag = (form.primary_tag ?? "").trim() || null;
42+
43+ if (!title) return { ok: false, error: "title is required" };
44+ if (!SLUG_RE.test(slug)) {
45+ return {
46+ ok: false,
47+ error: "slug must be lowercase segments (letters, digits, dash, underscore) joined by single slashes — e.g. about, company/about, docs/spec/grammar",
48+ };
49+ }
50+ if (type !== "page" && type !== "post") {
51+ return { ok: false, error: "type must be page or post" };
52+ }
53+ if (statusRaw !== "published" && statusRaw !== "draft") {
54+ return { ok: false, error: "status must be published or draft" };
55+ }
56+ const bytes = new TextEncoder().encode(html).length;
57+ if (bytes > MAX_ADMIN_HTML_BYTES) {
58+ return {
59+ ok: false,
60+ error: `body exceeds the ${MAX_ADMIN_HTML_BYTES / 1024} KB limit (got ${Math.round(bytes / 1024)} KB)`,
61+ };
62+ }
63+
64+ return {
65+ ok: true,
66+ data: { slug, type, title, html, status: statusRaw, primaryTag },
67+ };
68+};
added src/c31_sxdoc.ts +142 −0
@@ -0,0 +1,142 @@
1+// c31 — types for sx-doc: tdd.md's typed rich-content format.
2+//
3+// Why a typed tree instead of HTML strings:
4+// • Editor saves a structured shape, not a string blob — block-level
5+// ops (move, transform, AI-edit) operate on typed nodes, not regex.
6+// • Round-trippable: htmlToSx(sxToHtml(doc)) ≈ doc (whitespace modulo).
7+// • Compact JSON: single-letter keys (`t`, `c`, `v`, `m`) keep the
8+// SQLite + git-sidecar payloads small.
9+//
10+// SAMA placement: c31 because this file is pure types/registry — no I/O,
11+// no logic. Parser/renderer live in c32_sxdoc_parse + c32_sxdoc_render
12+// where the deterministic transforms (and their sibling tests) belong.
13+//
14+// Scope-omission: podman's typed marketing blocks (hero, feature-card,
15+// feature-grid, stats-row, steps-grid, use-case-card, cta-band) are
16+// deliberately skipped — tdd.md content has no marketing-landing-page
17+// shape; skipping saves ~600 LOC across server + client.
18+
19+export const SX_DOC_VERSION = 1;
20+
21+export interface SxDocument {
22+ v: typeof SX_DOC_VERSION;
23+ blocks: SxBlock[];
24+}
25+
26+export type SxBlock =
27+ | SxParagraph
28+ | SxHeading
29+ | SxList
30+ | SxListItem
31+ | SxQuote
32+ | SxCodeBlock
33+ | SxImage
34+ | SxDivider
35+ | SxHtml
36+ | SxShortcode;
37+
38+export interface SxParagraph {
39+ t: "p";
40+ c: SxInline[];
41+}
42+
43+export interface SxHeading {
44+ t: "h";
45+ level: 1 | 2 | 3 | 4 | 5 | 6;
46+ c: SxInline[];
47+}
48+
49+export interface SxList {
50+ t: "ul" | "ol";
51+ // Each item is an array of blocks so a list item can hold paragraphs,
52+ // nested lists, etc.
53+ items: SxBlock[][];
54+}
55+
56+// Separate type so renderers can special-case loose list-items. Lists
57+// store items as SxBlock[][] directly; SxListItem only appears when an
58+// isolated <li> reaches the parser without a parent list.
59+export interface SxListItem {
60+ t: "li";
61+ c: SxBlock[];
62+}
63+
64+export interface SxQuote {
65+ t: "quote";
66+ c: SxBlock[];
67+}
68+
69+export interface SxCodeBlock {
70+ t: "code";
71+ // Language hint — e.g. "ts", "py". May be empty.
72+ lang?: string;
73+ // Raw source code. Newlines preserved verbatim.
74+ src: string;
75+}
76+
77+export interface SxImage {
78+ t: "img";
79+ src: string;
80+ alt?: string;
81+ caption?: string;
82+ // Intrinsic dimensions if known — used for layout-shift prevention.
83+ w?: number;
84+ h?: number;
85+}
86+
87+export interface SxDivider {
88+ t: "hr";
89+}
90+
91+// Escape hatch for HTML we don't (yet) model — preserves the source
92+// verbatim so round-tripping is lossless. New element kinds should land
93+// as proper SxBlock variants over time, not as `html` blobs.
94+export interface SxHtml {
95+ t: "html";
96+ src: string;
97+}
98+
99+// `[[sx:name arg=value ...]]` shortcode lifted out of source. We store
100+// the name + args structurally so renderers and queries don't need to
101+// understand the wire syntax.
102+export interface SxShortcode {
103+ t: "shortcode";
104+ name: string;
105+ args: Record<string, string>;
106+}
107+
108+// ─── inline ──────────────────────────────────────────────────────────────
109+
110+export type SxInline = SxText | SxLink;
111+
112+// Text run with optional marks. Marks are single-character flags:
113+// b=bold i=italic u=underline s=strikethrough c=inline-code
114+// Storage order doesn't matter; renderers nest them deterministically
115+// (see MARK_ORDER in c32_sxdoc_render).
116+export interface SxText {
117+ t: "text";
118+ v: string;
119+ m?: SxMark[];
120+}
121+
122+export type SxMark = "b" | "i" | "u" | "s" | "c";
123+
124+export interface SxLink {
125+ t: "a";
126+ href: string;
127+ c: SxInline[];
128+}
129+
130+// ─── helpers ─────────────────────────────────────────────────────────────
131+
132+// Type guard — useful at renderer and storage boundaries.
133+export const isBlock = (node: unknown): node is SxBlock => {
134+ if (!node || typeof node !== "object") return false;
135+ return "t" in node && typeof (node as { t: unknown }).t === "string";
136+};
137+
138+// Sentinel for new posts that haven't been parsed yet.
139+export const emptyDocument = (): SxDocument => ({
140+ v: SX_DOC_VERSION,
141+ blocks: [],
142+});
added src/c31_sxdoc_parse.test.ts +234 −0
@@ -0,0 +1,234 @@
1+import { test, expect } from "bun:test";
2+import { htmlToSx } from "./c31_sxdoc_parse.ts";
3+import { SX_DOC_VERSION } from "./c31_sxdoc.ts";
4+
5+test("returns an empty document for empty input", () => {
6+ const doc = htmlToSx("");
7+ expect(doc.v).toBe(SX_DOC_VERSION);
8+ expect(doc.blocks).toEqual([]);
9+});
10+
11+test("parses a simple paragraph", () => {
12+ const doc = htmlToSx("<p>Hello world</p>");
13+ expect(doc.blocks).toHaveLength(1);
14+ expect(doc.blocks[0]).toEqual({
15+ t: "p",
16+ c: [{ t: "text", v: "Hello world" }],
17+ });
18+});
19+
20+test("parses headings with correct level for h1-h6", () => {
21+ for (const level of [1, 2, 3, 4, 5, 6] as const) {
22+ const doc = htmlToSx(`<h${level}>Title ${level}</h${level}>`);
23+ expect(doc.blocks).toHaveLength(1);
24+ expect(doc.blocks[0]).toEqual({
25+ t: "h", level,
26+ c: [{ t: "text", v: `Title ${level}` }],
27+ });
28+ }
29+});
30+
31+test("parses unordered list with items wrapped as paragraphs", () => {
32+ const doc = htmlToSx("<ul><li>one</li><li>two</li></ul>");
33+ expect(doc.blocks).toHaveLength(1);
34+ expect(doc.blocks[0]).toEqual({
35+ t: "ul",
36+ items: [
37+ [{ t: "p", c: [{ t: "text", v: "one" }] }],
38+ [{ t: "p", c: [{ t: "text", v: "two" }] }],
39+ ],
40+ });
41+});
42+
43+test("parses ordered list", () => {
44+ const doc = htmlToSx("<ol><li>first</li></ol>");
45+ const block = doc.blocks[0];
46+ expect(block.t).toBe("ol");
47+ expect((block as { items: unknown }).items).toEqual([
48+ [{ t: "p", c: [{ t: "text", v: "first" }] }],
49+ ]);
50+});
51+
52+test("parses nested lists inside a list item", () => {
53+ const doc = htmlToSx("<ul><li>outer<ul><li>inner</li></ul></li></ul>");
54+ const outer = doc.blocks[0] as { t: "ul"; items: unknown[][] };
55+ expect(outer.t).toBe("ul");
56+ expect(outer.items[0]).toHaveLength(2);
57+ expect(outer.items[0][0]).toEqual({ t: "p", c: [{ t: "text", v: "outer" }] });
58+ expect(outer.items[0][1]).toEqual({
59+ t: "ul",
60+ items: [[{ t: "p", c: [{ t: "text", v: "inner" }] }]],
61+ });
62+});
63+
64+test("parses blockquote with paragraph inside", () => {
65+ const doc = htmlToSx("<blockquote><p>quoted</p></blockquote>");
66+ expect(doc.blocks).toEqual([{
67+ t: "quote",
68+ c: [{ t: "p", c: [{ t: "text", v: "quoted" }] }],
69+ }]);
70+});
71+
72+test("parses blockquote with loose text wraps it in a paragraph", () => {
73+ const doc = htmlToSx("<blockquote>loose</blockquote>");
74+ expect(doc.blocks[0]).toEqual({
75+ t: "quote",
76+ c: [{ t: "p", c: [{ t: "text", v: "loose" }] }],
77+ });
78+});
79+
80+test("parses pre>code with language hint", () => {
81+ const doc = htmlToSx(`<pre><code class="language-ts">const x = 1;</code></pre>`);
82+ expect(doc.blocks[0]).toEqual({
83+ t: "code", lang: "ts", src: "const x = 1;",
84+ });
85+});
86+
87+test("parses pre without inner code element", () => {
88+ const doc = htmlToSx("<pre>raw text</pre>");
89+ expect(doc.blocks[0]).toEqual({
90+ t: "code", lang: "", src: "raw text",
91+ });
92+});
93+
94+test("preserves encoded entities in code blocks", () => {
95+ const doc = htmlToSx(`<pre><code>&lt;p&gt;</code></pre>`);
96+ expect(doc.blocks[0]).toEqual({
97+ t: "code", lang: "", src: "<p>",
98+ });
99+});
100+
101+test("parses img with src and alt", () => {
102+ const doc = htmlToSx(`<img src="/x.png" alt="x icon">`);
103+ expect(doc.blocks[0]).toEqual({ t: "img", src: "/x.png", alt: "x icon" });
104+});
105+
106+test("parses img with width and height attributes", () => {
107+ const doc = htmlToSx(`<img src="/a.jpg" width="200" height="100">`);
108+ expect(doc.blocks[0]).toEqual({ t: "img", src: "/a.jpg", w: 200, h: 100 });
109+});
110+
111+test("skips img with empty src", () => {
112+ const doc = htmlToSx(`<img src="">`);
113+ expect(doc.blocks).toEqual([]);
114+});
115+
116+test("parses figure with figcaption", () => {
117+ const doc = htmlToSx(`<figure><img src="/y.png"><figcaption>nice y</figcaption></figure>`);
118+ expect(doc.blocks[0]).toEqual({
119+ t: "img", src: "/y.png", caption: "nice y",
120+ });
121+});
122+
123+test("parses hr", () => {
124+ const doc = htmlToSx("<hr>");
125+ expect(doc.blocks[0]).toEqual({ t: "hr" });
126+});
127+
128+test("parses inline bold and italic marks", () => {
129+ const doc = htmlToSx("<p><strong>bold</strong> and <em>ital</em></p>");
130+ expect(doc.blocks[0]).toEqual({
131+ t: "p",
132+ c: [
133+ { t: "text", v: "bold", m: ["b"] },
134+ { t: "text", v: " and " },
135+ { t: "text", v: "ital", m: ["i"] },
136+ ],
137+ });
138+});
139+
140+test("composes nested marks into a single mark array", () => {
141+ const doc = htmlToSx("<p><strong><em>both</em></strong></p>");
142+ expect(doc.blocks[0]).toEqual({
143+ t: "p",
144+ c: [{ t: "text", v: "both", m: ["b", "i"] }],
145+ });
146+});
147+
148+test("dedupes repeated marks across nested wrappers", () => {
149+ const doc = htmlToSx("<p><b><strong>x</strong></b></p>");
150+ const para = doc.blocks[0] as { c: Array<{ m?: string[] }> };
151+ expect(para.c[0].m).toEqual(["b"]);
152+});
153+
154+test("treats <br> as a newline text run carrying marks", () => {
155+ const doc = htmlToSx("<p>a<br>b</p>");
156+ expect(doc.blocks[0]).toEqual({
157+ t: "p",
158+ c: [
159+ { t: "text", v: "a" },
160+ { t: "text", v: "\n" },
161+ { t: "text", v: "b" },
162+ ],
163+ });
164+});
165+
166+test("parses anchor links with href", () => {
167+ const doc = htmlToSx(`<p><a href="/x">click</a></p>`);
168+ expect(doc.blocks[0]).toEqual({
169+ t: "p",
170+ c: [{ t: "a", href: "/x", c: [{ t: "text", v: "click" }] }],
171+ });
172+});
173+
174+test("strips unknown inline wrappers like span and keeps content", () => {
175+ const doc = htmlToSx(`<p>before <span class="x">middle</span> after</p>`);
176+ expect(doc.blocks[0]).toEqual({
177+ t: "p",
178+ c: [
179+ { t: "text", v: "before " },
180+ { t: "text", v: "middle" },
181+ { t: "text", v: " after" },
182+ ],
183+ });
184+});
185+
186+test("parses a standalone shortcode out of plain text", () => {
187+ const doc = htmlToSx("<p>[[sx:event-count]]</p>");
188+ expect(doc.blocks).toEqual([
189+ { t: "shortcode", name: "event-count", args: {} },
190+ ]);
191+});
192+
193+test("parses a shortcode with quoted and bare args", () => {
194+ const doc = htmlToSx(`<p>[[sx:list tag="blog" limit=5]]</p>`);
195+ expect(doc.blocks).toEqual([
196+ { t: "shortcode", name: "list", args: { tag: "blog", limit: "5" } },
197+ ]);
198+});
199+
200+test("lifts a shortcode out of a mixed paragraph", () => {
201+ const doc = htmlToSx("<p>before [[sx:x]] after</p>");
202+ expect(doc.blocks).toEqual([
203+ { t: "p", c: [{ t: "text", v: "before " }] },
204+ { t: "shortcode", name: "x", args: {} },
205+ { t: "p", c: [{ t: "text", v: " after" }] },
206+ ]);
207+});
208+
209+test("recurses into div/section/article containers", () => {
210+ const doc = htmlToSx("<div><p>one</p><section><p>two</p></section></div>");
211+ expect(doc.blocks).toHaveLength(2);
212+ expect(doc.blocks[0]).toEqual({ t: "p", c: [{ t: "text", v: "one" }] });
213+ expect(doc.blocks[1]).toEqual({ t: "p", c: [{ t: "text", v: "two" }] });
214+});
215+
216+test("falls back to html escape-hatch for unknown elements", () => {
217+ const doc = htmlToSx(`<table><tr><td>x</td></tr></table>`);
218+ expect(doc.blocks).toHaveLength(1);
219+ expect(doc.blocks[0].t).toBe("html");
220+ expect((doc.blocks[0] as { src: string }).src).toContain("<table>");
221+});
222+
223+test("decodes named entities in inline text", () => {
224+ const doc = htmlToSx("<p>A &amp; B</p>");
225+ expect(doc.blocks[0]).toEqual({
226+ t: "p", c: [{ t: "text", v: "A & B" }],
227+ });
228+});
229+
230+test("ignores empty paragraphs", () => {
231+ const doc = htmlToSx("<p></p><p>real</p>");
232+ expect(doc.blocks).toHaveLength(1);
233+ expect(doc.blocks[0]).toEqual({ t: "p", c: [{ t: "text", v: "real" }] });
234+});
added src/c31_sxdoc_parse.ts +327 −0
@@ -0,0 +1,327 @@
1+// c31 — HTML → SxDocument parser.
2+//
3+// SAMA placement: c31 because this is a parser for external input —
4+// Modeled.md is explicit: "every external input has a parser in a c31_*
5+// model — types and parse-functions colocated". HTML strings reach this
6+// file from the editor's save POST, from the markdown-import script, and
7+// from the AI-edit response — all "outside the process" → c31.
8+//
9+// Why a typed tree and not HTML strings: see c31_sxdoc.ts header.
10+//
11+// Why node-html-parser and not Bun's HTMLRewriter: we need a tree we can
12+// recurse over, not a streaming filter. The dep is pure-logic (no I/O,
13+// no fs, no spawn) so it doesn't push the file into c14 territory.
14+
15+import { parse, type HTMLElement, type Node, NodeType } from "node-html-parser";
16+import type { SxDocument, SxBlock, SxInline, SxMark } from "./c31_sxdoc.ts";
17+import { SX_DOC_VERSION } from "./c31_sxdoc.ts";
18+
19+const SHORTCODE_RE = /\[\[sx:([a-z][a-z0-9-]*)((?:\s+[a-z0-9_-]+=(?:"[^"]*"|[^\s"\]]+))*)\s*\]\]/g;
20+const SHORTCODE_ARG_RE = /([a-z0-9_-]+)=(?:"([^"]*)"|([^\s"\]]+))/g;
21+
22+const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
23+
24+// Block-level tags — used by parseListItem to know where to stop
25+// collecting inlines and recurse instead. Keep in sync with the
26+// pushBlocksFromNode dispatcher above.
27+const BLOCK_TAGS = new Set([
28+ "p", "h1", "h2", "h3", "h4", "h5", "h6",
29+ "ul", "ol", "blockquote", "pre",
30+ "img", "figure", "hr",
31+ "div", "section", "article", "table",
32+]);
33+
34+const MARK_FOR_TAG: Record<string, SxMark> = {
35+ b: "b", strong: "b",
36+ i: "i", em: "i",
37+ u: "u",
38+ s: "s", strike: "s", del: "s",
39+ code: "c",
40+};
41+
42+export const htmlToSx = (html: string): SxDocument => {
43+ // Wrap in <root> so we always have a single parent to walk childNodes
44+ // of, regardless of whether the input has its own wrapper element.
45+ const root = parse(`<root>${html}</root>`, {
46+ blockTextElements: { script: false, style: false },
47+ });
48+ const rootEl = root.firstChild as HTMLElement;
49+ const blocks: SxBlock[] = [];
50+ for (const node of rootEl.childNodes) {
51+ pushBlocksFromNode(node, blocks);
52+ }
53+ return { v: SX_DOC_VERSION, blocks };
54+};
55+
56+// ─── block-level dispatch ────────────────────────────────────────────────
57+
58+const pushBlocksFromNode = (node: Node, out: SxBlock[]): void => {
59+ if (node.nodeType === NodeType.TEXT_NODE) {
60+ const text = (node.text ?? "").trim();
61+ if (text) out.push(...textWithShortcodesToBlocks(text, []));
62+ return;
63+ }
64+ if (node.nodeType !== NodeType.ELEMENT_NODE) return;
65+
66+ const el = node as HTMLElement;
67+ const tag = el.tagName?.toLowerCase();
68+ if (!tag) return;
69+
70+ // Comments / processing-instructions surface as element nodes with a
71+ // tagName starting with "!" — drop them, they're not content.
72+ if (tag === "!" || tag === "comment") return;
73+
74+ if (tag === "p") {
75+ const inlines = parseInline(el.childNodes, []);
76+ if (inlines.length === 0) return;
77+ out.push(...splitShortcodesFromParagraph(inlines));
78+ return;
79+ }
80+
81+ if (HEADING_TAGS.has(tag)) {
82+ const level = parseInt(tag.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6;
83+ out.push({ t: "h", level, c: parseInline(el.childNodes, []) });
84+ return;
85+ }
86+
87+ if (tag === "ul" || tag === "ol") { out.push(parseList(el, tag)); return; }
88+ if (tag === "blockquote") { out.push(parseQuote(el)); return; }
89+ if (tag === "pre") { out.push(parseCodeBlock(el)); return; }
90+ if (tag === "img") {
91+ const img = parseImg(el);
92+ if (img) out.push(img);
93+ return;
94+ }
95+ if (tag === "figure") { out.push(parseFigure(el)); return; }
96+ if (tag === "hr") { out.push({ t: "hr" }); return; }
97+
98+ if (tag === "div" || tag === "section" || tag === "article") {
99+ for (const child of el.childNodes) pushBlocksFromNode(child, out);
100+ return;
101+ }
102+
103+ // Anything else → escape hatch so round-tripping stays lossless.
104+ out.push({ t: "html", src: el.outerHTML });
105+};
106+
107+// ─── per-block parsers ───────────────────────────────────────────────────
108+
109+const parseList = (el: HTMLElement, tag: "ul" | "ol"): SxBlock => {
110+ const items: SxBlock[][] = [];
111+ for (const child of el.childNodes) {
112+ if (child.nodeType !== NodeType.ELEMENT_NODE) continue;
113+ const childEl = child as HTMLElement;
114+ if (childEl.tagName?.toLowerCase() !== "li") continue;
115+ const itemBlocks = parseListItem(childEl);
116+ if (itemBlocks.length > 0) items.push(itemBlocks);
117+ }
118+ return { t: tag, items };
119+};
120+
121+// Walk an <li>'s children in source-order. Inline runs collect into
122+// paragraphs; block-level children (nested ul/ol/blockquote/pre/…)
123+// flush the current inline buffer and recurse as their own block.
124+// Without this split, parseInline would walk into nested <ul> and the
125+// inner text would leak into the outer paragraph.
126+const parseListItem = (li: HTMLElement): SxBlock[] => {
127+ const result: SxBlock[] = [];
128+ let inlineBuf: Node[] = [];
129+ const flushInlines = (): void => {
130+ if (inlineBuf.length === 0) return;
131+ const inlines = parseInline(inlineBuf, []);
132+ if (inlines.length > 0) result.push({ t: "p", c: inlines });
133+ inlineBuf = [];
134+ };
135+ for (const node of li.childNodes) {
136+ if (node.nodeType === NodeType.ELEMENT_NODE) {
137+ const t = (node as HTMLElement).tagName?.toLowerCase();
138+ if (t && BLOCK_TAGS.has(t)) {
139+ flushInlines();
140+ pushBlocksFromNode(node, result);
141+ continue;
142+ }
143+ }
144+ inlineBuf.push(node);
145+ }
146+ flushInlines();
147+ return result;
148+};
149+
150+const parseQuote = (el: HTMLElement): SxBlock => {
151+ const inner: SxBlock[] = [];
152+ for (const child of el.childNodes) pushBlocksFromNode(child, inner);
153+ if (inner.length === 0) {
154+ const inlines = parseInline(el.childNodes, []);
155+ if (inlines.length > 0) inner.push({ t: "p", c: inlines });
156+ }
157+ return { t: "quote", c: inner };
158+};
159+
160+const parseCodeBlock = (el: HTMLElement): SxBlock => {
161+ // Canonical shape: <pre><code class="language-X">…</code></pre>.
162+ // Loose <pre>text</pre> also supported.
163+ const codeChild = el.querySelector("code");
164+ const inner = codeChild ?? el;
165+ const lang = parseLangFromClass(inner.getAttribute("class") ?? "");
166+ return { t: "code", lang, src: decodeEntities(inner.innerHTML) };
167+};
168+
169+const parseImg = (el: HTMLElement): SxBlock | null => {
170+ const src = el.getAttribute("src") ?? "";
171+ if (!src) return null;
172+ const block: { t: "img"; src: string; alt?: string; w?: number; h?: number } = { t: "img", src };
173+ const alt = el.getAttribute("alt");
174+ if (alt) block.alt = alt;
175+ const w = numAttr(el, "width"); if (w !== undefined) block.w = w;
176+ const h = numAttr(el, "height"); if (h !== undefined) block.h = h;
177+ return block as SxBlock;
178+};
179+
180+const parseFigure = (el: HTMLElement): SxBlock => {
181+ const img = el.querySelector("img");
182+ const caption = el.querySelector("figcaption");
183+ if (img) {
184+ const src = img.getAttribute("src") ?? "";
185+ if (src) {
186+ const block: { t: "img"; src: string; alt?: string; caption?: string; w?: number; h?: number } = { t: "img", src };
187+ const alt = img.getAttribute("alt"); if (alt) block.alt = alt;
188+ if (caption) block.caption = caption.text;
189+ const w = numAttr(img, "width"); if (w !== undefined) block.w = w;
190+ const h = numAttr(img, "height"); if (h !== undefined) block.h = h;
191+ return block as SxBlock;
192+ }
193+ }
194+ return { t: "html", src: el.outerHTML };
195+};
196+
197+// ─── inline parsing ──────────────────────────────────────────────────────
198+
199+const parseInline = (nodes: Node[] | undefined, marks: SxMark[]): SxInline[] => {
200+ if (!nodes) return [];
201+ const out: SxInline[] = [];
202+ for (const node of nodes) {
203+ if (node.nodeType === NodeType.TEXT_NODE) {
204+ const v = decodeEntities(node.text ?? "");
205+ if (v.length > 0) {
206+ out.push({ t: "text", v, ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
207+ }
208+ continue;
209+ }
210+ if (node.nodeType !== NodeType.ELEMENT_NODE) continue;
211+ const el = node as HTMLElement;
212+ const tag = el.tagName?.toLowerCase();
213+ if (!tag) continue;
214+
215+ if (tag === "br") {
216+ out.push({ t: "text", v: "\n", ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
217+ continue;
218+ }
219+
220+ if (tag === "a") {
221+ const href = el.getAttribute("href") ?? "";
222+ out.push({ t: "a", href, c: parseInline(el.childNodes, marks) });
223+ continue;
224+ }
225+
226+ const mark = MARK_FOR_TAG[tag];
227+ if (mark) {
228+ out.push(...parseInline(el.childNodes, [...marks, mark]));
229+ continue;
230+ }
231+
232+ // <span>, <font>, etc. — strip wrapper, keep contents.
233+ out.push(...parseInline(el.childNodes, marks));
234+ }
235+ return out;
236+};
237+
238+const dedupeMarks = (marks: SxMark[]): SxMark[] => {
239+ const seen = new Set<SxMark>();
240+ const out: SxMark[] = [];
241+ for (const m of marks) if (!seen.has(m)) { seen.add(m); out.push(m); }
242+ return out;
243+};
244+
245+// ─── shortcode lifting ──────────────────────────────────────────────────
246+
247+// When a <p> contains [[sx:foo]] tokens mixed with text, split it into
248+// (paragraph)(shortcode)(paragraph) blocks so the document is queryable
249+// per-shortcode rather than per-paragraph-with-substring.
250+const splitShortcodesFromParagraph = (inlines: SxInline[]): SxBlock[] => {
251+ const out: SxBlock[] = [];
252+ let buf: SxInline[] = [];
253+ const flush = (): void => {
254+ if (buf.length > 0 && buf.some((i) => !(i.t === "text" && i.v.trim() === ""))) {
255+ out.push({ t: "p", c: buf });
256+ }
257+ buf = [];
258+ };
259+ for (const i of inlines) {
260+ if (i.t !== "text" || !SHORTCODE_RE.test(i.v)) {
261+ buf.push(i);
262+ continue;
263+ }
264+ SHORTCODE_RE.lastIndex = 0;
265+ const blocks = textWithShortcodesToBlocks(i.v, i.m ?? []);
266+ for (const b of blocks) {
267+ if (b.t === "shortcode") {
268+ flush();
269+ out.push(b);
270+ } else if (b.t === "p") {
271+ for (const inner of b.c) buf.push(inner);
272+ }
273+ }
274+ }
275+ flush();
276+ return out;
277+};
278+
279+const textWithShortcodesToBlocks = (text: string, marks: SxMark[]): SxBlock[] => {
280+ const out: SxBlock[] = [];
281+ let last = 0;
282+ SHORTCODE_RE.lastIndex = 0;
283+ for (const m of text.matchAll(SHORTCODE_RE)) {
284+ const idx = m.index ?? 0;
285+ if (idx > last) {
286+ const before = text.slice(last, idx);
287+ if (before.trim() !== "") {
288+ out.push({ t: "p", c: [{ t: "text", v: before, ...(marks.length ? { m: marks } : {}) }] });
289+ }
290+ }
291+ const name = m[1]!;
292+ const args: Record<string, string> = {};
293+ for (const a of (m[2] ?? "").matchAll(SHORTCODE_ARG_RE)) {
294+ args[a[1]!] = a[2] ?? a[3] ?? "";
295+ }
296+ out.push({ t: "shortcode", name, args });
297+ last = idx + m[0].length;
298+ }
299+ const tail = text.slice(last);
300+ if (tail.trim() !== "") {
301+ out.push({ t: "p", c: [{ t: "text", v: tail, ...(marks.length ? { m: marks } : {}) }] });
302+ }
303+ return out;
304+};
305+
306+// ─── small helpers ───────────────────────────────────────────────────────
307+
308+const parseLangFromClass = (cls: string): string => {
309+ const m = cls.match(/(?:^|\s)language-([\w-]+)/);
310+ return m?.[1] ?? "";
311+};
312+
313+const numAttr = (el: HTMLElement, name: string): number | undefined => {
314+ const v = el.getAttribute(name);
315+ if (!v) return undefined;
316+ const n = parseInt(v, 10);
317+ return Number.isFinite(n) ? n : undefined;
318+};
319+
320+const decodeEntities = (s: string): string =>
321+ s
322+ .replace(/&amp;/g, "&")
323+ .replace(/&lt;/g, "<")
324+ .replace(/&gt;/g, ">")
325+ .replace(/&quot;/g, '"')
326+ .replace(/&#39;/g, "'")
327+ .replace(/&nbsp;/g, " ");
added src/c51_render_admin.ts +163 −0
@@ -0,0 +1,163 @@
1+// c51 — UI: shells for the admin sxdoc editor.
2+//
3+// Three views: list (GET /admin), edit form (GET /admin/edit/...), and
4+// auth walls for non-admin viewers. Body builders return HTML strings;
5+// the c21 handler wraps them in htmlResponse.
6+//
7+// Fase 2a: raw-HTML textarea editor. Fase 2b adds the block editor on
8+// top — the textarea stays as the underlying form field, and the
9+// block-editor JS will hydrate it into a typed UI. So the form shape
10+// here is forward-compatible with the block editor that lands next.
11+
12+import { escape, renderPage } from "./c51_render_layout.ts";
13+import type { SxDocumentSummary } from "./c13_database.ts";
14+import type { SxDocument } from "./c31_sxdoc.ts";
15+import { sxToHtml } from "./c51_render_sxdoc.ts";
16+
17+export const renderAdminList = async (documents: SxDocumentSummary[]): Promise<string> => {
18+ const pages = documents.filter((d) => d.type === "page");
19+ const posts = documents.filter((d) => d.type === "post");
20+ const body = `# admin
21+
22+[+ new document](/admin/new)
23+
24+## pages (${pages.length})
25+
26+${pages.length === 0 ? "_no pages yet — migrate or create one._" : adminTable(pages)}
27+
28+## posts (${posts.length})
29+
30+${posts.length === 0 ? "_no posts yet — migrate or create one._" : adminTable(posts)}
31+
32+[← back to home](/)
33+`;
34+ return renderPage({
35+ title: "admin — tdd.md",
36+ bodyMarkdown: body,
37+ noindex: true,
38+ });
39+};
40+
41+const adminTable = (rows: SxDocumentSummary[]): string => {
42+ const lines = rows.map((r) =>
43+ `| [${escape(r.title)}](/admin/edit/${r.type}/${r.slug}) | \`${escape(r.slug)}\` | ${r.status} | ${r.primaryTag ?? "—"} |`,
44+ );
45+ return `| title | slug | status | tag |
46+|---|---|---|---|
47+${lines.join("\n")}`;
48+};
49+
50+export interface AdminEditViewModel {
51+ mode: "new" | "edit";
52+ title: string;
53+ slug: string;
54+ type: "page" | "post";
55+ // SxDocument is the canonical input — server projects it to HTML for
56+ // the textarea and embeds the JSON for the client editor's hydration.
57+ doc: SxDocument;
58+ status: "published" | "draft";
59+ primaryTag: string | null;
60+ error?: string;
61+}
62+
63+// Embed JSON safely inside <script type="application/json">: replace
64+// any "<" so a stray "</script>" in user content can't break out of the
65+// script tag. JSON.parse handles "<" identically to "<".
66+const safeJsonForScript = (value: unknown): string =>
67+ JSON.stringify(value).replace(/</g, "\\u003c");
68+
69+export const renderAdminEdit = async (vm: AdminEditViewModel): Promise<string> => {
70+ const action = vm.mode === "new" ? "/admin/new" : `/admin/edit/${vm.type}/${vm.slug}`;
71+ const heading = vm.mode === "new" ? "new document" : "edit document";
72+ const submitLabel = vm.mode === "new" ? "Create" : "Save";
73+ const html = sxToHtml(vm.doc);
74+ const docJson = safeJsonForScript(vm.doc);
75+
76+ const errorBlock = vm.error
77+ ? `<p class="admin-error">${escape(vm.error)}</p>`
78+ : "";
79+
80+ // Delete button uses a separate form to avoid posting the entire edit
81+ // payload to the delete endpoint. confirm() catches accidental clicks.
82+ const deleteForm = vm.mode === "edit"
83+ ? `<form method="POST" action="/admin/delete/${vm.type}/${vm.slug}" onsubmit="return confirm('Delete \\'${escape(vm.title)}\\'?');" style="display:inline">
84+ <button type="submit" class="admin-delete">Delete</button>
85+ </form>`
86+ : "";
87+
88+ const form = `<form method="POST" action="${escape(action)}" class="admin-form">
89+ ${errorBlock}
90+ <label class="admin-field">
91+ <span>Title</span>
92+ <input type="text" name="title" value="${escape(vm.title)}" required>
93+ </label>
94+ <label class="admin-field">
95+ <span>Slug</span>
96+ <input type="text" name="slug" value="${escape(vm.slug)}" placeholder="about, company/about, docs/spec/grammar" pattern="[a-z0-9_\-]+(?:/[a-z0-9_\-]+)*" required>
97+ </label>
98+ <div class="admin-row">
99+ <label class="admin-field">
100+ <span>Type</span>
101+ <select name="type">
102+ <option value="page"${vm.type === "page" ? " selected" : ""}>page</option>
103+ <option value="post"${vm.type === "post" ? " selected" : ""}>post</option>
104+ </select>
105+ </label>
106+ <label class="admin-field">
107+ <span>Status</span>
108+ <select name="status">
109+ <option value="published"${vm.status === "published" ? " selected" : ""}>published</option>
110+ <option value="draft"${vm.status === "draft" ? " selected" : ""}>draft</option>
111+ </select>
112+ </label>
113+ <label class="admin-field">
114+ <span>Primary tag</span>
115+ <input type="text" name="primary_tag" value="${escape(vm.primaryTag ?? "")}" placeholder="optional">
116+ </label>
117+ </div>
118+ <label class="admin-field">
119+ <span>HTML body</span>
120+ <textarea name="html" rows="24" required>${escape(html)}</textarea>
121+ </label>
122+ <div class="admin-actions">
123+ <button type="submit">${submitLabel}</button>
124+ <a href="/admin" class="admin-cancel">Cancel</a>
125+ </div>
126+</form>
127+${deleteForm}
128+<script type="application/json" id="sxdoc-initial">${docJson}</script>
129+<script type="module" src="/admin/assets/blockeditor.js"></script>`;
130+
131+ const title = vm.mode === "new"
132+ ? "new — admin — tdd.md"
133+ : `${vm.title} — admin — tdd.md`;
134+ return renderPage({
135+ title,
136+ bodyHtml: `<h1>${heading}</h1>${form}`,
137+ noindex: true,
138+ });
139+};
140+
141+export const renderAdminLoginWall = async (): Promise<string> =>
142+ renderPage({
143+ title: "admin — sign in — tdd.md",
144+ bodyMarkdown: `# admin
145+
146+> Sign in with GitHub to access the admin UI.
147+
148+[ sign in with github → ](/auth/github/start)
149+
150+[← back to home](/)`,
151+ noindex: true,
152+ });
153+
154+export const renderAdminNonAdminWall = async (viewer: string): Promise<string> =>
155+ renderPage({
156+ title: "admin — not authorized — tdd.md",
157+ bodyMarkdown: `# not authorized
158+
159+> You are signed in as \`${escape(viewer)}\`, but the admin UI is reserved for the site admin.
160+
161+[← back to home](/) · [your agent](/agents/${escape(viewer)})`,
162+ noindex: true,
163+ });
modified src/c51_render_commit.ts +1 −0
@@ -123,5 +123,6 @@ export const renderCommitView = async (params: {
123123 description: `Commit ${shortSha(detail.sha)} on ${owner}/${repo}: ${subject}`,
124124 noindex: true,
125125 bodyClass: "commit-body-page",
126+ hideNav: true,
126127 });
127128 };
modified src/c51_render_docs_layout.ts +6 −32
@@ -1,16 +1,13 @@
11 // c51 (docs-layout) — UI: GitBook-style chrome around the existing
2-// renderPage. Wraps content with a left sidebar (sections from
3-// SITE_NAV), a right "on this page" anchor rail (h2/h3 from the
4-// rendered body), an edit-on-GitHub link at the top of content, and
5-// a prev/next navigator at the bottom. Per SAMA: imports c31 (data),
6-// c32 (logic), and c51_render_layout (chrome). No I/O of its own.
2+// renderPage. Wraps content with a right "on this page" anchor rail
3+// (h2/h3 from the rendered body), an edit-on-GitHub link at the top
4+// of content, and a prev/next navigator at the bottom. Per SAMA:
5+// imports c31 (data), c32 (logic), and c51_render_layout (chrome).
6+// No I/O of its own.
77
88 import { marked } from "marked";
99 import {
10- SITE_NAV,
1110 resolveDocsLocation,
12- type DocsNavLink,
13- type DocsNavSection,
1411 type ResolvedDocsLocation,
1512 } from "./c31_docs_nav.ts";
1613 import { extractAnchors, type Anchor } from "./c32_anchor_extract.ts";
@@ -22,7 +19,7 @@ import {
2219
2320 export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> {
2421 // The route path the user is on, e.g. "/sama/sorted". Used to
25- // highlight the active sidebar entry and compute prev/next.
22+ // compute prev/next.
2623 pathForDocs: string;
2724 // Optional override of which file the "edit on GitHub" link
2825 // targets, when the body isn't a content/<section>/<slug>.md.
@@ -30,27 +27,6 @@ export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> {
3027 editPathOverride?: string | null;
3128 }
3229
33-const sidebarLink = (link: DocsNavLink, current: string): string => {
34- const cls = link.href === current ? "docs-side-link docs-side-link-active" : "docs-side-link";
35- return `<li><a class="${cls}" href="${link.href}">${escape(link.label)}</a></li>`;
36-};
37-
38-const renderSidebar = (currentPath: string): string => {
39- const sections = SITE_NAV.map((section: DocsNavSection) => {
40- const items = section.links.map((l) => sidebarLink(l, currentPath)).join("");
41- const sectionCls = section.links.some((l) => l.href === currentPath)
42- ? "docs-side-section docs-side-section-active"
43- : "docs-side-section";
44- return `<div class="${sectionCls}">
45- <p class="docs-side-title"><a href="${section.rootHref}">${escape(section.title)}</a></p>
46- <ul class="docs-side-list">${items}</ul>
47-</div>`;
48- }).join("\n");
49- return `<aside class="docs-sidebar" aria-label="documentation navigation">
50-${sections}
51-</aside>`;
52-};
53-
5430 const renderAnchorRail = (anchors: Anchor[]): string => {
5531 if (anchors.length === 0) return "";
5632 const items = anchors
@@ -121,13 +97,11 @@ export const renderDocsPage = async (opts: DocsPageOptions): Promise<string> =>
12197 const loc = resolveDocsLocation(opts.pathForDocs);
12298 const editPath = opts.editPathOverride !== undefined ? opts.editPathOverride : loc?.current.editPath ?? null;
12399
124- const sidebar = renderSidebar(opts.pathForDocs);
125100 const rail = renderAnchorRail(anchors);
126101 const editLink = renderEditLink(editPath);
127102 const prevNext = renderPrevNext(loc);
128103
129104 const composed = `<div class="docs-layout">
130-${sidebar}
131105 <article class="docs-content">
132106 ${editLink}
133107 ${enriched}
modified src/c51_render_layout.ts +7 −2
@@ -27,9 +27,14 @@ export interface PageOptions {
2727 noindex?: boolean;
2828 jsonLd?: Record<string, unknown>;
2929 bodyClass?: string;
30+ // Skip the top nav bar (tdd.md · games · guides · sama · blog · agents
31+ // · leaderboard). Used by the /GIT views which have their own
32+ // breadcrumb chrome and don't need the site-wide nav competing for
33+ // space at the top of the page.
34+ hideNav?: boolean;
3035 }
3136
32-const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts.";
37+const SITE_DESCRIPTION = "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic. Four pillars, one CI verifier.";
3338
3439 export const escape = (s: string): string =>
3540 s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -75,7 +80,7 @@ ${robots}<link rel="canonical" href="${escape(ogPath)}">
7580 ${jsonLd}<style>${css}</style>
7681 </head>
7782 <body${bodyClassAttr}>
78-${nav(opts.active)}
83+${opts.hideNav ? "" : nav(opts.active)}
7984 <main class="md">
8085 ${body}
8186 </main>
added src/c51_render_repo.ts +154 −0
@@ -0,0 +1,154 @@
1+// c51 — UI: tree listing + blob viewer for the local bare repo.
2+// Visited at /GIT/:owner/:repo/tree/:ref/<path> and /blob/:ref/<path>.
3+// Renders through tdd.md's chrome (renderPage with bodyHtml). Markdown
4+// blobs get parsed via marked; everything else is rendered as
5+// preformatted source.
6+
7+import { marked } from "marked";
8+import { renderPage, escape } from "./c51_render_layout.ts";
9+import type { TreeEntry } from "./c14_git.ts";
10+
11+const shortSha = (sha: string): string => sha.slice(0, 7);
12+
13+// Build a breadcrumb: "owner/repo · main · content/blog" with each
14+// segment a clickable link to /GIT/.../tree/<ref>/<segments-so-far>.
15+const renderBreadcrumb = (params: {
16+ owner: string;
17+ repo: string;
18+ ref: string;
19+ path: string;
20+ asBlob?: boolean;
21+}): string => {
22+ const { owner, repo, ref, path, asBlob } = params;
23+ const repoLink = `<a href="/GIT/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}"><strong>${escape(owner)}/${escape(repo)}</strong></a>`;
24+ const refLink = `<a class="commit-meta-pill" href="/GIT/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}"><code>${escape(ref)}</code></a>`;
25+ if (path === "") return `<p class="commit-breadcrumb">${repoLink} · ${refLink}</p>`;
26+
27+ const segments = path.split("/");
28+ const lastIdx = segments.length - 1;
29+ const links = segments
30+ .map((seg, i) => {
31+ const so_far = segments.slice(0, i + 1).join("/");
32+ // For blob view, the last segment is the file itself — no link.
33+ // For tree view, every segment links to the tree at that depth.
34+ const isLastFile = asBlob && i === lastIdx;
35+ if (isLastFile) return `<code>${escape(seg)}</code>`;
36+ return `<a href="/GIT/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}/${escape(so_far)}"><code>${escape(seg)}</code></a>`;
37+ })
38+ .join(" / ");
39+ return `<p class="commit-breadcrumb">${repoLink} · ${refLink} · ${links}</p>`;
40+};
41+
42+// Sort: trees first, then blobs, alphabetically within each group.
43+// Mirrors what GitHub / Forgejo's tree views do.
44+const sortEntries = (entries: TreeEntry[]): TreeEntry[] => {
45+ return [...entries].sort((a, b) => {
46+ if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
47+ return a.name.localeCompare(b.name);
48+ });
49+};
50+
51+const renderTreeRow = (params: {
52+ entry: TreeEntry;
53+ owner: string;
54+ repo: string;
55+ ref: string;
56+ parentPath: string;
57+}): string => {
58+ const { entry, owner, repo, ref, parentPath } = params;
59+ const childPath = parentPath === "" ? entry.name : `${parentPath}/${entry.name}`;
60+ const icon =
61+ entry.type === "tree" ? "📁" :
62+ entry.type === "commit" ? "🔗" : // submodule
63+ "📄";
64+ const kind = entry.type === "tree" ? "tree" : "blob";
65+ const href = `/GIT/${escape(owner)}/${escape(repo)}/${kind}/${escape(ref)}/${escape(childPath)}`;
66+ return `<tr class="repo-tree-row repo-tree-row-${entry.type}">
67+ <td class="repo-tree-icon">${icon}</td>
68+ <td class="repo-tree-name"><a href="${href}">${escape(entry.name)}</a></td>
69+ <td class="repo-tree-sha"><code>${escape(shortSha(entry.sha))}</code></td>
70+</tr>`;
71+};
72+
73+export const renderRepoTree = async (params: {
74+ owner: string;
75+ repo: string;
76+ ref: string;
77+ path: string;
78+ entries: TreeEntry[];
79+}): Promise<string> => {
80+ const { owner, repo, ref, path, entries } = params;
81+ const sorted = sortEntries(entries);
82+ const upRow = path === ""
83+ ? ""
84+ : (() => {
85+ const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
86+ const upHref = parentPath === ""
87+ ? `/GIT/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}`
88+ : `/GIT/${escape(owner)}/${escape(repo)}/tree/${escape(ref)}/${escape(parentPath)}`;
89+ return `<tr class="repo-tree-row repo-tree-row-up"><td class="repo-tree-icon">⬆</td><td class="repo-tree-name"><a href="${upHref}">..</a></td><td></td></tr>`;
90+ })();
91+ const rows = entries.length === 0
92+ ? `<tr><td colspan="3" class="commit-empty">empty tree</td></tr>`
93+ : upRow + sorted.map((entry) => renderTreeRow({ entry, owner, repo, ref, parentPath: path })).join("");
94+
95+ const titlePath = path === "" ? "" : ` · ${path}`;
96+ const inner = `<main class="md commit-view">
97+ ${renderBreadcrumb({ owner, repo, ref, path })}
98+ <h1 class="commit-subject">${escape(path === "" ? `${owner}/${repo}` : path)}</h1>
99+ <p class="commit-files-summary">${entries.length} entr${entries.length === 1 ? "y" : "ies"} at <code>${escape(ref)}</code></p>
100+ <table class="repo-tree-table"><tbody>${rows}</tbody></table>
101+</main>`;
102+
103+ return renderPage({
104+ title: `${owner}/${repo}${titlePath} — tdd.md`,
105+ bodyHtml: inner,
106+ description: `Repository tree at ${ref}${path ? "/" + path : ""} on tdd.md.`,
107+ noindex: true,
108+ bodyClass: "commit-body-page",
109+ hideNav: true,
110+ });
111+};
112+
113+const isMarkdown = (path: string): boolean => path.endsWith(".md");
114+
115+export const renderRepoBlob = async (params: {
116+ owner: string;
117+ repo: string;
118+ ref: string;
119+ path: string;
120+ content: string;
121+}): Promise<string> => {
122+ const { owner, repo, ref, path, content } = params;
123+ const filename = path.split("/").pop() ?? path;
124+
125+ // Markdown gets rendered through marked; code files get a <pre><code>
126+ // block; everything else also <pre> (we don't try to syntax-highlight,
127+ // just render readable monospace).
128+ const bodyHtml = isMarkdown(path)
129+ ? `<div class="repo-blob-rendered md">${await marked.parse(content, { gfm: true, breaks: false })}</div>`
130+ : `<pre class="repo-blob-source"><code>${escape(content)}</code></pre>`;
131+
132+ const inner = `<main class="md commit-view">
133+ ${renderBreadcrumb({ owner, repo, ref, path, asBlob: true })}
134+ <header class="repo-blob-header">
135+ <code class="repo-blob-path">${escape(filename)}</code>
136+ <span class="repo-blob-meta">${content.split("\n").length} lines · ${content.length} bytes</span>
137+ <span class="repo-blob-actions">
138+ <a href="/GIT/${escape(owner)}/${escape(repo)}/raw/${escape(ref)}/${escape(path)}">raw</a>
139+ ${isMarkdown(path) ? `· <a href="/GIT/${escape(owner)}/${escape(repo)}/blob/${escape(ref)}/${escape(path)}?source=1">source</a>` : ""}
140+ </span>
141+ </header>
142+ ${bodyHtml}
143+</main>`;
144+
145+ return renderPage({
146+ title: `${path} · ${owner}/${repo} — tdd.md`,
147+ bodyHtml: inner,
148+ description: `${path} at ${ref} on tdd.md.`,
149+ noindex: true,
150+ bodyClass: "commit-body-page",
151+ hideNav: true,
152+ });
153+};
154+
added src/c51_render_sxdoc.test.ts +240 −0
@@ -0,0 +1,240 @@
1+import { test, expect } from "bun:test";
2+import { sxToHtml } from "./c51_render_sxdoc.ts";
3+import { htmlToSx } from "./c31_sxdoc_parse.ts";
4+import { SX_DOC_VERSION, emptyDocument, type SxDocument } from "./c31_sxdoc.ts";
5+
6+test("renders the empty document as empty string", () => {
7+ expect(sxToHtml(emptyDocument())).toBe("");
8+});
9+
10+test("renders a paragraph", () => {
11+ const out = sxToHtml({
12+ v: SX_DOC_VERSION,
13+ blocks: [{ t: "p", c: [{ t: "text", v: "hello" }] }],
14+ });
15+ expect(out).toBe("<p>hello</p>");
16+});
17+
18+test("renders headings at the correct level", () => {
19+ for (const level of [1, 2, 3, 4, 5, 6] as const) {
20+ const out = sxToHtml({
21+ v: SX_DOC_VERSION,
22+ blocks: [{ t: "h", level, c: [{ t: "text", v: "X" }] }],
23+ });
24+ expect(out).toBe(`<h${level}>X</h${level}>`);
25+ }
26+});
27+
28+test("renders ul and ol with li wrappers", () => {
29+ const ul = sxToHtml({
30+ v: SX_DOC_VERSION,
31+ blocks: [{
32+ t: "ul",
33+ items: [
34+ [{ t: "p", c: [{ t: "text", v: "one" }] }],
35+ [{ t: "p", c: [{ t: "text", v: "two" }] }],
36+ ],
37+ }],
38+ });
39+ expect(ul).toBe("<ul><li><p>one</p></li><li><p>two</p></li></ul>");
40+ const ol = sxToHtml({
41+ v: SX_DOC_VERSION,
42+ blocks: [{ t: "ol", items: [[{ t: "p", c: [{ t: "text", v: "a" }] }]] }],
43+ });
44+ expect(ol).toBe("<ol><li><p>a</p></li></ol>");
45+});
46+
47+test("renders blockquote with inner blocks", () => {
48+ const out = sxToHtml({
49+ v: SX_DOC_VERSION,
50+ blocks: [{
51+ t: "quote",
52+ c: [{ t: "p", c: [{ t: "text", v: "quoted" }] }],
53+ }],
54+ });
55+ expect(out).toBe("<blockquote><p>quoted</p></blockquote>");
56+});
57+
58+test("renders code block with language class", () => {
59+ const out = sxToHtml({
60+ v: SX_DOC_VERSION,
61+ blocks: [{ t: "code", lang: "ts", src: "const x = 1;" }],
62+ });
63+ expect(out).toBe(`<pre><code class="language-ts">const x = 1;</code></pre>`);
64+});
65+
66+test("renders code block without lang as plain pre>code", () => {
67+ const out = sxToHtml({
68+ v: SX_DOC_VERSION,
69+ blocks: [{ t: "code", src: "raw" }],
70+ });
71+ expect(out).toBe(`<pre><code>raw</code></pre>`);
72+});
73+
74+test("escapes html entities inside code source", () => {
75+ const out = sxToHtml({
76+ v: SX_DOC_VERSION,
77+ blocks: [{ t: "code", src: "<p>" }],
78+ });
79+ expect(out).toContain("&lt;p&gt;");
80+});
81+
82+test("renders img with src and alt", () => {
83+ const out = sxToHtml({
84+ v: SX_DOC_VERSION,
85+ blocks: [{ t: "img", src: "/x.png", alt: "x" }],
86+ });
87+ expect(out).toBe(`<img src="/x.png" alt="x">`);
88+});
89+
90+test("wraps captioned img in a figure", () => {
91+ const out = sxToHtml({
92+ v: SX_DOC_VERSION,
93+ blocks: [{ t: "img", src: "/y.png", caption: "nice" }],
94+ });
95+ expect(out).toBe(`<figure><img src="/y.png"><figcaption>nice</figcaption></figure>`);
96+});
97+
98+test("renders hr", () => {
99+ const out = sxToHtml({
100+ v: SX_DOC_VERSION,
101+ blocks: [{ t: "hr" }],
102+ });
103+ expect(out).toBe("<hr>");
104+});
105+
106+test("passes html escape-hatch through verbatim", () => {
107+ const out = sxToHtml({
108+ v: SX_DOC_VERSION,
109+ blocks: [{ t: "html", src: "<table><tr><td>x</td></tr></table>" }],
110+ });
111+ expect(out).toBe("<table><tr><td>x</td></tr></table>");
112+});
113+
114+test("renders shortcodes without args using a compact form", () => {
115+ const out = sxToHtml({
116+ v: SX_DOC_VERSION,
117+ blocks: [{ t: "shortcode", name: "event-count", args: {} }],
118+ });
119+ expect(out).toBe("[[sx:event-count]]");
120+});
121+
122+test("renders shortcodes with args quoted", () => {
123+ const out = sxToHtml({
124+ v: SX_DOC_VERSION,
125+ blocks: [{ t: "shortcode", name: "list", args: { tag: "blog", limit: "5" } }],
126+ });
127+ expect(out).toBe(`[[sx:list tag="blog" limit="5"]]`);
128+});
129+
130+test("renders bold and italic marks deterministically", () => {
131+ const out = sxToHtml({
132+ v: SX_DOC_VERSION,
133+ blocks: [{
134+ t: "p",
135+ c: [{ t: "text", v: "both", m: ["i", "b"] }],
136+ }],
137+ });
138+ expect(out).toBe("<p><strong><em>both</em></strong></p>");
139+});
140+
141+test("renders anchor links", () => {
142+ const out = sxToHtml({
143+ v: SX_DOC_VERSION,
144+ blocks: [{
145+ t: "p",
146+ c: [{ t: "a", href: "/x", c: [{ t: "text", v: "click" }] }],
147+ }],
148+ });
149+ expect(out).toBe(`<p><a href="/x">click</a></p>`);
150+});
151+
152+test("escapes quotes and angle brackets in attributes", () => {
153+ const out = sxToHtml({
154+ v: SX_DOC_VERSION,
155+ blocks: [{
156+ t: "p",
157+ c: [{ t: "a", href: `/a"<b`, c: [{ t: "text", v: "x" }] }],
158+ }],
159+ });
160+ expect(out).toBe(`<p><a href="/a&quot;&lt;b">x</a></p>`);
161+});
162+
163+test("renders inline newline as <br>", () => {
164+ const out = sxToHtml({
165+ v: SX_DOC_VERSION,
166+ blocks: [{
167+ t: "p",
168+ c: [
169+ { t: "text", v: "a" },
170+ { t: "text", v: "\n" },
171+ { t: "text", v: "b" },
172+ ],
173+ }],
174+ });
175+ expect(out).toBe("<p>a<br>b</p>");
176+});
177+
178+// ─── round-trip property tests ───────────────────────────────────────────
179+// htmlToSx(sxToHtml(doc)) === doc must hold for representative docs.
180+
181+test("round-trip: simple paragraph", () => {
182+ const doc: SxDocument = {
183+ v: SX_DOC_VERSION,
184+ blocks: [{ t: "p", c: [{ t: "text", v: "hello" }] }],
185+ };
186+ expect(htmlToSx(sxToHtml(doc))).toEqual(doc);
187+});
188+
189+test("round-trip: heading + paragraph + hr", () => {
190+ const doc: SxDocument = {
191+ v: SX_DOC_VERSION,
192+ blocks: [
193+ { t: "h", level: 2, c: [{ t: "text", v: "Title" }] },
194+ { t: "p", c: [{ t: "text", v: "body" }] },
195+ { t: "hr" },
196+ ],
197+ };
198+ expect(htmlToSx(sxToHtml(doc))).toEqual(doc);
199+});
200+
201+test("round-trip: list of paragraphs", () => {
202+ const doc: SxDocument = {
203+ v: SX_DOC_VERSION,
204+ blocks: [{
205+ t: "ul",
206+ items: [
207+ [{ t: "p", c: [{ t: "text", v: "one" }] }],
208+ [{ t: "p", c: [{ t: "text", v: "two" }] }],
209+ ],
210+ }],
211+ };
212+ expect(htmlToSx(sxToHtml(doc))).toEqual(doc);
213+});
214+
215+test("round-trip: marks preserved across re-parse", () => {
216+ const doc: SxDocument = {
217+ v: SX_DOC_VERSION,
218+ blocks: [{
219+ t: "p",
220+ c: [{ t: "text", v: "x", m: ["b", "i"] }],
221+ }],
222+ };
223+ expect(htmlToSx(sxToHtml(doc))).toEqual(doc);
224+});
225+
226+test("round-trip: shortcode survives the trip", () => {
227+ const doc: SxDocument = {
228+ v: SX_DOC_VERSION,
229+ blocks: [{ t: "shortcode", name: "event-count", args: {} }],
230+ };
231+ expect(htmlToSx(sxToHtml(doc))).toEqual(doc);
232+});
233+
234+test("round-trip: code block with language", () => {
235+ const doc: SxDocument = {
236+ v: SX_DOC_VERSION,
237+ blocks: [{ t: "code", lang: "ts", src: "const x = 1;" }],
238+ };
239+ expect(htmlToSx(sxToHtml(doc))).toEqual(doc);
240+});
added src/c51_render_sxdoc.ts +132 −0
@@ -0,0 +1,132 @@
1+// c51 — SxDocument → HTML renderer.
2+//
3+// SAMA placement: c51 because this file produces HTML — Architecture.md
4+// picking-order regel 4: "Does it produce HTML? Yes → c51". Sub-page
5+// renderer (fragment-level) used by c51_render_layout / page builders to
6+// embed sxdoc content inside larger templates.
7+//
8+// Pure deterministic transform — no DOM, no I/O, no time, no randomness.
9+
10+import type {
11+ SxDocument, SxBlock, SxInline, SxMark, SxShortcode,
12+} from "./c31_sxdoc.ts";
13+
14+export const sxToHtml = (doc: SxDocument): string =>
15+ doc.blocks.map(renderBlock).join("\n");
16+
17+// ─── block-level ─────────────────────────────────────────────────────────
18+
19+const renderBlock = (block: SxBlock): string => {
20+ switch (block.t) {
21+ case "p":
22+ return `<p>${renderInline(block.c)}</p>`;
23+
24+ case "h":
25+ return `<h${block.level}>${renderInline(block.c)}</h${block.level}>`;
26+
27+ case "ul":
28+ case "ol": {
29+ const items = block.items
30+ .map((blocks) => `<li>${blocks.map(renderBlock).join("")}</li>`)
31+ .join("");
32+ return `<${block.t}>${items}</${block.t}>`;
33+ }
34+
35+ case "li":
36+ return `<li>${block.c.map(renderBlock).join("")}</li>`;
37+
38+ case "quote":
39+ return `<blockquote>${block.c.map(renderBlock).join("")}</blockquote>`;
40+
41+ case "code":
42+ return renderCodeBlock(block);
43+
44+ case "img":
45+ return renderImg(block);
46+
47+ case "hr":
48+ return `<hr>`;
49+
50+ case "html":
51+ // Raw passthrough — trust whoever inserted it. The parser only
52+ // emits SxHtml for round-trip-preservation of unknown HTML.
53+ return block.src;
54+
55+ case "shortcode":
56+ return renderShortcode(block);
57+ }
58+};
59+
60+const renderCodeBlock = (block: { lang?: string; src: string }): string => {
61+ const langClass = block.lang ? ` class="language-${escAttr(block.lang)}"` : "";
62+ return `<pre><code${langClass}>${escText(block.src)}</code></pre>`;
63+};
64+
65+const renderImg = (block: { src: string; alt?: string; caption?: string; w?: number; h?: number }): string => {
66+ const attrs = [`src="${escAttr(block.src)}"`];
67+ if (block.alt !== undefined) attrs.push(`alt="${escAttr(block.alt)}"`);
68+ if (block.w !== undefined) attrs.push(`width="${block.w}"`);
69+ if (block.h !== undefined) attrs.push(`height="${block.h}"`);
70+ const img = `<img ${attrs.join(" ")}>`;
71+ if (block.caption) {
72+ return `<figure>${img}<figcaption>${escText(block.caption)}</figcaption></figure>`;
73+ }
74+ return img;
75+};
76+
77+const renderShortcode = (block: SxShortcode): string => {
78+ const args = Object.entries(block.args)
79+ .map(([k, v]) => `${k}="${v.replace(/"/g, "&quot;")}"`)
80+ .join(" ");
81+ return args ? `[[sx:${block.name} ${args}]]` : `[[sx:${block.name}]]`;
82+};
83+
84+// ─── inline ──────────────────────────────────────────────────────────────
85+
86+// Stable mark order — matters so round-tripping is deterministic. The
87+// parser dedupes marks per text-run; renderer wraps them in this fixed
88+// order regardless of input ordering.
89+const MARK_ORDER: SxMark[] = ["b", "i", "u", "s", "c"];
90+const MARK_TAG: Record<SxMark, string> = {
91+ b: "strong", i: "em", u: "u", s: "s", c: "code",
92+};
93+
94+const renderInline = (inlines: SxInline[]): string =>
95+ inlines.map(renderOneInline).join("");
96+
97+const renderOneInline = (inline: SxInline): string => {
98+ if (inline.t === "a") {
99+ return `<a href="${escAttr(inline.href)}">${renderInline(inline.c)}</a>`;
100+ }
101+ // Newline runs render as <br>. Marks on a <br> are meaningless so we
102+ // drop them — the parser already emits them on the next text run.
103+ if (inline.v === "\n") return "<br>";
104+ let body = escText(inline.v);
105+ if (inline.m && inline.m.length > 0) {
106+ // MARK_ORDER lists marks outer→inner. Wrap in reverse so the
107+ // innermost mark is applied first, leaving the outermost-listed
108+ // mark as the outermost tag. Without the reverse, the deepest tag
109+ // becomes the outermost — and a re-parse flips the mark order.
110+ const sortedMarks = MARK_ORDER.filter((m) => inline.m!.includes(m));
111+ for (let i = sortedMarks.length - 1; i >= 0; i--) {
112+ const m = sortedMarks[i]!;
113+ body = `<${MARK_TAG[m]}>${body}</${MARK_TAG[m]}>`;
114+ }
115+ }
116+ return body;
117+};
118+
119+// ─── escape helpers ──────────────────────────────────────────────────────
120+
121+const escText = (s: string): string =>
122+ s
123+ .replace(/&/g, "&amp;")
124+ .replace(/</g, "&lt;")
125+ .replace(/>/g, "&gt;");
126+
127+const escAttr = (s: string): string =>
128+ s
129+ .replace(/&/g, "&amp;")
130+ .replace(/</g, "&lt;")
131+ .replace(/>/g, "&gt;")
132+ .replace(/"/g, "&quot;");
added src/client/blockeditor.ts +336 −0
@@ -0,0 +1,336 @@
1+// src/client — admin block editor: hydrates the admin edit form's
2+// textarea into a typed-block UI. Read SxDocument JSON from a
3+// <script id="sxdoc-initial"> tag, render blocks, persist changes
4+// back as HTML in the hidden textarea, autosave POST on debounce.
5+//
6+// SAMA note: src/client/**.ts lives outside the verifier's cXX_*.ts
7+// glob (per plan.md werkwijze). Files here can free-name but stay
8+// pure-functional where possible and avoid I/O modules that don't
9+// work in browsers (no node:fs, no bun:sqlite).
10+//
11+// The server's c51_render_sxdoc.sxToHtml is reused here for client-side
12+// serialisation. It's a pure type-imports-only module so Bun.build
13+// bundles it cleanly into the browser output.
14+
15+import type { SxDocument, SxBlock } from "../c31_sxdoc.ts";
16+import { SX_DOC_VERSION, emptyDocument } from "../c31_sxdoc.ts";
17+import { sxToHtml } from "../c51_render_sxdoc.ts";
18+import { renderBlock, blockToInlineText, plainTextToInlines } from "./blocks.ts";
19+import { openSlashMenu } from "./slashmenu.ts";
20+
21+const AUTOSAVE_DEBOUNCE_MS = 1000;
22+const SAVE_TOAST_MS = 2500;
23+
24+interface EditorState {
25+ doc: SxDocument;
26+ form: HTMLFormElement;
27+ htmlField: HTMLTextAreaElement;
28+ mount: HTMLElement;
29+ toast: HTMLElement;
30+ saveTimer: number | null;
31+ toastTimer: number | null;
32+ lastSavedHash: string;
33+}
34+
35+const hashStr = (s: string): number => {
36+ let h = 0;
37+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
38+ return h;
39+};
40+
41+// ─── hydration ───────────────────────────────────────────────────────────
42+
43+const init = (): void => {
44+ const initialScript = document.getElementById("sxdoc-initial");
45+ const form = document.querySelector<HTMLFormElement>("form.admin-form");
46+ const htmlField = document.querySelector<HTMLTextAreaElement>('form.admin-form textarea[name="html"]');
47+ if (!initialScript || !form || !htmlField) return; // not an admin edit page
48+
49+ let doc: SxDocument;
50+ try {
51+ const raw = initialScript.textContent ?? "";
52+ doc = raw.trim() ? (JSON.parse(raw) as SxDocument) : emptyDocument();
53+ if (!doc || !Array.isArray(doc.blocks)) doc = emptyDocument();
54+ } catch {
55+ doc = emptyDocument();
56+ }
57+ if (doc.v !== SX_DOC_VERSION) {
58+ console.warn(`[blockeditor] sxdoc version ${doc.v} ≠ expected ${SX_DOC_VERSION}`);
59+ }
60+
61+ const mount = document.createElement("div");
62+ mount.className = "block-editor";
63+ htmlField.parentElement?.insertBefore(mount, htmlField);
64+ htmlField.classList.add("block-editor-raw");
65+ // Keep the raw textarea reachable as the escape-hatch — hidden by
66+ // default, toggleable via a "raw" button so users can rescue malformed
67+ // content if the block editor stumbles.
68+ htmlField.style.display = "none";
69+
70+ const toast = document.createElement("div");
71+ toast.className = "block-editor-toast";
72+ toast.setAttribute("aria-live", "polite");
73+ document.body.appendChild(toast);
74+
75+ const initialHtml = sxToHtml(doc);
76+ htmlField.value = initialHtml;
77+
78+ const state: EditorState = {
79+ doc,
80+ form,
81+ htmlField,
82+ mount,
83+ toast,
84+ saveTimer: null,
85+ toastTimer: null,
86+ lastSavedHash: hashStr(initialHtml),
87+ };
88+
89+ // Mode toggle: a "raw" link to drop into the textarea if the block
90+ // editor mis-parses something. The user's escape hatch — small but
91+ // load-bearing per the SAMA escape-hatch principle.
92+ const toggle = document.createElement("button");
93+ toggle.type = "button";
94+ toggle.className = "block-editor-mode";
95+ toggle.textContent = "raw mode";
96+ toggle.addEventListener("click", () => {
97+ if (htmlField.style.display === "none") {
98+ htmlField.style.display = "";
99+ mount.style.display = "none";
100+ toggle.textContent = "block mode";
101+ } else {
102+ // Re-hydrate from the raw textarea HTML — parse there happens
103+ // server-side on form submit, so just round-trip the value.
104+ htmlField.style.display = "none";
105+ mount.style.display = "";
106+ toggle.textContent = "raw mode";
107+ }
108+ });
109+ htmlField.parentElement?.insertBefore(toggle, mount);
110+
111+ renderAll(state);
112+ attachAutosaveOnSubmit(state);
113+};
114+
115+// ─── rendering ───────────────────────────────────────────────────────────
116+
117+const renderAll = (state: EditorState): void => {
118+ state.mount.innerHTML = "";
119+ if (state.doc.blocks.length === 0) {
120+ state.mount.appendChild(emptyBlockSlot(state, 0));
121+ return;
122+ }
123+ state.doc.blocks.forEach((block, idx) => {
124+ state.mount.appendChild(blockWrapper(state, block, idx));
125+ });
126+ // Final trailing insert-slot so the user can append without dancing
127+ // around the last block's hover affordance.
128+ state.mount.appendChild(insertSlot(state, state.doc.blocks.length));
129+};
130+
131+const blockWrapper = (state: EditorState, block: SxBlock, idx: number): HTMLElement => {
132+ const wrap = document.createElement("div");
133+ wrap.className = `block block-${block.t}`;
134+ wrap.dataset.idx = String(idx);
135+
136+ const handle = document.createElement("div");
137+ handle.className = "block-handle";
138+ handle.textContent = "⋮⋮";
139+ handle.title = "drag (todo) · click for actions";
140+ wrap.appendChild(handle);
141+
142+ const body = renderBlock(block, (next) => updateBlock(state, idx, next));
143+ body.classList.add("block-body");
144+ wrap.appendChild(body);
145+
146+ const actions = document.createElement("div");
147+ actions.className = "block-actions";
148+ const del = document.createElement("button");
149+ del.type = "button";
150+ del.className = "block-delete";
151+ del.title = "delete block";
152+ del.textContent = "×";
153+ del.addEventListener("click", () => deleteBlock(state, idx));
154+ actions.appendChild(del);
155+ wrap.appendChild(actions);
156+
157+ // Slash trigger: when an empty paragraph's contenteditable gets a "/"
158+ // at position 0, surface the conversion menu instead of typing the
159+ // character literally. The block renderer wires this signal via a
160+ // CustomEvent("sxdoc:slash"), keeping per-block logic in blocks.ts.
161+ body.addEventListener("sxdoc:slash", (evt) => {
162+ const ce = evt as CustomEvent<{ x: number; y: number }>;
163+ openSlashMenu({
164+ anchor: { x: ce.detail.x, y: ce.detail.y },
165+ onPick: (kind) => convertBlock(state, idx, kind),
166+ });
167+ });
168+
169+ return wrap;
170+};
171+
172+const emptyBlockSlot = (state: EditorState, idx: number): HTMLElement => {
173+ const wrap = document.createElement("div");
174+ wrap.className = "block-empty";
175+ const hint = document.createElement("p");
176+ hint.className = "block-empty-hint";
177+ hint.textContent = "Type / to insert a block, or click +";
178+ wrap.appendChild(hint);
179+ wrap.appendChild(insertSlot(state, idx));
180+ return wrap;
181+};
182+
183+const insertSlot = (state: EditorState, idx: number): HTMLElement => {
184+ const slot = document.createElement("div");
185+ slot.className = "block-insert";
186+ const btn = document.createElement("button");
187+ btn.type = "button";
188+ btn.className = "block-insert-btn";
189+ btn.textContent = "+";
190+ btn.title = "insert block here";
191+ btn.addEventListener("click", (e) => {
192+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
193+ openSlashMenu({
194+ anchor: { x: rect.left, y: rect.bottom + 4 },
195+ onPick: (kind) => insertBlock(state, idx, kind),
196+ });
197+ });
198+ slot.appendChild(btn);
199+ return slot;
200+};
201+
202+// ─── state mutations ─────────────────────────────────────────────────────
203+
204+const updateBlock = (state: EditorState, idx: number, next: SxBlock): void => {
205+ state.doc = {
206+ ...state.doc,
207+ blocks: state.doc.blocks.map((b, i) => (i === idx ? next : b)),
208+ };
209+ // No full re-render here — the per-block element kept its own DOM
210+ // and only the underlying state changed. Just persist + autosave.
211+ persistAndAutosave(state);
212+};
213+
214+const deleteBlock = (state: EditorState, idx: number): void => {
215+ state.doc = {
216+ ...state.doc,
217+ blocks: state.doc.blocks.filter((_, i) => i !== idx),
218+ };
219+ renderAll(state);
220+ persistAndAutosave(state);
221+};
222+
223+const insertBlock = (state: EditorState, idx: number, kind: string): void => {
224+ const block = newBlock(kind);
225+ if (!block) return;
226+ const blocks = state.doc.blocks.slice();
227+ blocks.splice(idx, 0, block);
228+ state.doc = { ...state.doc, blocks };
229+ renderAll(state);
230+ persistAndAutosave(state);
231+};
232+
233+const convertBlock = (state: EditorState, idx: number, kind: string): void => {
234+ const current = state.doc.blocks[idx];
235+ if (!current) return;
236+ // Carry over any inline content so converting "Hello/" → heading
237+ // doesn't wipe what the user just typed.
238+ const text = blockToInlineText(current);
239+ const inlines = plainTextToInlines(text);
240+ const next = newBlock(kind, inlines);
241+ if (!next) return;
242+ state.doc = {
243+ ...state.doc,
244+ blocks: state.doc.blocks.map((b, i) => (i === idx ? next : b)),
245+ };
246+ renderAll(state);
247+ persistAndAutosave(state);
248+};
249+
250+const newBlock = (kind: string, inlines?: import("../c31_sxdoc.ts").SxInline[]): SxBlock | null => {
251+ const c = inlines ?? [];
252+ switch (kind) {
253+ case "p": return { t: "p", c };
254+ case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": {
255+ const level = parseInt(kind.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6;
256+ return { t: "h", level, c };
257+ }
258+ case "ul": return { t: "ul", items: [[{ t: "p", c }]] };
259+ case "ol": return { t: "ol", items: [[{ t: "p", c }]] };
260+ case "quote": return { t: "quote", c: [{ t: "p", c }] };
261+ case "code": return { t: "code", lang: "", src: inlines ? inlines.map((i) => (i.t === "text" ? i.v : "")).join("") : "" };
262+ case "hr": return { t: "hr" };
263+ case "html": return { t: "html", src: "" };
264+ case "shortcode": return { t: "shortcode", name: "", args: {} };
265+ default: return null;
266+ }
267+};
268+
269+// ─── persistence ─────────────────────────────────────────────────────────
270+
271+const persistAndAutosave = (state: EditorState): void => {
272+ const html = sxToHtml(state.doc);
273+ state.htmlField.value = html;
274+ const h = hashStr(html);
275+ if (h === state.lastSavedHash) return; // no diff
276+
277+ if (state.saveTimer !== null) {
278+ clearTimeout(state.saveTimer);
279+ }
280+ state.saveTimer = window.setTimeout(() => {
281+ void doAutosave(state, html, h);
282+ }, AUTOSAVE_DEBOUNCE_MS);
283+};
284+
285+const doAutosave = async (state: EditorState, html: string, hash: number): Promise<void> => {
286+ state.saveTimer = null;
287+ const fd = new FormData(state.form);
288+ // Ensure the html field carries our serialised value (FormData reads
289+ // from the form fields, which we already wrote into).
290+ fd.set("html", html);
291+ try {
292+ const res = await fetch(state.form.action, {
293+ method: "POST",
294+ body: fd,
295+ headers: { Accept: "application/json" },
296+ });
297+ if (!res.ok) {
298+ const txt = await res.text();
299+ showToast(state, `save failed: ${res.status} ${txt.slice(0, 80)}`, "error");
300+ return;
301+ }
302+ state.lastSavedHash = hash;
303+ showToast(state, "saved · just now", "ok");
304+ } catch (e) {
305+ showToast(state, `save failed: ${(e as Error).message}`, "error");
306+ }
307+};
308+
309+// If the user hits the explicit Save button before the debounce fires,
310+// the form submits naturally — but we still want the latest HTML in the
311+// textarea. Our writes are synchronous, so by submit time the field is
312+// already fresh; this handler is a paranoid backstop.
313+const attachAutosaveOnSubmit = (state: EditorState): void => {
314+ state.form.addEventListener("submit", () => {
315+ state.htmlField.value = sxToHtml(state.doc);
316+ });
317+};
318+
319+// ─── toast ────────────────────────────────────────────────────────────────
320+
321+const showToast = (state: EditorState, msg: string, kind: "ok" | "error"): void => {
322+ state.toast.textContent = msg;
323+ state.toast.className = `block-editor-toast block-editor-toast-${kind} block-editor-toast-show`;
324+ if (state.toastTimer !== null) clearTimeout(state.toastTimer);
325+ state.toastTimer = window.setTimeout(() => {
326+ state.toast.className = "block-editor-toast";
327+ }, SAVE_TOAST_MS);
328+};
329+
330+// ─── boot ────────────────────────────────────────────────────────────────
331+
332+if (document.readyState === "loading") {
333+ document.addEventListener("DOMContentLoaded", init);
334+} else {
335+ init();
336+}
added src/client/blocks.ts +393 −0
@@ -0,0 +1,393 @@
1+// src/client — per-block-kind interactive renderers. Each renderer
2+// returns an HTMLElement that's the editable surface for a block, and
3+// calls `onChange(next)` whenever the user's edit changes the typed
4+// block shape. blockeditor.ts owns state/persistence/slash-menu wiring
5+// and renders <handle><body><actions> chrome around each call here.
6+//
7+// Split per Atomic threshold: keep this file ≤700 LOC. If marketing
8+// blocks ever land they get their own file.
9+
10+import type {
11+ SxBlock, SxInline, SxText, SxLink, SxParagraph, SxHeading, SxList,
12+ SxQuote, SxCodeBlock, SxImage, SxHtml, SxShortcode,
13+} from "../c31_sxdoc.ts";
14+
15+export const renderBlock = (block: SxBlock, onChange: (next: SxBlock) => void): HTMLElement => {
16+ switch (block.t) {
17+ case "p": return renderParagraph(block, onChange);
18+ case "h": return renderHeading(block, onChange);
19+ case "ul":
20+ case "ol": return renderList(block, onChange);
21+ case "li": return renderParagraph({ t: "p", c: extractInlines(block.c) }, (next) =>
22+ onChange({ t: "li", c: [next] }));
23+ case "quote": return renderQuote(block, onChange);
24+ case "code": return renderCode(block, onChange);
25+ case "img": return renderImage(block, onChange);
26+ case "hr": return renderHr();
27+ case "html": return renderHtml(block, onChange);
28+ case "shortcode": return renderShortcode(block, onChange);
29+ }
30+};
31+
32+// ─── paragraph ───────────────────────────────────────────────────────────
33+
34+const renderParagraph = (block: SxParagraph, onChange: (next: SxBlock) => void): HTMLElement => {
35+ const el = document.createElement("p");
36+ el.contentEditable = "true";
37+ el.dataset.placeholder = "Type / for menu";
38+ el.innerHTML = inlinesToEditableHtml(block.c);
39+ attachInlineEditing(el, () => onChange({ t: "p", c: parseInlinesFromHtml(el.innerHTML) }));
40+ attachSlashTrigger(el);
41+ return el;
42+};
43+
44+// ─── heading ─────────────────────────────────────────────────────────────
45+
46+const renderHeading = (block: SxHeading, onChange: (next: SxBlock) => void): HTMLElement => {
47+ const el = document.createElement(`h${block.level}`);
48+ el.contentEditable = "true";
49+ el.dataset.placeholder = `Heading ${block.level}`;
50+ el.innerHTML = inlinesToEditableHtml(block.c);
51+ attachInlineEditing(el, () =>
52+ onChange({ t: "h", level: block.level, c: parseInlinesFromHtml(el.innerHTML) }));
53+ attachSlashTrigger(el);
54+ return el;
55+};
56+
57+// ─── list ────────────────────────────────────────────────────────────────
58+
59+const renderList = (block: SxList, onChange: (next: SxBlock) => void): HTMLElement => {
60+ const el = document.createElement(block.t);
61+ // Each list item is an SxBlock[] — for the editor we treat the first
62+ // paragraph as the editable item content. Nested lists/quotes inside
63+ // a list item are kept structural and re-rendered on each change.
64+ block.items.forEach((itemBlocks, itemIdx) => {
65+ const li = document.createElement("li");
66+ const text = itemBlocks.find((b) => b.t === "p") as SxParagraph | undefined;
67+ li.contentEditable = "true";
68+ li.dataset.placeholder = "List item";
69+ li.innerHTML = inlinesToEditableHtml(text?.c ?? []);
70+ attachInlineEditing(li, () => {
71+ const newItems = block.items.slice();
72+ newItems[itemIdx] = [{ t: "p", c: parseInlinesFromHtml(li.innerHTML) }];
73+ onChange({ t: block.t, items: newItems });
74+ });
75+ li.addEventListener("keydown", (evt) => {
76+ if (evt.key === "Enter" && !evt.shiftKey) {
77+ evt.preventDefault();
78+ const newItems = block.items.slice();
79+ newItems.splice(itemIdx + 1, 0, [{ t: "p", c: [] }]);
80+ onChange({ t: block.t, items: newItems });
81+ }
82+ });
83+ el.appendChild(li);
84+ });
85+ return el;
86+};
87+
88+// ─── quote ────────────────────────────────────────────────────────────────
89+
90+const renderQuote = (block: SxQuote, onChange: (next: SxBlock) => void): HTMLElement => {
91+ const el = document.createElement("blockquote");
92+ el.contentEditable = "true";
93+ el.dataset.placeholder = "Quote";
94+ const firstPara = block.c.find((b) => b.t === "p") as SxParagraph | undefined;
95+ el.innerHTML = inlinesToEditableHtml(firstPara?.c ?? []);
96+ attachInlineEditing(el, () =>
97+ onChange({ t: "quote", c: [{ t: "p", c: parseInlinesFromHtml(el.innerHTML) }] }));
98+ return el;
99+};
100+
101+// ─── code ────────────────────────────────────────────────────────────────
102+
103+const renderCode = (block: SxCodeBlock, onChange: (next: SxBlock) => void): HTMLElement => {
104+ const wrap = document.createElement("div");
105+ wrap.className = "code-shell";
106+ const lang = document.createElement("input");
107+ lang.type = "text";
108+ lang.placeholder = "language (ts, py, …)";
109+ lang.value = block.lang ?? "";
110+ lang.className = "code-lang";
111+ const ta = document.createElement("textarea");
112+ ta.value = block.src;
113+ ta.spellcheck = false;
114+ ta.rows = Math.max(3, block.src.split("\n").length);
115+ ta.className = "code-src";
116+ const emit = (): void => onChange({ t: "code", lang: lang.value.trim() || "", src: ta.value });
117+ lang.addEventListener("input", emit);
118+ ta.addEventListener("input", () => {
119+ ta.rows = Math.max(3, ta.value.split("\n").length);
120+ emit();
121+ });
122+ wrap.appendChild(lang);
123+ wrap.appendChild(ta);
124+ return wrap;
125+};
126+
127+// ─── image ───────────────────────────────────────────────────────────────
128+
129+const renderImage = (block: SxImage, onChange: (next: SxBlock) => void): HTMLElement => {
130+ const wrap = document.createElement("div");
131+ wrap.className = "img-shell";
132+ const src = inputRow("src", block.src);
133+ const alt = inputRow("alt", block.alt ?? "");
134+ const cap = inputRow("caption", block.caption ?? "");
135+ const emit = (): void => {
136+ const next: SxImage = { t: "img", src: (src.input.value || "").trim() };
137+ if (alt.input.value.trim()) next.alt = alt.input.value.trim();
138+ if (cap.input.value.trim()) next.caption = cap.input.value.trim();
139+ if (block.w !== undefined) next.w = block.w;
140+ if (block.h !== undefined) next.h = block.h;
141+ onChange(next);
142+ };
143+ [src.input, alt.input, cap.input].forEach((i) => i.addEventListener("input", emit));
144+ wrap.appendChild(src.row);
145+ wrap.appendChild(alt.row);
146+ wrap.appendChild(cap.row);
147+ if (block.src) {
148+ const preview = document.createElement("img");
149+ preview.className = "img-preview";
150+ preview.src = block.src;
151+ preview.alt = block.alt ?? "";
152+ wrap.appendChild(preview);
153+ }
154+ return wrap;
155+};
156+
157+const inputRow = (label: string, value: string): { row: HTMLElement; input: HTMLInputElement } => {
158+ const row = document.createElement("label");
159+ row.className = "img-row";
160+ const span = document.createElement("span");
161+ span.textContent = label;
162+ const input = document.createElement("input");
163+ input.type = "text";
164+ input.value = value;
165+ row.appendChild(span);
166+ row.appendChild(input);
167+ return { row, input };
168+};
169+
170+// ─── hr ──────────────────────────────────────────────────────────────────
171+
172+const renderHr = (): HTMLElement => {
173+ const wrap = document.createElement("div");
174+ wrap.className = "hr-shell";
175+ wrap.appendChild(document.createElement("hr"));
176+ return wrap;
177+};
178+
179+// ─── html escape-hatch ──────────────────────────────────────────────────
180+
181+const renderHtml = (block: SxHtml, onChange: (next: SxBlock) => void): HTMLElement => {
182+ const ta = document.createElement("textarea");
183+ ta.className = "html-shell";
184+ ta.value = block.src;
185+ ta.spellcheck = false;
186+ ta.rows = Math.max(3, block.src.split("\n").length);
187+ ta.addEventListener("input", () => {
188+ ta.rows = Math.max(3, ta.value.split("\n").length);
189+ onChange({ t: "html", src: ta.value });
190+ });
191+ return ta;
192+};
193+
194+// ─── shortcode ──────────────────────────────────────────────────────────
195+
196+const renderShortcode = (block: SxShortcode, onChange: (next: SxBlock) => void): HTMLElement => {
197+ const wrap = document.createElement("div");
198+ wrap.className = "shortcode-shell";
199+ const name = document.createElement("input");
200+ name.type = "text";
201+ name.placeholder = "shortcode name (e.g. event-count)";
202+ name.value = block.name;
203+ const args = document.createElement("input");
204+ args.type = "text";
205+ args.placeholder = `args as key="value" pairs`;
206+ args.value = Object.entries(block.args).map(([k, v]) => `${k}="${v}"`).join(" ");
207+ const parseArgs = (raw: string): Record<string, string> => {
208+ const out: Record<string, string> = {};
209+ const re = /([a-z0-9_-]+)=(?:"([^"]*)"|([^\s"]+))/g;
210+ for (const m of raw.matchAll(re)) {
211+ out[m[1]!] = m[2] ?? m[3] ?? "";
212+ }
213+ return out;
214+ };
215+ const emit = (): void => onChange({ t: "shortcode", name: name.value.trim(), args: parseArgs(args.value) });
216+ name.addEventListener("input", emit);
217+ args.addEventListener("input", emit);
218+ wrap.appendChild(name);
219+ wrap.appendChild(args);
220+ return wrap;
221+};
222+
223+// ─── inline ←→ HTML helpers ──────────────────────────────────────────────
224+
225+// Render SxInline[] as the minimal HTML the contenteditable surface
226+// can preserve. We only emit tags we know how to parse back: a, strong,
227+// em, u, s, code, br. Anything fancier round-trips as plain text.
228+export const inlinesToEditableHtml = (inlines: SxInline[]): string => {
229+ if (inlines.length === 0) return "";
230+ return inlines.map(renderOneInline).join("");
231+};
232+
233+const renderOneInline = (inline: SxInline): string => {
234+ if (inline.t === "a") return `<a href="${escAttr(inline.href)}">${inlinesToEditableHtml(inline.c)}</a>`;
235+ // text
236+ if (inline.v === "\n") return "<br>";
237+ let body = escText(inline.v);
238+ // Stable nesting: b > i > u > s > c (matches server c51_render_sxdoc).
239+ const marks = inline.m ?? [];
240+ const order: Array<{ m: string; tag: string }> = [
241+ { m: "b", tag: "strong" }, { m: "i", tag: "em" }, { m: "u", tag: "u" },
242+ { m: "s", tag: "s" }, { m: "c", tag: "code" },
243+ ];
244+ for (let i = order.length - 1; i >= 0; i--) {
245+ const { m, tag } = order[i]!;
246+ if (marks.includes(m as never)) body = `<${tag}>${body}</${tag}>`;
247+ }
248+ return body;
249+};
250+
251+// Lossy-by-design: re-parse the contenteditable's innerHTML into the
252+// inline subset we support. Uses DOMParser → walks the tree → emits
253+// SxText / SxLink. Marks are accumulated as we descend.
254+export const parseInlinesFromHtml = (html: string): SxInline[] => {
255+ const parser = new DOMParser();
256+ const doc = parser.parseFromString(`<div>${html}</div>`, "text/html");
257+ const root = doc.body.firstChild as HTMLElement | null;
258+ if (!root) return [];
259+ return collectInline(root, []);
260+};
261+
262+const MARK_FOR_TAG: Record<string, string> = {
263+ b: "b", strong: "b", i: "i", em: "i", u: "u", s: "s",
264+ strike: "s", del: "s", code: "c",
265+};
266+
267+const collectInline = (node: Node, marks: string[]): SxInline[] => {
268+ const out: SxInline[] = [];
269+ for (const child of Array.from(node.childNodes)) {
270+ if (child.nodeType === Node.TEXT_NODE) {
271+ const v = (child as Text).data;
272+ if (v.length > 0) {
273+ const text: SxText = { t: "text", v };
274+ if (marks.length) (text as { m?: string[] }).m = dedupe(marks);
275+ out.push(text as SxText);
276+ }
277+ continue;
278+ }
279+ if (child.nodeType !== Node.ELEMENT_NODE) continue;
280+ const el = child as HTMLElement;
281+ const tag = el.tagName.toLowerCase();
282+ if (tag === "br") {
283+ const t: SxText = { t: "text", v: "\n" };
284+ if (marks.length) (t as { m?: string[] }).m = dedupe(marks);
285+ out.push(t);
286+ continue;
287+ }
288+ if (tag === "a") {
289+ const link: SxLink = {
290+ t: "a",
291+ href: (el as HTMLAnchorElement).getAttribute("href") ?? "",
292+ c: collectInline(el, marks),
293+ };
294+ out.push(link);
295+ continue;
296+ }
297+ const mark = MARK_FOR_TAG[tag];
298+ if (mark) {
299+ out.push(...collectInline(el, [...marks, mark]));
300+ continue;
301+ }
302+ out.push(...collectInline(el, marks));
303+ }
304+ // Best-effort: cast marks to SxMark inside the consumer. Marks were
305+ // already validated by the MARK_FOR_TAG whitelist.
306+ return out as SxInline[];
307+};
308+
309+const dedupe = (marks: string[]): string[] => {
310+ const seen = new Set<string>();
311+ const out: string[] = [];
312+ for (const m of marks) if (!seen.has(m)) { seen.add(m); out.push(m); }
313+ return out;
314+};
315+
316+// ─── slash-trigger ──────────────────────────────────────────────────────
317+
318+// When the user types "/" at the start of an empty contenteditable
319+// surface, fire a CustomEvent with the caret position so the editor
320+// shell can open the slash menu near the cursor.
321+const attachSlashTrigger = (el: HTMLElement): void => {
322+ el.addEventListener("keydown", (evt) => {
323+ if (evt.key !== "/") return;
324+ const text = el.textContent ?? "";
325+ if (text.trim().length > 0) return; // only fire on empty blocks
326+ evt.preventDefault();
327+ const rect = el.getBoundingClientRect();
328+ el.dispatchEvent(new CustomEvent("sxdoc:slash", {
329+ detail: { x: rect.left, y: rect.bottom + 4 },
330+ bubbles: true,
331+ }));
332+ });
333+};
334+
335+// Debounced inline-edit signal — fires onChange after a brief idle so we
336+// aren't re-serialising on every keystroke. State stays consistent
337+// because the input event always wins-last-write.
338+const attachInlineEditing = (el: HTMLElement, onChange: () => void): void => {
339+ let t: number | null = null;
340+ el.addEventListener("input", () => {
341+ if (t !== null) clearTimeout(t);
342+ t = window.setTimeout(() => {
343+ t = null;
344+ onChange();
345+ }, 150);
346+ });
347+ // Blur flushes immediately so leaving the field saves the latest edit.
348+ el.addEventListener("blur", () => {
349+ if (t !== null) { clearTimeout(t); t = null; }
350+ onChange();
351+ });
352+};
353+
354+// ─── conversion helpers (used by blockeditor.ts) ────────────────────────
355+
356+// Extract a plain-text projection of a block — used when converting one
357+// block kind to another (so the user's typed prefix carries over).
358+export const blockToInlineText = (block: SxBlock): string => {
359+ switch (block.t) {
360+ case "p":
361+ case "h": return inlinesToPlain(block.c);
362+ case "quote": return block.c.flatMap((b) => b.t === "p" ? [inlinesToPlain(b.c)] : []).join(" ");
363+ case "ul":
364+ case "ol": return block.items.flat().flatMap((b) => b.t === "p" ? [inlinesToPlain(b.c)] : []).join(" ");
365+ case "li": return block.c.flatMap((b) => b.t === "p" ? [inlinesToPlain(b.c)] : []).join(" ");
366+ case "code": return block.src;
367+ case "img": return block.caption ?? block.alt ?? "";
368+ case "hr": return "";
369+ case "html": return "";
370+ case "shortcode": return "";
371+ }
372+};
373+
374+const inlinesToPlain = (inlines: SxInline[]): string =>
375+ inlines.map((i) => (i.t === "text" ? i.v : inlinesToPlain(i.c))).join("");
376+
377+// Wrap a plain string into a single SxText inline (no marks).
378+export const plainTextToInlines = (text: string): SxInline[] =>
379+ text.length === 0 ? [] : [{ t: "text", v: text }];
380+
381+// Helpers for `extractInlines` of nested-li edge case.
382+const extractInlines = (blocks: SxBlock[]): SxInline[] => {
383+ const para = blocks.find((b) => b.t === "p") as SxParagraph | undefined;
384+ return para?.c ?? [];
385+};
386+
387+// ─── escape helpers ─────────────────────────────────────────────────────
388+
389+const escText = (s: string): string =>
390+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
391+
392+const escAttr = (s: string): string =>
393+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
added src/client/slashmenu.ts +161 −0
@@ -0,0 +1,161 @@
1+// src/client — slash menu. Pop-up at (x, y) with a filterable list of
2+// block kinds. blockeditor.ts opens this when the user types "/" at the
3+// start of an empty block, or clicks the inline "+" affordance.
4+//
5+// API is intentionally tiny: openSlashMenu({ anchor, onPick }). The
6+// menu owns its own DOM and clean-up; closes on Escape, click-outside,
7+// or selection.
8+
9+export interface SlashMenuOptions {
10+ anchor: { x: number; y: number };
11+ onPick: (kind: string) => void;
12+}
13+
14+interface MenuItem {
15+ kind: string;
16+ label: string;
17+ hint: string;
18+ // Aliases the user might type instead of the canonical kind. Helps
19+ // muscle-memory ("/heading" → h2, "/list" → ul).
20+ aliases: string[];
21+}
22+
23+const ITEMS: MenuItem[] = [
24+ { kind: "p", label: "Paragraph", hint: "text", aliases: ["paragraph", "text"] },
25+ { kind: "h1", label: "Heading 1", hint: "section title", aliases: ["h1", "heading"] },
26+ { kind: "h2", label: "Heading 2", hint: "subsection title", aliases: ["h2", "heading"] },
27+ { kind: "h3", label: "Heading 3", hint: "small section", aliases: ["h3"] },
28+ { kind: "ul", label: "Bulleted list", hint: "unordered list", aliases: ["ul", "list", "bullets"] },
29+ { kind: "ol", label: "Numbered list", hint: "ordered list", aliases: ["ol", "numbered"] },
30+ { kind: "quote", label: "Quote", hint: "blockquote", aliases: ["quote", "blockquote"] },
31+ { kind: "code", label: "Code block", hint: "fenced code with language", aliases: ["code", "pre"] },
32+ { kind: "img", label: "Image", hint: "src + alt + caption", aliases: ["image", "img", "picture"] },
33+ { kind: "hr", label: "Divider", hint: "horizontal rule", aliases: ["divider", "hr", "rule"] },
34+ { kind: "shortcode", label: "Shortcode", hint: "[[sx:name args]]", aliases: ["shortcode", "sx"] },
35+ { kind: "html", label: "Raw HTML", hint: "escape hatch", aliases: ["html", "raw"] },
36+];
37+
38+let openMenu: HTMLElement | null = null;
39+let openCleanup: (() => void) | null = null;
40+
41+export const openSlashMenu = (opts: SlashMenuOptions): void => {
42+ closeSlashMenu();
43+
44+ const menu = document.createElement("div");
45+ menu.className = "slash-menu";
46+ menu.style.left = `${opts.anchor.x}px`;
47+ menu.style.top = `${opts.anchor.y}px`;
48+
49+ const input = document.createElement("input");
50+ input.type = "text";
51+ input.className = "slash-menu-filter";
52+ input.placeholder = "filter…";
53+ menu.appendChild(input);
54+
55+ const listEl = document.createElement("ul");
56+ listEl.className = "slash-menu-list";
57+ menu.appendChild(listEl);
58+
59+ let filtered: MenuItem[] = ITEMS;
60+ let highlighted = 0;
61+
62+ const render = (): void => {
63+ listEl.innerHTML = "";
64+ if (filtered.length === 0) {
65+ const empty = document.createElement("li");
66+ empty.className = "slash-menu-empty";
67+ empty.textContent = "no matches";
68+ listEl.appendChild(empty);
69+ return;
70+ }
71+ filtered.forEach((item, i) => {
72+ const li = document.createElement("li");
73+ li.className = i === highlighted ? "slash-menu-item highlighted" : "slash-menu-item";
74+ const label = document.createElement("span");
75+ label.className = "slash-menu-label";
76+ label.textContent = item.label;
77+ const hint = document.createElement("span");
78+ hint.className = "slash-menu-hint";
79+ hint.textContent = item.hint;
80+ li.appendChild(label);
81+ li.appendChild(hint);
82+ li.addEventListener("mouseenter", () => {
83+ highlighted = i;
84+ render();
85+ });
86+ li.addEventListener("mousedown", (evt) => {
87+ // mousedown (not click) so the menu doesn't lose focus before
88+ // the pick fires — the click handler can race with blur.
89+ evt.preventDefault();
90+ pick(item.kind);
91+ });
92+ listEl.appendChild(li);
93+ });
94+ };
95+
96+ const filter = (query: string): void => {
97+ const q = query.toLowerCase().trim();
98+ if (!q) {
99+ filtered = ITEMS;
100+ } else {
101+ filtered = ITEMS.filter((item) =>
102+ item.kind.toLowerCase().includes(q) ||
103+ item.label.toLowerCase().includes(q) ||
104+ item.aliases.some((a) => a.startsWith(q)),
105+ );
106+ }
107+ highlighted = Math.min(highlighted, Math.max(0, filtered.length - 1));
108+ render();
109+ };
110+
111+ const pick = (kind: string): void => {
112+ closeSlashMenu();
113+ opts.onPick(kind);
114+ };
115+
116+ input.addEventListener("input", () => filter(input.value));
117+ input.addEventListener("keydown", (evt) => {
118+ if (evt.key === "Escape") { evt.preventDefault(); closeSlashMenu(); return; }
119+ if (evt.key === "ArrowDown") {
120+ evt.preventDefault();
121+ highlighted = Math.min(filtered.length - 1, highlighted + 1);
122+ render();
123+ return;
124+ }
125+ if (evt.key === "ArrowUp") {
126+ evt.preventDefault();
127+ highlighted = Math.max(0, highlighted - 1);
128+ render();
129+ return;
130+ }
131+ if (evt.key === "Enter") {
132+ evt.preventDefault();
133+ const choice = filtered[highlighted];
134+ if (choice) pick(choice.kind);
135+ return;
136+ }
137+ });
138+
139+ document.body.appendChild(menu);
140+ openMenu = menu;
141+
142+ // Defer focus until after the mount so the input actually accepts keys.
143+ setTimeout(() => input.focus(), 0);
144+ render();
145+
146+ const onDocMouseDown = (evt: MouseEvent): void => {
147+ if (!menu.contains(evt.target as Node)) closeSlashMenu();
148+ };
149+ document.addEventListener("mousedown", onDocMouseDown, true);
150+
151+ openCleanup = (): void => {
152+ document.removeEventListener("mousedown", onDocMouseDown, true);
153+ menu.remove();
154+ };
155+};
156+
157+export const closeSlashMenu = (): void => {
158+ if (openCleanup) openCleanup();
159+ openCleanup = null;
160+ openMenu = null;
161+};