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]>
26 files changed · +3673 −182
bun.lock
+23
−0
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | "name": "tdd.md", |
| 7 | 7 | "dependencies": { |
| 8 | 8 | "marked": "^14.1.4", |
| 9 | + "node-html-parser": "^7.0.1", | |
| 9 | 10 | }, |
| 10 | 11 | "devDependencies": { |
| 11 | 12 | "@playwright/test": "^1.59.1", |
| @@ -20,12 +21,34 @@ | ||
| 20 | 21 | |
| 21 | 22 | "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], |
| 22 | 23 | |
| 24 | + "boolbase": ["[email protected]", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], | |
| 25 | + | |
| 23 | 26 | "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], |
| 24 | 27 | |
| 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 | + | |
| 25 | 42 | "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], |
| 26 | 43 | |
| 44 | + "he": ["[email protected]", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], | |
| 45 | + | |
| 27 | 46 | "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="], |
| 28 | 47 | |
| 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 | + | |
| 29 | 52 | "playwright": ["[email protected]", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], |
| 30 | 53 | |
| 31 | 54 | "playwright-core": ["[email protected]", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], |
content/home.md
+25
−80
| @@ -1,96 +1,41 @@ | ||
| 1 | -# tdd.md | |
| 1 | +# SAMA | |
| 2 | 2 | |
| 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. | |
| 4 | 4 | |
| 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. | |
| 6 | 6 | |
| 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.** | |
| 10 | 8 | |
| 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 | |
| 12 | 10 | |
| 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. | |
| 14 | 15 | |
| 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. | |
| 16 | 17 | |
| 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 | |
| 18 | 19 | |
| 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 | | |
| 40 | 21 | |---|---|---| |
| 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 | | |
| 68 | 26 | |
| 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. | |
| 75 | 28 | |
| 76 | -## scoring (strict mode) | |
| 29 | +## Why this matters | |
| 77 | 30 | |
| 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. | |
| 87 | 32 | |
| 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. | |
| 89 | 34 | |
| 90 | -## play | |
| 35 | +## See it in practice | |
| 91 | 36 | |
| 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. | |
| 95 | 40 | |
| 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). | |
package.json
+2
−1
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | "e2e:headed": "playwright test --headed" |
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| 13 | - "marked": "^14.1.4" | |
| 13 | + "marked": "^14.1.4", | |
| 14 | + "node-html-parser": "^7.0.1" | |
| 14 | 15 | }, |
| 15 | 16 | "devDependencies": { |
| 16 | 17 | "@playwright/test": "^1.59.1", |
public/style.css
+261
−64
| @@ -509,10 +509,9 @@ main.md table.test-stability td.test-stab-num { | ||
| 509 | 509 | .project-form-error strong { color: var(--red); } |
| 510 | 510 | |
| 511 | 511 | /* ----------------------------------------------------------------- |
| 512 | - Docs layout — GitBook-style sidebar + content + on-this-page rail. | |
| 512 | + Docs layout — content + on-this-page rail. | |
| 513 | 513 | 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. | |
| 516 | 515 | ----------------------------------------------------------------- */ |
| 517 | 516 | |
| 518 | 517 | .docs-body main.md { |
| @@ -522,15 +521,14 @@ main.md table.test-stability td.test-stab-num { | ||
| 522 | 521 | |
| 523 | 522 | .docs-layout { |
| 524 | 523 | display: grid; |
| 525 | - grid-template-columns: 240px minmax(0, 1fr) 220px; | |
| 524 | + grid-template-columns: minmax(0, 1fr) 220px; | |
| 526 | 525 | gap: 2rem; |
| 527 | - max-width: 1400px; | |
| 526 | + max-width: 1100px; | |
| 528 | 527 | margin: 0 auto; |
| 529 | 528 | padding: 1rem 1.5rem 4rem; |
| 530 | 529 | align-items: start; |
| 531 | 530 | } |
| 532 | 531 | |
| 533 | -.docs-sidebar, | |
| 534 | 532 | .docs-rail { |
| 535 | 533 | position: sticky; |
| 536 | 534 | top: 1rem; |
| @@ -540,49 +538,6 @@ main.md table.test-stability td.test-stab-num { | ||
| 540 | 538 | overflow-y: auto; |
| 541 | 539 | } |
| 542 | 540 | |
| 543 | -.docs-sidebar { padding-right: 0.5rem; } | |
| 544 | - | |
| 545 | -.docs-side-section { margin: 0 0 1.5rem; } | |
| 546 | -.docs-side-title { | |
| 547 | - margin: 0 0 0.4rem; | |
| 548 | - font-size: 0.75rem; | |
| 549 | - text-transform: uppercase; | |
| 550 | - letter-spacing: 0.06em; | |
| 551 | - color: var(--muted); | |
| 552 | -} | |
| 553 | -.docs-side-title a { | |
| 554 | - color: inherit; | |
| 555 | - text-decoration: none; | |
| 556 | -} | |
| 557 | -.docs-side-title a:hover { color: var(--fg); } | |
| 558 | - | |
| 559 | -.docs-side-list { | |
| 560 | - list-style: none; | |
| 561 | - padding: 0; | |
| 562 | - margin: 0; | |
| 563 | - border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); | |
| 564 | -} | |
| 565 | -.docs-side-list li { margin: 0; } | |
| 566 | - | |
| 567 | -.docs-side-link { | |
| 568 | - display: block; | |
| 569 | - padding: 0.3rem 0.6rem; | |
| 570 | - margin-left: -1px; | |
| 571 | - border-left: 2px solid transparent; | |
| 572 | - color: var(--muted); | |
| 573 | - text-decoration: none; | |
| 574 | - line-height: 1.35; | |
| 575 | -} | |
| 576 | -.docs-side-link:hover { | |
| 577 | - color: var(--fg); | |
| 578 | - border-left-color: color-mix(in srgb, var(--fg) 40%, transparent); | |
| 579 | -} | |
| 580 | -.docs-side-link-active { | |
| 581 | - color: var(--accent); | |
| 582 | - border-left-color: var(--accent); | |
| 583 | - font-weight: 600; | |
| 584 | -} | |
| 585 | - | |
| 586 | 541 | .docs-content { |
| 587 | 542 | min-width: 0; |
| 588 | 543 | font-size: 1rem; |
| @@ -680,24 +635,10 @@ main.md table.test-stability td.test-stab-num { | ||
| 680 | 635 | .docs-pn-spacer {} |
| 681 | 636 | |
| 682 | 637 | @media (max-width: 1080px) { |
| 683 | - .docs-layout { | |
| 684 | - grid-template-columns: 220px minmax(0, 1fr); | |
| 685 | - } | |
| 686 | - .docs-rail { display: none; } | |
| 687 | -} | |
| 688 | - | |
| 689 | -@media (max-width: 768px) { | |
| 690 | 638 | .docs-layout { |
| 691 | 639 | grid-template-columns: 1fr; |
| 692 | 640 | } |
| 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; } | |
| 701 | 642 | } |
| 702 | 643 | |
| 703 | 644 | /* ----------------------------------------------------------------- |
| @@ -1005,3 +946,259 @@ main.md table.test-stability td.test-stab-num { | ||
| 1005 | 946 | .commit-meta { grid-template-columns: 1fr; gap: 0.1rem; } |
| 1006 | 947 | .commit-meta dt { margin-top: 0.4rem; } |
| 1007 | 948 | } |
| 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); } | |
src/c13_database.ts
+166
−0
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import { Database } from "bun:sqlite"; |
| 2 | 2 | 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"; | |
| 3 | 5 | |
| 4 | 6 | const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:"; |
| 5 | 7 | |
| @@ -35,6 +37,23 @@ const getDb = (): Database => { | ||
| 35 | 37 | ); |
| 36 | 38 | CREATE INDEX IF NOT EXISTS idx_projects_registered_by |
| 37 | 39 | 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); | |
| 38 | 57 | `); |
| 39 | 58 | |
| 40 | 59 | // Note: a `proposals` table existed in earlier versions of this CMS |
| @@ -204,6 +223,153 @@ export const listActiveProjects = (): ProjectRow[] => { | ||
| 204 | 223 | return rows.map(rowToProject); |
| 205 | 224 | }; |
| 206 | 225 | |
| 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 | + | |
| 207 | 373 | // Latest verdict per (owner, repo) across all agents — drives the |
| 208 | 374 | // leaderboard and the /agents index. |
| 209 | 375 | export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { |
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)); | |
src/c14_git.ts
+37
−0
| @@ -101,6 +101,43 @@ export const readBlob = async (sha: string): Promise<string> => { | ||
| 101 | 101 | return await runGitOk(["cat-file", "-p", sha]); |
| 102 | 102 | }; |
| 103 | 103 | |
| 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 | + | |
| 104 | 141 | // Detail for a single commit (one parsed GitCommit). Returns null on |
| 105 | 142 | // missing — same shape as c14_forgejo.getCommitDetail used to expose. |
| 106 | 143 | export const getCommit = async (sha: string): Promise<GitCommit | null> => { |
src/c21_app.ts
+91
−3
| @@ -58,18 +58,30 @@ import { | ||
| 58 | 58 | samaSlugHandler, |
| 59 | 59 | } from "./c21_handlers_sama.ts"; |
| 60 | 60 | 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"; | |
| 61 | 69 | import { rawSourceHandler } from "./c21_handlers_source.ts"; |
| 62 | 70 | import { commitViewHandler } from "./c21_handlers_commit_view.ts"; |
| 71 | +import { | |
| 72 | + parseRepoBrowsePath, | |
| 73 | + repoBrowseHandler, | |
| 74 | +} from "./c21_handlers_repo_browse.ts"; | |
| 63 | 75 | |
| 64 | 76 | const HOME_MD = "./content/home.md"; |
| 65 | 77 | const GAME_DIR = "./content/games"; |
| 66 | 78 | |
| 67 | 79 | 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."; | |
| 69 | 81 | |
| 70 | 82 | const homeBody = await Bun.file(HOME_MD).text(); |
| 71 | 83 | const HOME_HTML = await renderPage({ |
| 72 | - title: "tdd.md — TDD for agentic coding", | |
| 84 | + title: "SAMA — the architectural standard for AI-agent codebases", | |
| 73 | 85 | description: HOME_DESCRIPTION, |
| 74 | 86 | bodyMarkdown: homeBody, |
| 75 | 87 | active: "home", |
| @@ -173,10 +185,42 @@ const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => { | ||
| 173 | 185 | }; |
| 174 | 186 | |
| 175 | 187 | // 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. | |
| 177 | 190 | const appFetch = async (req: Request): Promise<Response> => { |
| 178 | 191 | const url = new URL(req.url); |
| 179 | 192 | |
| 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 | + | |
| 180 | 224 | // Bare /<owner>/<repo>.git (no sub-path) is what someone gets when |
| 181 | 225 | // they paste the clone URL into a browser. Without intervention our |
| 182 | 226 | // proxy hands it to Forgejo, which renders its own repo page — |
| @@ -194,6 +238,26 @@ const appFetch = async (req: Request): Promise<Response> => { | ||
| 194 | 238 | }); |
| 195 | 239 | } |
| 196 | 240 | |
| 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 | + | |
| 197 | 261 | // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo. |
| 198 | 262 | if (isGitProtocol(url.pathname, url.searchParams)) { |
| 199 | 263 | return proxyToForgejo(req, url.pathname + url.search); |
| @@ -646,6 +710,30 @@ ${rows} | ||
| 646 | 710 | |
| 647 | 711 | "/edit/:section/:slug": editPageHandler, |
| 648 | 712 | |
| 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 | + | |
| 649 | 737 | // Raw markdown source — replaces the previous git.tdd.md "view source" |
| 650 | 738 | // link so docs pages don't depend on the Forgejo subdomain. The |
| 651 | 739 | // route uses `:filename` (with trailing `.md` validated in the |
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 | +}; | |
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 | +}; | |
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 | +}; | |
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 | +}); | |
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 | +}; | |
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 | +}); | |
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><p></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 & 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 | +}); | |
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(/&/g, "&") | |
| 323 | + .replace(/</g, "<") | |
| 324 | + .replace(/>/g, ">") | |
| 325 | + .replace(/"/g, '"') | |
| 326 | + .replace(/'/g, "'") | |
| 327 | + .replace(/ /g, " "); | |
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 | + }); | |
src/c51_render_commit.ts
+1
−0
| @@ -123,5 +123,6 @@ export const renderCommitView = async (params: { | ||
| 123 | 123 | description: `Commit ${shortSha(detail.sha)} on ${owner}/${repo}: ${subject}`, |
| 124 | 124 | noindex: true, |
| 125 | 125 | bodyClass: "commit-body-page", |
| 126 | + hideNav: true, | |
| 126 | 127 | }); |
| 127 | 128 | }; |
src/c51_render_docs_layout.ts
+6
−32
| @@ -1,16 +1,13 @@ | ||
| 1 | 1 | // c51 (docs-layout) — UI: GitBook-style chrome around the existing |
| 2 | -// renderPage. Wraps content with a left sidebar (sections from | |
| 3 | -// SITE_NAV), a right "on this page" anchor rail (h2/h3 from the | |
| 4 | -// rendered body), an edit-on-GitHub link at the top of content, and | |
| 5 | -// a prev/next navigator at the bottom. Per SAMA: imports c31 (data), | |
| 6 | -// c32 (logic), and c51_render_layout (chrome). No I/O of its own. | |
| 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. | |
| 7 | 7 | |
| 8 | 8 | import { marked } from "marked"; |
| 9 | 9 | import { |
| 10 | - SITE_NAV, | |
| 11 | 10 | resolveDocsLocation, |
| 12 | - type DocsNavLink, | |
| 13 | - type DocsNavSection, | |
| 14 | 11 | type ResolvedDocsLocation, |
| 15 | 12 | } from "./c31_docs_nav.ts"; |
| 16 | 13 | import { extractAnchors, type Anchor } from "./c32_anchor_extract.ts"; |
| @@ -22,7 +19,7 @@ import { | ||
| 22 | 19 | |
| 23 | 20 | export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> { |
| 24 | 21 | // 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. | |
| 26 | 23 | pathForDocs: string; |
| 27 | 24 | // Optional override of which file the "edit on GitHub" link |
| 28 | 25 | // targets, when the body isn't a content/<section>/<slug>.md. |
| @@ -30,27 +27,6 @@ export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> { | ||
| 30 | 27 | editPathOverride?: string | null; |
| 31 | 28 | } |
| 32 | 29 | |
| 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 | - | |
| 54 | 30 | const renderAnchorRail = (anchors: Anchor[]): string => { |
| 55 | 31 | if (anchors.length === 0) return ""; |
| 56 | 32 | const items = anchors |
| @@ -121,13 +97,11 @@ export const renderDocsPage = async (opts: DocsPageOptions): Promise<string> => | ||
| 121 | 97 | const loc = resolveDocsLocation(opts.pathForDocs); |
| 122 | 98 | const editPath = opts.editPathOverride !== undefined ? opts.editPathOverride : loc?.current.editPath ?? null; |
| 123 | 99 | |
| 124 | - const sidebar = renderSidebar(opts.pathForDocs); | |
| 125 | 100 | const rail = renderAnchorRail(anchors); |
| 126 | 101 | const editLink = renderEditLink(editPath); |
| 127 | 102 | const prevNext = renderPrevNext(loc); |
| 128 | 103 | |
| 129 | 104 | const composed = `<div class="docs-layout"> |
| 130 | -${sidebar} | |
| 131 | 105 | <article class="docs-content"> |
| 132 | 106 | ${editLink} |
| 133 | 107 | ${enriched} |
src/c51_render_layout.ts
+7
−2
| @@ -27,9 +27,14 @@ export interface PageOptions { | ||
| 27 | 27 | noindex?: boolean; |
| 28 | 28 | jsonLd?: Record<string, unknown>; |
| 29 | 29 | 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; | |
| 30 | 35 | } |
| 31 | 36 | |
| 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."; | |
| 33 | 38 | |
| 34 | 39 | export const escape = (s: string): string => |
| 35 | 40 | s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); |
| @@ -75,7 +80,7 @@ ${robots}<link rel="canonical" href="${escape(ogPath)}"> | ||
| 75 | 80 | ${jsonLd}<style>${css}</style> |
| 76 | 81 | </head> |
| 77 | 82 | <body${bodyClassAttr}> |
| 78 | -${nav(opts.active)} | |
| 83 | +${opts.hideNav ? "" : nav(opts.active)} | |
| 79 | 84 | <main class="md"> |
| 80 | 85 | ${body} |
| 81 | 86 | </main> |
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 | + | |
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("<p>"); | |
| 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"<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 | +}); | |
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, """)}"`) | |
| 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, "&") | |
| 124 | + .replace(/</g, "<") | |
| 125 | + .replace(/>/g, ">"); | |
| 126 | + | |
| 127 | +const escAttr = (s: string): string => | |
| 128 | + s | |
| 129 | + .replace(/&/g, "&") | |
| 130 | + .replace(/</g, "<") | |
| 131 | + .replace(/>/g, ">") | |
| 132 | + .replace(/"/g, """); | |
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 | +} | |
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, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 391 | + | |
| 392 | +const escAttr = (s: string): string => | |
| 393 | + s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
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 | +}; | |