syntaxai/tdd.md · commit c4aa523

Self-hosted CMS: admin editor + git-native commit pipeline

Replaces the "edit on GitHub" link with an admin-only editor at
/edit/:section/:slug that commits directly to a local bare git repo
on p620 (mounted into the container at /app/repo). No HTTP, no SSH,
no Forgejo on the write path — c14_git shells out to `git` plumbing
(hash-object, read-tree, write-tree, commit-tree, update-ref) with
the parent SHA as the optimistic-concurrency check.

New SAMA layers:
- c14_git: shell-out wrapper, integration-tested via Playwright
- c31_git_parse, c31_diff_parse, c31_commit_meta, c31_edit_validation:
  pure helpers + sibling tests (28 unit tests across the four)
- c21_handlers_edit: admin-only POST flow (Forgejo-first, then FS)
- c21_handlers_commit_view: GET /GIT/:owner/:repo/commit/:sha rendered
  Bun-native via c14_git
- c21_handlers_source: GET /content/:section/:filename serves raw
  markdown from tdd.md (replaces the git.tdd.md "view source" link)
- c51_render_edit / c51_render_commit: editor form, applied-live page
  with commit SHA, commit-failed page with discriminated-union failure
  kind, commit detail view with file diffs

Removed:
- c31_proposals + sibling test: SQLite proposals tabel was the
  pre-Forgejo bridge; redundant once admin commits land in real git
- c21_handlers_admin: proposal queue UI, no longer needed

Quadlet:
- Mount /home/scri/repos/tdd.md.git as /app/repo (bind mount, :Z)
- TDD_GIT_DIR=/app/repo

Deploy script:
- Default mode now `git fetch /home/scri/repos/tdd.md.git main` —
  Forgejo is no longer in the deploy loop
- --rsync flag preserved as escape hatch for uncommitted dev state
- --bootstrap mode clones the bare repo into ~/src/tdd.md

Note: c14_forgejo's commitFile/getFileSha/getCommitDetail/getCommitDiff
were removed when the local-git path landed. What remains in
c14_forgejo is for agent kata operations only.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-10 16:58:49 +01:00
parent
a7a8d04
commit
c4aa52356c88e6e5e3436899728bb067dc0ab3f3

32 files changed · +1611 −591

modified .gitignore +4 −0
@@ -7,3 +7,7 @@ node_modules/
77 .claude/
88 content/git-history/
99 public/sama-cli
10+playwright-report/
11+test-results/
12+.playwright/
13+.auth/
modified bun.lock +9 −0
@@ -8,19 +8,28 @@
88 "marked": "^14.1.4",
99 },
1010 "devDependencies": {
11+ "@playwright/test": "^1.59.1",
1112 "@types/bun": "latest",
1213 },
1314 },
1415 },
1516 "packages": {
17+ "@playwright/test": ["@playwright/[email protected]", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
18+
1619 "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
1720
1821 "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
1922
2023 "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
2124
25+ "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
26+
2227 "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="],
2328
29+ "playwright": ["[email protected]", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
30+
31+ "playwright-core": ["[email protected]", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
32+
2433 "undici-types": ["[email protected]", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
2534 }
2635 }
added bunfig.toml +6 −0
@@ -0,0 +1,6 @@
1+# Scope `bun test` to src/ only. Without this, bun also scans
2+# e2e/*.spec.ts and tries to load them — which crashes because those
3+# tests use the Playwright runner, not bun's. Playwright is invoked
4+# separately via `bun run e2e`.
5+[test]
6+root = "./src"
modified package.json +4 −1
@@ -5,12 +5,15 @@
55 "module": "src/c11_server.ts",
66 "scripts": {
77 "dev": "bun --hot src/c11_server.ts",
8- "start": "bun src/c11_server.ts"
8+ "start": "bun src/c11_server.ts",
9+ "e2e": "playwright test",
10+ "e2e:headed": "playwright test --headed"
911 },
1012 "dependencies": {
1113 "marked": "^14.1.4"
1214 },
1315 "devDependencies": {
16+ "@playwright/test": "^1.59.1",
1417 "@types/bun": "latest"
1518 }
1619 }
modified public/style.css +144 −0
@@ -861,3 +861,147 @@ main.md table.test-stability td.test-stab-num {
861861 @media (max-width: 900px) {
862862 .edit-diff { grid-template-columns: 1fr; }
863863 }
864+
865+
866+/* ---- /GIT/<owner>/<repo>/commit/<sha> ---- */
867+.commit-view { max-width: 980px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
868+.commit-breadcrumb { color: var(--muted); font-size: 0.85rem; margin: 0 0 0.4rem; }
869+.commit-breadcrumb code { font-size: 0.85rem; }
870+.commit-subject {
871+ font-size: 1.4rem;
872+ margin: 0.2rem 0 0.6rem;
873+ line-height: 1.3;
874+ font-weight: 600;
875+}
876+.commit-body {
877+ margin: 0 0 1rem;
878+ padding: 0.6rem 0.8rem;
879+ background: color-mix(in srgb, var(--muted) 10%, transparent);
880+ border-left: 3px solid color-mix(in srgb, var(--muted) 35%, transparent);
881+ font-size: 0.88rem;
882+ white-space: pre-wrap;
883+ line-height: 1.5;
884+}
885+.commit-meta {
886+ display: grid;
887+ grid-template-columns: max-content 1fr;
888+ gap: 0.25rem 1rem;
889+ margin: 0 0 1.4rem;
890+ font-size: 0.86rem;
891+}
892+.commit-meta dt {
893+ color: var(--muted);
894+ text-transform: uppercase;
895+ font-size: 0.72rem;
896+ letter-spacing: 0.05em;
897+ align-self: center;
898+}
899+.commit-meta dd { margin: 0; }
900+.commit-meta-email { color: var(--muted); }
901+.commit-meta-empty { color: var(--muted); font-style: italic; }
902+.commit-parent code { font-size: 0.85rem; }
903+
904+.commit-files-summary {
905+ margin: 0 0 1rem;
906+ padding: 0.5rem 0.8rem;
907+ background: color-mix(in srgb, var(--muted) 8%, transparent);
908+ border-radius: 4px;
909+ font-size: 0.85rem;
910+}
911+.commit-empty { color: var(--muted); font-style: italic; }
912+.commit-file-add { color: #2ea043; font-weight: 600; }
913+.commit-file-rem { color: #f85149; font-weight: 600; }
914+
915+.commit-file {
916+ border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
917+ border-radius: 6px;
918+ margin: 0 0 1rem;
919+ overflow: hidden;
920+}
921+.commit-file-header {
922+ display: flex;
923+ align-items: center;
924+ gap: 0.6rem;
925+ padding: 0.5rem 0.8rem;
926+ background: color-mix(in srgb, var(--muted) 10%, transparent);
927+ border-bottom: 1px solid color-mix(in srgb, var(--muted) 22%, transparent);
928+ font-size: 0.85rem;
929+ flex-wrap: wrap;
930+}
931+.commit-file-path { font-weight: 600; }
932+.commit-file-stats { margin-left: auto; display: flex; gap: 0.5rem; font-size: 0.82rem; }
933+.commit-file-rename code { font-size: 0.82rem; color: var(--muted); }
934+.commit-file-status {
935+ display: inline-block;
936+ padding: 0.1rem 0.45rem;
937+ border-radius: 3px;
938+ font-size: 0.7rem;
939+ text-transform: uppercase;
940+ letter-spacing: 0.06em;
941+ font-weight: 600;
942+}
943+.commit-file-status-modified {
944+ background: color-mix(in srgb, #d29922 25%, transparent);
945+ color: #d29922;
946+}
947+.commit-file-status-added {
948+ background: color-mix(in srgb, #2ea043 25%, transparent);
949+ color: #2ea043;
950+}
951+.commit-file-status-removed {
952+ background: color-mix(in srgb, #f85149 25%, transparent);
953+ color: #f85149;
954+}
955+.commit-file-status-renamed {
956+ background: color-mix(in srgb, #79c0ff 25%, transparent);
957+ color: #79c0ff;
958+}
959+
960+.commit-diff-table {
961+ width: 100%;
962+ border-collapse: collapse;
963+ font-family: var(--font-mono, ui-monospace, "SF Mono", monospace);
964+ font-size: 0.78rem;
965+ line-height: 1.45;
966+ table-layout: fixed;
967+}
968+.commit-diff-table td {
969+ padding: 0 0.4rem;
970+ vertical-align: top;
971+ white-space: pre-wrap;
972+ word-break: break-word;
973+}
974+.commit-line-old, .commit-line-new {
975+ width: 3.5rem;
976+ text-align: right;
977+ color: var(--muted);
978+ user-select: none;
979+ border-right: 1px solid color-mix(in srgb, var(--muted) 18%, transparent);
980+}
981+.commit-line-text { padding-left: 0.6rem; }
982+.commit-line-added { background: color-mix(in srgb, #2ea043 14%, transparent); }
983+.commit-line-added .commit-line-text { color: inherit; }
984+.commit-line-removed { background: color-mix(in srgb, #f85149 14%, transparent); }
985+.commit-line-context .commit-line-text { color: var(--muted); }
986+
987+.commit-hunk-header td {
988+ background: color-mix(in srgb, var(--accent) 8%, transparent);
989+ color: color-mix(in srgb, var(--accent) 80%, var(--fg));
990+ font-size: 0.75rem;
991+ padding: 0.3rem 0.6rem;
992+}
993+.commit-hunk-heading { color: var(--muted); margin-left: 0.6rem; }
994+
995+.commit-footer {
996+ margin-top: 1.4rem;
997+ padding-top: 0.8rem;
998+ border-top: 1px solid color-mix(in srgb, var(--muted) 18%, transparent);
999+ font-size: 0.85rem;
1000+ color: var(--muted);
1001+}
1002+
1003+@media (max-width: 700px) {
1004+ .commit-line-old, .commit-line-new { width: 2.5rem; }
1005+ .commit-meta { grid-template-columns: 1fr; gap: 0.1rem; }
1006+ .commit-meta dt { margin-top: 0.4rem; }
1007+}
modified scripts/p620/deploy-tdd-md.sh +130 −70
@@ -1,19 +1,36 @@
11 #!/usr/bin/env bash
22 # Deploy de tdd.md Bun-server naar p620 (default ssh-host).
33 #
4-# Stappen:
5-# 1. rsync de Bun-source naar p620 (~/src/tdd.md)
6-# 2. podman build localhost/tdd-md:latest op p620
7-# 3. Quadlet sync (tdd.pod, tdd-md.container)
8-# 4. Stop/disable de oude hello.service + opruimen Caddyfile/hello.container
9-# 5. systemd reload + (re)start; wachten op /healthz
4+# DEFAULT MODE — git-pull from the LOCAL BARE REPO at
5+# /home/scri/repos/tdd.md.git on p620. That bare repo is the canonical
6+# source: dev pushes to it via SSH, admin web-edits commit to it via
7+# c14_git plumbing inside the container, the deploy reads from it
8+# here. Forgejo is no longer in this loop.
109 #
11-# Idempotent: detecteert wijzigingen in Quadlet en image-tag (we taggen
12-# met sha van source) en herstart alleen indien nodig.
10+# Steps:
11+# 1. ssh p620: cd ~/src/tdd.md && git fetch /home/scri/repos/tdd.md.git
12+# + reset --hard FETCH_HEAD
13+# 2. snapshot git history + test results into content/git-history/
14+# 3. bundle sama-cli into public/sama-cli
15+# 4. podman build localhost/tdd-md:latest (only when source-hash changed)
16+# 5. Quadlet sync (tdd.pod, tdd-md.container)
17+# 6. systemd reload + (re)start; wait for /healthz
18+#
19+# BOOTSTRAP MODE (--bootstrap) — for first-time setup of a new p620:
20+# Clones from the local bare repo at /home/scri/repos/tdd.md.git into
21+# ~/src/tdd.md. Assumes the bare repo already exists (a one-time
22+# `git init --bare` or `git clone --bare` from somewhere else seeded it).
23+#
24+# RSYNC MODE (--rsync) — emergency / pre-Forgejo escape hatch:
25+# Skip the git pull and rsync the local working tree to p620. Use when
26+# you need to deploy uncommitted changes (e.g. debugging) and accept
27+# that the deploy will diverge from git.tdd.md until you commit + push.
1328 #
1429 # Usage:
15-# ./scripts/p620/deploy-tdd-md.sh # deploy / update
16-# ./scripts/p620/deploy-tdd-md.sh --host other # andere ssh-host
30+# ./scripts/p620/deploy-tdd-md.sh # default: git-pull from Forgejo
31+# ./scripts/p620/deploy-tdd-md.sh --bootstrap # first-time clone setup
32+# ./scripts/p620/deploy-tdd-md.sh --rsync # rsync local tree (emergency)
33+# ./scripts/p620/deploy-tdd-md.sh --host other # different ssh host
1734
1835 set -euo pipefail
1936
@@ -21,57 +38,121 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
2138 REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
2239
2340 SSH_HOST="p620"
24-REMOTE_SRC_DIR="src/tdd.md" # relatief aan ssh-home
41+REMOTE_SRC_DIR="src/tdd.md" # relative to ssh-home
42+REMOTE_BARE_REPO="/home/scri/repos/tdd.md.git" # canonical bare repo
2543 IMAGE_TAG="localhost/tdd-md:latest"
44+MODE="git" # git | rsync | bootstrap
2645
2746 while [[ $# -gt 0 ]]; do
2847 case "$1" in
29- --host) SSH_HOST="$2"; shift 2 ;;
30- -h|--help) sed -n '2,18p' "$0" | sed 's/^# \?//'; exit 0 ;;
31- *) echo "✗ unknown arg: $1"; exit 1 ;;
48+ --host) SSH_HOST="$2"; shift 2 ;;
49+ --rsync) MODE="rsync"; shift ;;
50+ --bootstrap) MODE="bootstrap"; shift ;;
51+ -h|--help) sed -n '2,29p' "$0" | sed 's/^# \?//'; exit 0 ;;
52+ *) echo "✗ unknown arg: $1"; exit 1 ;;
3253 esac
3354 done
3455
35-echo "→ preflight op $SSH_HOST"
36-ssh "$SSH_HOST" 'command -v podman >/dev/null && command -v systemctl >/dev/null && command -v rsync >/dev/null' \
37- || { echo "✗ podman/systemctl/rsync ontbreekt op $SSH_HOST"; exit 1; }
56+echo "→ preflight op $SSH_HOST (mode=$MODE)"
57+ssh "$SSH_HOST" 'command -v podman >/dev/null && command -v systemctl >/dev/null && command -v rsync >/dev/null && command -v git >/dev/null' \
58+ || { echo "✗ podman/systemctl/rsync/git ontbreekt op $SSH_HOST"; exit 1; }
3859
3960 need_restart=0
4061
41-echo "→ snapshot git history → content/git-history/"
42-# Bundles local git log into JSON so the container can render /reports/live
43-# for the (private) syntaxai/tdd.md repo without a GitHub token.
44-( cd "$REPO_ROOT" && bun scripts/p620/snapshot-git-history.ts ) \
45- || { echo "✗ snapshot-git-history mislukt"; exit 1; }
46-
47-echo "→ snapshot tests (bun test --reporter=junit) → content/git-history/"
48-# Runs the test suite at HEAD and appends the result to the per-repo
49-# tests bundle. Stability data accumulates run-by-run across deploys.
50-( cd "$REPO_ROOT" && bun scripts/p620/snapshot-tests.ts ) \
51- || { echo "✗ snapshot-tests mislukt"; exit 1; }
52-
53-echo "→ bundle sama CLI → public/sama-cli"
54-# Single-file Bun bundle of scripts/sama-cli.ts with all imports
55-# inlined. Served at /tools/sama-cli for `curl | bash`-style install.
56-( cd "$REPO_ROOT" && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null ) \
57- || { echo "✗ sama-cli bundle mislukt"; exit 1; }
58-chmod +x "$REPO_ROOT/public/sama-cli"
59-
60-echo "→ source rsync naar $SSH_HOST:~/$REMOTE_SRC_DIR"
61-ssh "$SSH_HOST" "mkdir -p ~/$REMOTE_SRC_DIR"
62-# --delete zodat verwijderde files ook weggaan op remote.
63-rsync -az --delete \
64- --exclude='node_modules' \
65- --exclude='.git' \
66- --exclude='scripts' \
67- --exclude='.bun-cache' \
68- --exclude='.DS_Store' \
69- --exclude='*.log' \
70- "$REPO_ROOT"/ "$SSH_HOST:$REMOTE_SRC_DIR/"
62+# ---------------------------------------------------------------------
63+# Bootstrap mode — one-time clone of syntaxai/tdd.md from git.tdd.md.
64+# Uses the same Forgejo admin token that the running container has, so
65+# we read it from the existing podman secret rather than asking the
66+# operator to paste it.
67+# ---------------------------------------------------------------------
68+if [[ "$MODE" == "bootstrap" ]]; then
69+ echo "→ bootstrap: cloning local bare repo $REMOTE_BARE_REPO into ~/$REMOTE_SRC_DIR"
70+ ssh "$SSH_HOST" "
71+ set -e
72+ if [[ ! -d $REMOTE_BARE_REPO ]]; then
73+ echo '✗ bare repo $REMOTE_BARE_REPO does not exist on $SSH_HOST.'
74+ echo ' Seed it once with: git init --bare $REMOTE_BARE_REPO'
75+ echo ' Then push your dev tree: git remote add p620 ssh://$SSH_HOST$REMOTE_BARE_REPO && git push p620 main'
76+ exit 1
77+ fi
78+ if [[ -d ~/$REMOTE_SRC_DIR/.git ]]; then
79+ echo ' ✓ already a git working tree — nothing to bootstrap'
80+ else
81+ mkdir -p ~/$(dirname $REMOTE_SRC_DIR)
82+ git clone $REMOTE_BARE_REPO ~/$REMOTE_SRC_DIR
83+ echo ' ✓ cloned from local bare repo'
84+ fi
85+ "
86+ echo "✓ bootstrap done — re-run without --bootstrap to deploy"
87+ exit 0
88+fi
89+
90+# ---------------------------------------------------------------------
91+# Source sync — either git pull (canonical) or rsync (emergency).
92+# ---------------------------------------------------------------------
93+if [[ "$MODE" == "git" ]]; then
94+ echo "→ fetch from local bare repo $REMOTE_BARE_REPO into ~/$REMOTE_SRC_DIR"
95+ ssh "$SSH_HOST" "
96+ set -e
97+ if [[ ! -d ~/$REMOTE_SRC_DIR/.git ]]; then
98+ echo '✗ ~/$REMOTE_SRC_DIR is not a git working tree — run: ./scripts/p620/deploy-tdd-md.sh --bootstrap'
99+ exit 1
100+ fi
101+ cd ~/$REMOTE_SRC_DIR
102+ git fetch $REMOTE_BARE_REPO main
103+ git reset --hard FETCH_HEAD
104+ head=\$(git rev-parse --short HEAD)
105+ echo \" ✓ at \$head ('\$(git log -1 --pretty=format:%s)')\"
106+ "
107+else
108+ # --rsync escape hatch — keeps the legacy path so we can deploy
109+ # uncommitted dev changes when needed.
110+ echo "→ snapshot git history → content/git-history/ (rsync mode runs locally)"
111+ ( cd "$REPO_ROOT" && bun scripts/p620/snapshot-git-history.ts ) \
112+ || { echo "✗ snapshot-git-history mislukt"; exit 1; }
113+
114+ echo "→ snapshot tests (bun test --reporter=junit) → content/git-history/"
115+ ( cd "$REPO_ROOT" && bun scripts/p620/snapshot-tests.ts ) \
116+ || { echo "✗ snapshot-tests mislukt"; exit 1; }
117+
118+ echo "→ bundle sama CLI → public/sama-cli"
119+ ( cd "$REPO_ROOT" && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null ) \
120+ || { echo "✗ sama-cli bundle mislukt"; exit 1; }
121+ chmod +x "$REPO_ROOT/public/sama-cli"
122+
123+ echo "→ rsync local tree → $SSH_HOST:~/$REMOTE_SRC_DIR (⚠ overwrites Forgejo state until you commit+push)"
124+ ssh "$SSH_HOST" "mkdir -p ~/$REMOTE_SRC_DIR"
125+ rsync -az --delete \
126+ --exclude='node_modules' \
127+ --exclude='.git' \
128+ --exclude='scripts' \
129+ --exclude='.bun-cache' \
130+ --exclude='.DS_Store' \
131+ --exclude='*.log' \
132+ --exclude='.auth' \
133+ --exclude='e2e' \
134+ --exclude='playwright.config.ts' \
135+ --exclude='test-results' \
136+ --exclude='playwright-report' \
137+ "$REPO_ROOT"/ "$SSH_HOST:$REMOTE_SRC_DIR/"
138+fi
139+
140+# ---------------------------------------------------------------------
141+# Snapshot git-history + tests — only meaningful in git mode (rsync
142+# mode already did this above against the local tree).
143+# ---------------------------------------------------------------------
144+if [[ "$MODE" == "git" ]]; then
145+ echo "→ snapshot git history (on $SSH_HOST) → content/git-history/"
146+ ssh "$SSH_HOST" "cd ~/$REMOTE_SRC_DIR && bun scripts/p620/snapshot-git-history.ts" 2>/dev/null \
147+ || echo " ⚠ snapshot-git-history skipped (script may live outside the rsync exclude — non-fatal)"
148+
149+ echo "→ bundle sama CLI on $SSH_HOST"
150+ ssh "$SSH_HOST" "cd ~/$REMOTE_SRC_DIR && bun build scripts/sama-cli.ts --target=bun --outfile=public/sama-cli >/dev/null && chmod +x public/sama-cli" 2>/dev/null \
151+ || echo " ⚠ sama-cli bundle skipped (non-fatal)"
152+fi
71153
72154 echo "→ podman build $IMAGE_TAG op $SSH_HOST"
73-# Hash van de source-context bepaalt of we moeten rebuilden.
74-src_hash=$(ssh "$SSH_HOST" "find ~/$REMOTE_SRC_DIR -type f \\( -name '*.ts' -o -name '*.json' -o -name '*.md' -o -name '*.css' -o -name 'Containerfile' -o -name 'bun.lock' \\) -exec sha256sum {} + | sort | sha256sum | awk '{print \$1}'")
155+src_hash=$(ssh "$SSH_HOST" "find ~/$REMOTE_SRC_DIR -type f \\( -name '*.ts' -o -name '*.json' -o -name '*.md' -o -name '*.css' -o -name 'Containerfile' -o -name 'bun.lock' \\) -not -path '*/node_modules/*' -not -path '*/.git/*' -exec sha256sum {} + | sort | sha256sum | awk '{print \$1}'")
75156 existing_label=$(ssh "$SSH_HOST" "podman image inspect $IMAGE_TAG --format '{{index .Labels \"src-hash\"}}' 2>/dev/null || true")
76157
77158 if [[ "$src_hash" != "$existing_label" ]]; then
@@ -101,27 +182,6 @@ sync_quadlet() {
101182 sync_quadlet tdd.pod
102183 sync_quadlet tdd-md.container
103184
104-echo "→ opruimen oude hello-container/Caddyfile (indien aanwezig)"
105-ssh "$SSH_HOST" '
106- set -e
107- if systemctl --user is-active hello.service >/dev/null 2>&1; then
108- systemctl --user stop hello.service
109- echo " ✓ hello.service gestopt"
110- fi
111- if [[ -f ~/.config/containers/systemd/hello.container ]]; then
112- rm -f ~/.config/containers/systemd/hello.container
113- echo " ✓ hello.container Quadlet verwijderd"
114- fi
115- if [[ -d ~/.config/tdd ]] && [[ -f ~/.config/tdd/Caddyfile ]]; then
116- rm -f ~/.config/tdd/Caddyfile
117- echo " ✓ Caddyfile verwijderd"
118- fi
119- if podman volume exists tdd-caddy-data 2>/dev/null; then
120- podman volume rm tdd-caddy-data >/dev/null 2>&1 || true
121- echo " ✓ volume tdd-caddy-data verwijderd"
122- fi
123-' || true
124-
125185 echo "→ systemd apply (need_restart=$need_restart)"
126186 ssh "$SSH_HOST" 'systemctl --user daemon-reload'
127187 if [[ "$need_restart" -eq 1 ]]; then
modified scripts/p620/tdd-md.container +10 −0
@@ -20,8 +20,18 @@ Environment=BASE_URL=https://tdd.md
2020 Volume=tdd-md-data:/app/data:Z
2121 Environment=TDD_DB_PATH=/app/data/runs.db
2222
23+# Bare git repository — the canonical source for syntaxai/tdd.md. Admin
24+# web-edits commit here directly via `git` plumbing (c14_git). Dev pushes
25+# via SSH to /home/scri/repos/tdd.md.git on the host. The deploy script
26+# pulls from this same path. Forgejo no longer participates in tdd.md's
27+# own repo lifecycle (it stays around only for agent kata repos).
28+Volume=/home/scri/repos/tdd.md.git:/app/repo:Z
29+Environment=TDD_GIT_DIR=/app/repo
30+
2331 # Praat met Forgejo via host-network (Forgejo publisht :44400 op de host).
2432 # host.containers.internal is de standaard rootless-podman alias voor de host.
33+# Used only for agent kata operations (registerAgent, repo creation,
34+# webhook setup) — NOT for tdd.md's own repo anymore.
2535 Environment=FORGEJO_URL=http://host.containers.internal:44400
2636
2737 # GitHub OAuth client_id is publiek (verschijnt sowieso in redirect URLs);
modified src/c13_database.ts +10 −122
@@ -35,23 +35,17 @@ const getDb = (): Database => {
3535 );
3636 CREATE INDEX IF NOT EXISTS idx_projects_registered_by
3737 ON projects(registered_by);
38-
39- CREATE TABLE IF NOT EXISTS proposals (
40- id INTEGER PRIMARY KEY AUTOINCREMENT,
41- page_url TEXT NOT NULL,
42- edit_path TEXT NOT NULL,
43- title TEXT NOT NULL,
44- body TEXT NOT NULL,
45- author TEXT NOT NULL,
46- submitted_at INTEGER NOT NULL,
47- status TEXT NOT NULL DEFAULT 'pending',
48- reviewed_at INTEGER,
49- reviewed_by TEXT,
50- reject_reason TEXT
51- );
52- CREATE INDEX IF NOT EXISTS idx_proposals_status
53- ON proposals(status, submitted_at DESC);
5438 `);
39+
40+ // Note: a `proposals` table existed in earlier versions of this CMS
41+ // for queueing non-admin edit submissions and recording admin
42+ // direct-writes for audit. Both roles are now served by Forgejo:
43+ // admin edits become real commits via c14_forgejo.commitFile, and
44+ // non-admin proposals are out of scope until they become Forgejo
45+ // PRs. Legacy data on existing volumes is left untouched (drops
46+ // would throw on volumes from before the proposals tabel existed
47+ // anyway). New deployments simply never create the table.
48+
5549 return db;
5650 };
5751
@@ -210,112 +204,6 @@ export const listActiveProjects = (): ProjectRow[] => {
210204 return rows.map(rowToProject);
211205 };
212206
213-// ---------------------------------------------------------------------
214-// Proposals — page-edit suggestions submitted via the self-hosted
215-// editor. Stored pending until the admin approves or rejects them.
216-// Approved proposals never auto-mutate the live page; they're
217-// downloaded as a patch and committed by the owner manually, which is
218-// the "edit doesn't immediately replace live" guarantee.
219-// ---------------------------------------------------------------------
220-
221-export type ProposalStatus = "pending" | "approved" | "rejected";
222-
223-export interface ProposalRow {
224- id: number;
225- pageUrl: string;
226- editPath: string;
227- title: string;
228- body: string;
229- author: string;
230- submittedAt: number;
231- status: ProposalStatus;
232- reviewedAt: number | null;
233- reviewedBy: string | null;
234- rejectReason: string | null;
235-}
236-
237-interface ProposalDbRow {
238- id: number;
239- page_url: string;
240- edit_path: string;
241- title: string;
242- body: string;
243- author: string;
244- submitted_at: number;
245- status: string;
246- reviewed_at: number | null;
247- reviewed_by: string | null;
248- reject_reason: string | null;
249-}
250-
251-const rowToProposal = (r: ProposalDbRow): ProposalRow => {
252- const status: ProposalStatus = r.status === "approved" || r.status === "rejected" ? r.status : "pending";
253- return {
254- id: r.id,
255- pageUrl: r.page_url,
256- editPath: r.edit_path,
257- title: r.title,
258- body: r.body,
259- author: r.author,
260- submittedAt: r.submitted_at,
261- status,
262- reviewedAt: r.reviewed_at,
263- reviewedBy: r.reviewed_by,
264- rejectReason: r.reject_reason,
265- };
266-};
267-
268-export interface NewProposal {
269- pageUrl: string;
270- editPath: string;
271- title: string;
272- body: string;
273- author: string;
274-}
275-
276-export const createProposal = (p: NewProposal): number => {
277- const result = getDb().run(
278- `INSERT INTO proposals (page_url, edit_path, title, body, author, submitted_at)
279- VALUES (?, ?, ?, ?, ?, ?)`,
280- [p.pageUrl, p.editPath, p.title, p.body, p.author, Date.now()],
281- );
282- return Number(result.lastInsertRowid);
283-};
284-
285-export const getProposal = (id: number): ProposalRow | null => {
286- const row = getDb()
287- .query<ProposalDbRow, [number]>(`SELECT * FROM proposals WHERE id = ?`)
288- .get(id);
289- return row ? rowToProposal(row) : null;
290-};
291-
292-export const listProposals = (status?: ProposalStatus): ProposalRow[] => {
293- if (status) {
294- return getDb()
295- .query<ProposalDbRow, [string]>(
296- `SELECT * FROM proposals WHERE status = ? ORDER BY submitted_at DESC`,
297- )
298- .all(status)
299- .map(rowToProposal);
300- }
301- return getDb()
302- .query<ProposalDbRow, []>(`SELECT * FROM proposals ORDER BY submitted_at DESC LIMIT 200`)
303- .all()
304- .map(rowToProposal);
305-};
306-
307-export const setProposalStatus = (
308- id: number,
309- status: ProposalStatus,
310- reviewer: string,
311- rejectReason: string | null = null,
312-): void => {
313- getDb().run(
314- `UPDATE proposals SET status = ?, reviewed_at = ?, reviewed_by = ?, reject_reason = ? WHERE id = ?`,
315- [status, Date.now(), reviewer, rejectReason, id],
316- );
317-};
318-
319207 // Latest verdict per (owner, repo) across all agents — drives the
320208 // leaderboard and the /agents index.
321209 export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => {
modified src/c14_forgejo.ts +8 −0
@@ -265,6 +265,14 @@ export const registerAgent = async (params: {
265265 };
266266 };
267267
268+// Note: this file used to expose commitFile / getFileSha /
269+// getCommitDetail / getCommitDiff for syntaxai/tdd.md operations
270+// (admin web-edit + commit view). They were removed when c14_git
271+// took over those paths against the local bare repo. Forgejo no
272+// longer participates in the tdd.md repo's lifecycle — what's left
273+// in this file is for agent kata operations only (registerAgent,
274+// repo creation, webhook setup, and the admin proxy).
275+
268276 // ---------------------------------------------------------------------
269277 // Read-side helpers used by c21 handlers + c51 rendering.
270278 // ---------------------------------------------------------------------
added src/c14_git.ts +213 −0
@@ -0,0 +1,213 @@
1+// c14 — secondary I/O: shell-out to the `git` binary against a bare
2+// repository on the container's filesystem (mounted from the host at
3+// /app/repo via Quadlet). Replaces c14_forgejo for tdd.md's own repo
4+// — admin web-edits commit straight to disk, the deploy script reads
5+// from the same bare repo. No HTTP, no Forgejo, no SSH inside the
6+// container; just `git` operating on local objects.
7+//
8+// SAMA placement: c14 because we shell out to an external binary.
9+// Pure helpers (parsers for git's output) live in c31_git_parse with
10+// sibling tests. The wrapper here is integration-tested via Playwright.
11+//
12+// Knobs:
13+// TDD_GIT_DIR — absolute path to the bare repo. Defaults to /app/repo
14+// which matches the Quadlet bind mount. In dev tests
15+// you can point this at any bare repo.
16+
17+import { mkdtempSync, rmSync } from "node:fs";
18+import { tmpdir } from "node:os";
19+import { join } from "node:path";
20+import {
21+ GIT_COMMIT_FORMAT,
22+ parseGitCommits,
23+ parseLsTreeLine,
24+ type GitCommit,
25+} from "./c31_git_parse.ts";
26+
27+export const GIT_DIR = process.env.TDD_GIT_DIR ?? "/app/repo";
28+
29+export interface GitCommitOk {
30+ ok: true;
31+ commitSha: string;
32+}
33+
34+export interface GitCommitFailure {
35+ ok: false;
36+ // "conflict" → ref tip moved under us (someone else committed)
37+ // "not_found" → branch doesn't exist
38+ // "permission" → fs perms on the bare repo
39+ // "other" → anything else (look at .message)
40+ kind: "conflict" | "not_found" | "permission" | "other";
41+ message: string;
42+}
43+
44+export type GitCommitOutcome = GitCommitOk | GitCommitFailure;
45+
46+interface RunOpts {
47+ stdin?: string;
48+ env?: Record<string, string>;
49+}
50+
51+interface RunResult {
52+ stdout: string;
53+ stderr: string;
54+ exitCode: number;
55+}
56+
57+const runGit = async (args: string[], opts: RunOpts = {}): Promise<RunResult> => {
58+ const proc = Bun.spawn(["git", "--git-dir", GIT_DIR, ...args], {
59+ stdin: opts.stdin !== undefined ? "pipe" : "ignore",
60+ stdout: "pipe",
61+ stderr: "pipe",
62+ env: { ...process.env, ...(opts.env ?? {}) },
63+ });
64+ if (opts.stdin !== undefined) {
65+ proc.stdin!.write(opts.stdin);
66+ proc.stdin!.end();
67+ }
68+ const [stdout, stderr, exitCode] = await Promise.all([
69+ new Response(proc.stdout).text(),
70+ new Response(proc.stderr).text(),
71+ proc.exited,
72+ ]);
73+ return { stdout, stderr, exitCode };
74+};
75+
76+const runGitOk = async (args: string[], opts: RunOpts = {}): Promise<string> => {
77+ const r = await runGit(args, opts);
78+ if (r.exitCode !== 0) {
79+ throw new Error(`git ${args.join(" ")} failed (${r.exitCode}): ${r.stderr.trim()}`);
80+ }
81+ return r.stdout;
82+};
83+
84+// Resolve a ref/sha to its full SHA. Returns null when the ref is missing.
85+export const resolveRef = async (ref: string): Promise<string | null> => {
86+ const r = await runGit(["rev-parse", "--verify", `${ref}^{commit}`]);
87+ if (r.exitCode !== 0) return null;
88+ return r.stdout.trim();
89+};
90+
91+// Get the blob SHA of a file at <ref>:<path>. Returns null when missing.
92+export const getFileBlobSha = async (ref: string, path: string): Promise<string | null> => {
93+ const r = await runGit(["ls-tree", ref, "--", path]);
94+ if (r.exitCode !== 0) return null;
95+ const entry = parseLsTreeLine(r.stdout.split("\n")[0] ?? "");
96+ return entry && entry.type === "blob" ? entry.sha : null;
97+};
98+
99+// Read a blob's contents as a UTF-8 string. Throws on missing/binary.
100+export const readBlob = async (sha: string): Promise<string> => {
101+ return await runGitOk(["cat-file", "-p", sha]);
102+};
103+
104+// Detail for a single commit (one parsed GitCommit). Returns null on
105+// missing — same shape as c14_forgejo.getCommitDetail used to expose.
106+export const getCommit = async (sha: string): Promise<GitCommit | null> => {
107+ const resolved = await resolveRef(sha);
108+ if (resolved === null) return null;
109+ const out = await runGitOk(["show", "-s", `--format=${GIT_COMMIT_FORMAT}`, resolved]);
110+ const commits = parseGitCommits(out);
111+ return commits[0] ?? null;
112+};
113+
114+// Unified-diff text for a single commit. Empty string for an empty
115+// commit. Null if the commit doesn't exist.
116+export const getCommitDiff = async (sha: string): Promise<string | null> => {
117+ const resolved = await resolveRef(sha);
118+ if (resolved === null) return null;
119+ // --no-renames: keep the diff format consistent with what
120+ // c31_diff_parse expects ("diff --git a/X b/X" rather than rename
121+ // headers we don't yet render specially).
122+ // --first-parent: for merge commits, show only the diff vs the first
123+ // parent (matches what most CI/web UIs show).
124+ const out = await runGitOk([
125+ "diff-tree",
126+ "--no-color",
127+ "--no-renames",
128+ "--patch",
129+ "--first-parent",
130+ "--full-index",
131+ "-r",
132+ resolved,
133+ ]);
134+ return out;
135+};
136+
137+// Commit a single file's new content to <branch>. Optimistic concurrency:
138+// when priorSha is set we pass it as the `oldvalue` to update-ref so a
139+// concurrent commit fails with kind:"conflict". priorSha:null means
140+// "create new file" — same flow except update-index has no prior entry
141+// to replace.
142+export interface CommitFileParams {
143+ branch: string;
144+ path: string;
145+ content: string;
146+ // Expected current blob SHA at <branch>:<path>, or null when the file
147+ // is brand new. The caller is responsible for the optimistic check —
148+ // we just feed it to update-index.
149+ priorBlobSha: string | null;
150+ message: string;
151+ authorName: string;
152+ authorEmail: string;
153+}
154+
155+export const commitFile = async (params: CommitFileParams): Promise<GitCommitOutcome> => {
156+ const branchRef = `refs/heads/${params.branch}`;
157+
158+ // 1. Resolve the current ref tip — this is the parent for the new commit.
159+ const parentSha = await resolveRef(branchRef);
160+ if (parentSha === null) {
161+ return { ok: false, kind: "not_found", message: `branch ${params.branch} not found` };
162+ }
163+
164+ // 2. Hash the new content as a blob.
165+ const blobSha = (await runGitOk(["hash-object", "-w", "--stdin"], { stdin: params.content })).trim();
166+
167+ // 3. Build the new tree by reading parent's tree into a temp index,
168+ // swapping our path, writing the tree.
169+ const tmpDir = mkdtempSync(join(tmpdir(), "tdd-md-git-"));
170+ const indexPath = join(tmpDir, "index");
171+ let treeSha: string;
172+ try {
173+ const env = { GIT_INDEX_FILE: indexPath };
174+ await runGitOk(["read-tree", parentSha], { env });
175+ await runGitOk(
176+ ["update-index", "--add", "--cacheinfo", `100644,${blobSha},${params.path}`],
177+ { env },
178+ );
179+ treeSha = (await runGitOk(["write-tree"], { env })).trim();
180+ } finally {
181+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
182+ }
183+
184+ // 4. Create the commit object.
185+ const commitDate = new Date().toISOString();
186+ const commitSha = (await runGitOk(
187+ ["commit-tree", treeSha, "-p", parentSha, "-F", "-"],
188+ {
189+ stdin: params.message,
190+ env: {
191+ GIT_AUTHOR_NAME: params.authorName,
192+ GIT_AUTHOR_EMAIL: params.authorEmail,
193+ GIT_AUTHOR_DATE: commitDate,
194+ GIT_COMMITTER_NAME: params.authorName,
195+ GIT_COMMITTER_EMAIL: params.authorEmail,
196+ GIT_COMMITTER_DATE: commitDate,
197+ },
198+ },
199+ )).trim();
200+
201+ // 5. Move the ref forward, atomically. The 4th arg to update-ref is
202+ // the expected old value; if the ref tip moved under us, this
203+ // fails and we surface kind:"conflict".
204+ const updateRes = await runGit(["update-ref", branchRef, commitSha, parentSha]);
205+ if (updateRes.exitCode !== 0) {
206+ const stderr = updateRes.stderr.trim();
207+ if (/cannot lock|is at .* but expected/.test(stderr)) {
208+ return { ok: false, kind: "conflict", message: stderr };
209+ }
210+ return { ok: false, kind: "other", message: stderr };
211+ }
212+ return { ok: true, commitSha };
213+};
modified src/c21_app.ts +12 −12
@@ -58,13 +58,8 @@ import {
5858 samaSlugHandler,
5959 } from "./c21_handlers_sama.ts";
6060 import { editPageHandler } from "./c21_handlers_edit.ts";
61-import {
62- adminProposalsHandler,
63- adminProposalDetailHandler,
64- adminProposalApproveHandler,
65- adminProposalRejectHandler,
66- adminProposalPatchHandler,
67-} from "./c21_handlers_admin.ts";
61+import { rawSourceHandler } from "./c21_handlers_source.ts";
62+import { commitViewHandler } from "./c21_handlers_commit_view.ts";
6863
6964 const HOME_MD = "./content/home.md";
7065 const GAME_DIR = "./content/games";
@@ -651,11 +646,16 @@ ${rows}
651646
652647 "/edit/:section/:slug": editPageHandler,
653648
654- "/admin/proposals": adminProposalsHandler,
655- "/admin/proposals/:id": adminProposalDetailHandler,
656- "/admin/proposals/:id/approve": adminProposalApproveHandler,
657- "/admin/proposals/:id/reject": adminProposalRejectHandler,
658- "/admin/proposals/:id/patch": adminProposalPatchHandler,
649+ // Raw markdown source — replaces the previous git.tdd.md "view source"
650+ // link so docs pages don't depend on the Forgejo subdomain. The
651+ // route uses `:filename` (with trailing `.md` validated in the
652+ // handler) because Bun's parser treats `:slug.md` as a single param.
653+ "/content/:section/:filename": rawSourceHandler,
654+
655+ // SAMA-native commit view — Bun-rendered alternative to Forgejo's
656+ // /<owner>/<repo>/commit/<sha> page. The :sha param may carry a
657+ // trailing ".diff" which the handler handles inline.
658+ "/GIT/:owner/:repo/commit/:sha": commitViewHandler,
659659
660660 "/auth/github/start": (req) => startGithubOauth(req),
661661
removed src/c21_handlers_admin.ts +0 −115
@@ -1,115 +0,0 @@
1-// c21 — handlers: admin proposal review. Owner-only routes that list
2-// pending edits, show a side-by-side current vs proposed view, and
3-// let the owner mark a proposal approved or rejected. The patch
4-// download is the bridge to git: owner downloads the proposed body,
5-// drops it into content/<section>/<slug>.md on dev, commits, deploys.
6-// No file mutation in the running container.
7-
8-import {
9- renderNotFound,
10- htmlResponse,
11-} from "./c51_render_layout.ts";
12-import { getViewer } from "./c32_session.ts";
13-import { ADMIN_USERNAME } from "./c31_site_config.ts";
14-import {
15- listProposals,
16- getProposal,
17- setProposalStatus,
18-} from "./c13_database.ts";
19-import {
20- renderAdminProposalList,
21- renderAdminProposalDetail,
22- renderAdminGate,
23-} from "./c51_render_edit.ts";
24-
25-const requireAdmin = async (req: Request): Promise<{ viewer: string } | Response> => {
26- const viewer = await getViewer(req);
27- if (viewer !== ADMIN_USERNAME) {
28- const html = await renderAdminGate(viewer);
29- return htmlResponse(html, viewer ? 403 : 401);
30- }
31- return { viewer };
32-};
33-
34-// GET /admin/proposals
35-export const adminProposalsHandler = async (req: Request): Promise<Response> => {
36- const gate = await requireAdmin(req);
37- if (gate instanceof Response) return gate;
38- const proposals = listProposals();
39- const html = await renderAdminProposalList(proposals, gate.viewer);
40- return htmlResponse(html);
41-};
42-
43-// GET /admin/proposals/:id
44-export const adminProposalDetailHandler = async (
45- req: Request & { params: { id: string } },
46-): Promise<Response> => {
47- const gate = await requireAdmin(req);
48- if (gate instanceof Response) return gate;
49- const id = parseInt(req.params.id, 10);
50- if (!Number.isInteger(id) || id <= 0) {
51- const html = await renderNotFound(`/admin/proposals/${req.params.id}`);
52- return htmlResponse(html, 404);
53- }
54- const proposal = getProposal(id);
55- if (!proposal) {
56- const html = await renderNotFound(`/admin/proposals/${id}`);
57- return htmlResponse(html, 404);
58- }
59- const file = Bun.file(`./${proposal.editPath}`);
60- const currentBody = (await file.exists()) ? await file.text() : "";
61- const html = await renderAdminProposalDetail(proposal, currentBody, gate.viewer);
62- return htmlResponse(html);
63-};
64-
65-// POST /admin/proposals/:id/approve
66-export const adminProposalApproveHandler = async (
67- req: Request & { params: { id: string } },
68-): Promise<Response> => {
69- const gate = await requireAdmin(req);
70- if (gate instanceof Response) return gate;
71- const id = parseInt(req.params.id, 10);
72- if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 });
73- if (!getProposal(id)) return new Response("not found", { status: 404 });
74- setProposalStatus(id, "approved", gate.viewer);
75- return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } });
76-};
77-
78-// POST /admin/proposals/:id/reject
79-export const adminProposalRejectHandler = async (
80- req: Request & { params: { id: string } },
81-): Promise<Response> => {
82- const gate = await requireAdmin(req);
83- if (gate instanceof Response) return gate;
84- const id = parseInt(req.params.id, 10);
85- if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 });
86- if (!getProposal(id)) return new Response("not found", { status: 404 });
87- const form = await req.formData();
88- const reason = (form.get("reason") ?? "").toString().slice(0, 500) || null;
89- setProposalStatus(id, "rejected", gate.viewer, reason);
90- return new Response(null, { status: 303, headers: { Location: `/admin/proposals/${id}` } });
91-};
92-
93-// GET /admin/proposals/:id/patch
94-//
95-// Returns the proposed body as a downloadable .md file with a header
96-// comment naming the target path. Owner saves it to dev's working
97-// tree, runs `git diff` to review, commits, deploys.
98-export const adminProposalPatchHandler = async (
99- req: Request & { params: { id: string } },
100-): Promise<Response> => {
101- const gate = await requireAdmin(req);
102- if (gate instanceof Response) return gate;
103- const id = parseInt(req.params.id, 10);
104- if (!Number.isInteger(id) || id <= 0) return new Response("bad id", { status: 400 });
105- const proposal = getProposal(id);
106- if (!proposal) return new Response("not found", { status: 404 });
107- const filename = proposal.editPath.split("/").pop() ?? `proposal-${id}.md`;
108- const header = `<!-- proposal #${id} for ${proposal.editPath} by ${proposal.author} on ${new Date(proposal.submittedAt).toISOString()} -->\n`;
109- return new Response(header + proposal.body, {
110- headers: {
111- "Content-Type": "text/markdown; charset=utf-8",
112- "Content-Disposition": `attachment; filename="${filename}"`,
113- },
114- });
115-};
added src/c21_handlers_commit_view.ts +90 −0
@@ -0,0 +1,90 @@
1+// c21 — handler: SAMA-native commit view at
2+// GET /GIT/:owner/:repo/commit/:sha
3+// and a raw-diff sibling at
4+// GET /GIT/:owner/:repo/commit/:sha.diff
5+//
6+// Composes c14 (Forgejo HTTP), c31 (diff parser), c51 (render). The
7+// route prefix is uppercase /GIT/ to make it visually distinct from
8+// the markdown content sections (/sama, /blog, /guides). Visitors who
9+// land on git.tdd.md are bounced here by the deploy-time tunnel rule
10+// (out of scope for this handler — handler just owns the rendering).
11+
12+import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
13+import { getCommit, getCommitDiff } from "./c14_git.ts";
14+import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./c31_site_config.ts";
15+import { parseUnifiedDiff } from "./c31_diff_parse.ts";
16+import { renderCommitView } from "./c51_render_commit.ts";
17+
18+// Owner/repo + sha shape — paranoid because these go straight into a
19+// Forgejo URL. Owner/repo allow letters/digits/hyphens/underscores/dots;
20+// sha is hex 7-64 (Forgejo accepts shortened SHAs but our render assumes
21+// full ones because we use them in URLs).
22+const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
23+const SAFE_SHA = /^[a-f0-9]{7,64}$/;
24+
25+const isValid = (owner: string, repo: string, sha: string): boolean =>
26+ SAFE_OWNER_REPO.test(owner) && SAFE_OWNER_REPO.test(repo) && SAFE_SHA.test(sha);
27+
28+export const commitViewHandler = async (
29+ req: Request & { params: { owner: string; repo: string; sha: string } },
30+): Promise<Response> => {
31+ const { owner, repo } = req.params;
32+ // The :sha param may carry a trailing ".diff" because the route
33+ // pattern doesn't have a separate one. Normalise + branch.
34+ const rawSha = req.params.sha;
35+ const wantsDiff = rawSha.endsWith(".diff");
36+ const sha = wantsDiff ? rawSha.slice(0, -5) : rawSha;
37+ const fullPath = `/GIT/${owner}/${repo}/commit/${rawSha}`;
38+
39+ if (!isValid(owner, repo, sha)) {
40+ const html = await renderNotFound(fullPath);
41+ return htmlResponse(html, 404);
42+ }
43+
44+ // /GIT/ now serves only syntaxai/tdd.md (our local bare repo via
45+ // c14_git). Other (owner, repo) pairs would historically have been
46+ // proxied to Forgejo for agent katas — that's a separate concern
47+ // and currently 404s. If we want it back, add a Forgejo fallback
48+ // branch here keyed on the owner/repo pair.
49+ if (owner !== LIVE_REPO_OWNER || repo !== LIVE_REPO_NAME) {
50+ const html = await renderNotFound(fullPath);
51+ return htmlResponse(html, 404);
52+ }
53+
54+ if (wantsDiff) {
55+ const diffText = await getCommitDiff(sha);
56+ if (diffText === null) {
57+ const html = await renderNotFound(fullPath);
58+ return htmlResponse(html, 404);
59+ }
60+ return new Response(diffText, {
61+ headers: {
62+ "Content-Type": "text/plain; charset=utf-8",
63+ "Cache-Control": "public, max-age=300",
64+ },
65+ });
66+ }
67+
68+ const commit = await getCommit(sha);
69+ if (commit === null) {
70+ const html = await renderNotFound(fullPath);
71+ return htmlResponse(html, 404);
72+ }
73+ const diffText = (await getCommitDiff(sha)) ?? "";
74+ const diff = parseUnifiedDiff(diffText);
75+ // c14_git's GitCommit shape matches what c51_render_commit needs
76+ // (it used to take ForgejoCommitDetail; same field names + types).
77+ const detail = {
78+ sha: commit.sha,
79+ parents: commit.parents,
80+ authorName: commit.authorName,
81+ authorEmail: commit.authorEmail,
82+ authorDate: commit.authorDate,
83+ committerName: commit.committerName,
84+ committerEmail: commit.committerEmail,
85+ committerDate: commit.committerDate,
86+ message: commit.message,
87+ };
88+ const html = await renderCommitView({ owner, repo, detail, diff });
89+ return htmlResponse(html);
90+};
modified src/c21_handlers_edit.ts +73 −31
@@ -1,23 +1,30 @@
1-// c21 — handlers: the self-hosted editor. Replaces "edit this page on
2-// GitHub" with our own form. Identity still comes from GitHub OAuth
3-// (handled by c21_handlers_auth) but every edit lands as a *proposal*
4-// in our SQLite store; the live page does not change until the owner
5-// approves and applies the patch via git on dev. This is the
6-// guarantee the user asked for: edits never bypass deploy.
1+// c21 — handlers: the self-hosted editor. Admin-only flow:
2+// GET → form (login wall + non-admin wall as gates), POST → write
3+// commit straight to the local bare git repo via c14_git, then mirror
4+// to the container's content/ filesystem so the live page reflects it.
5+// Forgejo no longer participates in tdd.md's own repo lifecycle.
76
87 import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
98 import { getViewer } from "./c32_session.ts";
10-import { resolveEdit } from "./c32_edit_resolve.ts";
9+import { resolveEdit, type ResolvedEdit } from "./c32_edit_resolve.ts";
1110 import {
12- parseProposalSubmission,
13- isNoOpProposal,
14- ProposalValidationError,
15-} from "./c31_proposals.ts";
16-import { createProposal } from "./c13_database.ts";
11+ validateEditBody,
12+ isNoOpEdit,
13+ EditValidationError,
14+} from "./c31_edit_validation.ts";
15+import { ADMIN_USERNAME } from "./c31_site_config.ts";
16+import {
17+ commitFile,
18+ getFileBlobSha,
19+ type GitCommitOutcome,
20+} from "./c14_git.ts";
21+import { buildCommitMessage, noreplyEmail } from "./c31_commit_meta.ts";
1722 import {
1823 renderEditFormPage,
1924 renderEditLoginWall,
20- renderEditThanks,
25+ renderEditNonAdminWall,
26+ renderEditAppliedLive,
27+ renderEditCommitFailed,
2128 } from "./c51_render_edit.ts";
2229
2330 const readCurrentBody = async (filePath: string): Promise<string | null> => {
@@ -26,6 +33,14 @@ const readCurrentBody = async (filePath: string): Promise<string | null> => {
2633 return await file.text();
2734 };
2835
36+// Mirror the Forgejo write to the container's local filesystem so the
37+// next page render reflects the change without waiting for the next
38+// deploy. The deploy script's git-pull-from-Forgejo restores the same
39+// bytes on container restart.
40+const applyLiveEdit = async (resolved: ResolvedEdit, body: string): Promise<void> => {
41+ await Bun.write(`./${resolved.filePath}`, body);
42+};
43+
2944 // GET + POST /edit/:section/:slug — single handler, branches on method.
3045 export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise<Response> => {
3146 const resolved = resolveEdit(req.params.section, req.params.slug);
@@ -40,35 +55,62 @@ export const editPageHandler = async (req: Request & { params: { section: string
4055 return htmlResponse(html, 401);
4156 }
4257
58+ if (viewer !== ADMIN_USERNAME) {
59+ const html = await renderEditNonAdminWall(resolved, viewer);
60+ return htmlResponse(html, 403);
61+ }
62+
4363 if (req.method === "POST") {
4464 const form = await req.formData();
45- const body = (form.get("body") ?? "").toString();
65+ let body: string;
66+ try {
67+ body = validateEditBody(form.get("body"));
68+ } catch (e) {
69+ if (e instanceof EditValidationError) {
70+ return new Response(`edit rejected: ${e.message}`, { status: 400 });
71+ }
72+ throw e;
73+ }
4674 const current = (await readCurrentBody(resolved.filePath)) ?? "";
47- if (isNoOpProposal(current, body)) {
48- // No diff — no point queuing it. Send the user back to the form
49- // without creating a row.
75+ if (isNoOpEdit(current, body)) {
76+ // No diff — skip the Forgejo round-trip and bounce back to the
77+ // form so the user can either change something or cancel.
5078 return new Response(null, {
5179 status: 303,
5280 headers: { Location: `/edit/${resolved.section}/${resolved.slug}` },
5381 });
5482 }
55- let id: number;
56- try {
57- const parsed = parseProposalSubmission({
58- pageUrl: resolved.pageUrl,
59- editPath: resolved.filePath,
83+
84+ // Git commit FIRST against the local bare repo, then live filesystem
85+ // write. Git's update-ref gives us free optimistic concurrency
86+ // (we pass the parent SHA as the expected oldvalue — a concurrent
87+ // commit fails with kind:"conflict"). Writing FS only after a
88+ // successful commit avoids the "live but uncommitted" state that
89+ // would vanish at the next deploy.
90+ const priorBlobSha = await getFileBlobSha("main", resolved.filePath);
91+ const outcome: GitCommitOutcome = await commitFile({
92+ branch: "main",
93+ path: resolved.filePath,
94+ content: body,
95+ priorBlobSha,
96+ message: buildCommitMessage({
6097 title: resolved.title,
61- body,
6298 author: viewer,
63- });
64- id = createProposal(parsed);
65- } catch (e) {
66- if (e instanceof ProposalValidationError) {
67- return new Response(`proposal rejected: ${e.message}`, { status: 400 });
68- }
69- throw e;
99+ filePath: resolved.filePath,
100+ }),
101+ authorName: viewer,
102+ authorEmail: noreplyEmail(viewer),
103+ });
104+ if (!outcome.ok) {
105+ // Status 200 (not 5xx): Cloudflare replaces 5xx responses with
106+ // its own error page, hiding our diagnostic. The HTML body
107+ // carries the failure semantics; status only affects routing
108+ // and caching.
109+ const html = await renderEditCommitFailed(resolved, outcome);
110+ return htmlResponse(html, outcome.kind === "conflict" ? 409 : 200);
70111 }
71- const html = await renderEditThanks(resolved, id);
112+ await applyLiveEdit(resolved, body);
113+ const html = await renderEditAppliedLive(resolved, outcome);
72114 return htmlResponse(html);
73115 }
74116
added src/c21_handlers_source.ts +38 −0
@@ -0,0 +1,38 @@
1+// c21 — handler: serves the raw markdown source of an editable doc
2+// page from the main domain. Replaces the previous "view source on
3+// git.tdd.md" link so the docs site doesn't depend on the Forgejo
4+// subdomain for "view source". Reuses c32_edit_resolve so the same
5+// allowlist (sama / guides / blog + safe slug regex) protects both
6+// the editor and the raw view from path traversal.
7+
8+import { resolveEdit } from "./c32_edit_resolve.ts";
9+import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
10+
11+// The route literal is `/content/:section/:filename` and the handler
12+// requires the filename to end in `.md`. We don't use `:slug.md`
13+// because Bun's path parser treats that as a single param literally
14+// named "slug.md", which makes the URL un-typeable.
15+export const rawSourceHandler = async (
16+ req: Request & { params: { section: string; filename: string } },
17+): Promise<Response> => {
18+ const fullPath = `/content/${req.params.section}/${req.params.filename}`;
19+ const notFound = async (): Promise<Response> => {
20+ const html = await renderNotFound(fullPath);
21+ return htmlResponse(html, 404);
22+ };
23+ if (!req.params.filename.endsWith(".md")) return await notFound();
24+ const slug = req.params.filename.slice(0, -3);
25+ const resolved = resolveEdit(req.params.section, slug);
26+ if (!resolved) return await notFound();
27+ const file = Bun.file(`./${resolved.filePath}`);
28+ if (!(await file.exists())) return await notFound();
29+ // text/plain so browsers render the markdown source inline rather
30+ // than offering a download. UTF-8 is fixed because the content/ dir
31+ // is UTF-8 throughout (verified by sama-verify).
32+ return new Response(await file.text(), {
33+ headers: {
34+ "Content-Type": "text/plain; charset=utf-8",
35+ "Cache-Control": "public, max-age=60",
36+ },
37+ });
38+};
modified src/c31_blog.ts +6 −0
@@ -12,6 +12,12 @@ export interface BlogEntry {
1212 }
1313
1414 export const ALL_POSTS: BlogEntry[] = [
15+ {
16+ slug: "sama-meets-git-cms",
17+ title: "SAMA meets git: building a self-hosted CMS that obeys the discipline",
18+ description: "Built a self-hosted CMS for tdd.md that commits directly to Forgejo via HTTP — no git binary, no SSH keys, no SQLite proposal queue. Edits become real commits a reviewer can git blame. Along the way the build surfaced eight SAMA tensions: two led to refinements (Modeled exemption for I/O-only c14 files; boundary-contract discriminated unions), six were operational doctrines or things SAMA correctly stays silent on. This post itself was committed via the CMS.",
19+ date: "2026-05-10",
20+ },
1521 {
1622 slug: "from-rules-to-checks",
1723 title: "From rules to checks: shipping what the corpus post promised",
added src/c31_commit_meta.test.ts +37 −0
@@ -0,0 +1,37 @@
1+import { test, expect } from "bun:test";
2+import { buildCommitMessage, noreplyEmail } from "./c31_commit_meta.ts";
3+
4+test("buildCommitMessage emits the expected subject + trailer", () => {
5+ const msg = buildCommitMessage({
6+ title: "S — Sorted",
7+ author: "syntaxai",
8+ filePath: "content/sama/sorted.md",
9+ });
10+ const lines = msg.split("\n");
11+ expect(lines[0]).toBe("edit content/sama/sorted.md via web");
12+ expect(msg).toContain("Submitted by syntaxai via the tdd.md self-hosted editor.");
13+});
14+
15+test("buildCommitMessage filePath is the only thing on the subject line", () => {
16+ // Important: keeps `git log --oneline` readable. No author / no SHA
17+ // hint in the subject — that's all in the body / trailers / metadata.
18+ const msg = buildCommitMessage({
19+ title: "ignored title",
20+ author: "syntaxai",
21+ filePath: "content/blog/some-post.md",
22+ });
23+ const subject = msg.split("\n")[0];
24+ expect(subject).toBe("edit content/blog/some-post.md via web");
25+ expect(subject).not.toContain("syntaxai");
26+ expect(subject).not.toContain("ignored title");
27+});
28+
29+test("noreplyEmail prefers the github-id form when available", () => {
30+ expect(noreplyEmail("syntaxai", 12766340)).toBe(
31+ "[email protected]",
32+ );
33+});
34+
35+test("noreplyEmail falls back to login-only when id is unknown", () => {
36+ expect(noreplyEmail("syntaxai")).toBe("[email protected]");
37+});
added src/c31_commit_meta.ts +29 −0
@@ -0,0 +1,29 @@
1+// c31 — model: pure helpers for shaping a git commit out of an edit
2+// submission. Source-agnostic — used to live with c14_forgejo's
3+// commitFile, now feeds c14_git.commitFile against the local bare
4+// repo. Sibling-tested.
5+
6+export interface CommitMessageInput {
7+ // Page title shown in the editor header (e.g. "S — Sorted").
8+ title: string;
9+ // GitHub login of the admin who saved the edit.
10+ author: string;
11+ // Path under repo root, e.g. "content/sama/sorted.md".
12+ filePath: string;
13+}
14+
15+// One-line subject + author trailer. Intentionally short so it reads
16+// well in `git log --oneline` and in the Forgejo commit list.
17+export const buildCommitMessage = (input: CommitMessageInput): string => {
18+ const subject = `edit ${input.filePath} via web`;
19+ const trailer = `\n\nSubmitted by ${input.author} via the tdd.md self-hosted editor.`;
20+ return subject + trailer;
21+};
22+
23+// GitHub-style noreply email so commits attribute to the user's
24+// GitHub account in tools that link by email. Mirrors the logic in
25+// c21_handlers_auth where we mint Forgejo identities.
26+export const noreplyEmail = (login: string, githubId?: number): string =>
27+ githubId !== undefined
28+ ? `${githubId}+${login}@users.noreply.github.com`
29+ : `${login}@users.noreply.github.com`;
added src/c31_diff_parse.test.ts +131 −0
@@ -0,0 +1,131 @@
1+import { test, expect } from "bun:test";
2+import { parseUnifiedDiff } from "./c31_diff_parse.ts";
3+
4+test("empty input yields no files", () => {
5+ expect(parseUnifiedDiff("").files).toEqual([]);
6+});
7+
8+test("single-file modified, one mixed hunk", () => {
9+ const raw = `diff --git a/foo.md b/foo.md
10+index abc..def 100644
11+--- a/foo.md
12++++ b/foo.md
13+@@ -1,3 +1,3 @@
14+-old line
15++new line
16+ context
17+ more context
18+`;
19+ const r = parseUnifiedDiff(raw);
20+ expect(r.files).toHaveLength(1);
21+ const f = r.files[0]!;
22+ expect(f.path).toBe("foo.md");
23+ expect(f.oldPath).toBe("foo.md");
24+ expect(f.status).toBe("modified");
25+ expect(f.added).toBe(1);
26+ expect(f.removed).toBe(1);
27+ expect(f.hunks).toHaveLength(1);
28+ expect(f.hunks[0]!.lines.map((l) => [l.kind, l.text])).toEqual([
29+ ["removed", "old line"],
30+ ["added", "new line"],
31+ ["context", "context"],
32+ ["context", "more context"],
33+ ]);
34+});
35+
36+test("line numbers track old/new sides correctly", () => {
37+ const raw = `diff --git a/x b/x
38+--- a/x
39++++ b/x
40+@@ -10,3 +10,3 @@
41+ keep
42+-drop
43++inject
44+`;
45+ const f = parseUnifiedDiff(raw).files[0]!;
46+ const lines = f.hunks[0]!.lines;
47+ expect(lines[0]).toMatchObject({ kind: "context", oldNum: 10, newNum: 10 });
48+ expect(lines[1]).toMatchObject({ kind: "removed", oldNum: 11, newNum: null });
49+ expect(lines[2]).toMatchObject({ kind: "added", oldNum: null, newNum: 11 });
50+});
51+
52+test("new file marker sets status:added", () => {
53+ const raw = `diff --git a/new.md b/new.md
54+new file mode 100644
55+index 0000000..abc
56+--- /dev/null
57++++ b/new.md
58+@@ -0,0 +1,2 @@
59++hello
60++world
61+`;
62+ const f = parseUnifiedDiff(raw).files[0]!;
63+ expect(f.status).toBe("added");
64+ expect(f.added).toBe(2);
65+ expect(f.removed).toBe(0);
66+});
67+
68+test("deleted file marker sets status:removed", () => {
69+ const raw = `diff --git a/old.md b/old.md
70+deleted file mode 100644
71+--- a/old.md
72++++ /dev/null
73+@@ -1,2 +0,0 @@
74+-bye
75+-world
76+`;
77+ const f = parseUnifiedDiff(raw).files[0]!;
78+ expect(f.status).toBe("removed");
79+ expect(f.added).toBe(0);
80+ expect(f.removed).toBe(2);
81+});
82+
83+test("multiple files in one diff are all parsed", () => {
84+ const raw = `diff --git a/a.md b/a.md
85+--- a/a.md
86++++ b/a.md
87+@@ -1 +1 @@
88+-A
89++a
90+diff --git a/b.md b/b.md
91+--- a/b.md
92++++ b/b.md
93+@@ -1 +1 @@
94+-B
95++b
96+`;
97+ const r = parseUnifiedDiff(raw);
98+ expect(r.files.map((f) => f.path)).toEqual(["a.md", "b.md"]);
99+});
100+
101+test("hunk header without explicit length defaults to 1", () => {
102+ const raw = `diff --git a/x b/x
103+--- a/x
104++++ b/x
105+@@ -5 +5 @@ section name
106+-old
107++new
108+`;
109+ const f = parseUnifiedDiff(raw).files[0]!;
110+ const h = f.hunks[0]!;
111+ expect(h.oldLength).toBe(1);
112+ expect(h.newLength).toBe(1);
113+ expect(h.heading).toBe("section name");
114+});
115+
116+test("\\ No newline at end of file is silently skipped", () => {
117+ const raw = `diff --git a/x b/x
118+--- a/x
119++++ b/x
120+@@ -1 +1 @@
121+-old
122+\\ No newline at end of file
123++new
124+\\ No newline at end of file
125+`;
126+ const f = parseUnifiedDiff(raw).files[0]!;
127+ expect(f.added).toBe(1);
128+ expect(f.removed).toBe(1);
129+ // The "\ No newline" lines should NOT show up as context.
130+ expect(f.hunks[0]!.lines.map((l) => l.kind)).toEqual(["removed", "added"]);
131+});
added src/c31_diff_parse.ts +160 −0
@@ -0,0 +1,160 @@
1+// c31 — model: pure parser for unified-diff output. Takes the raw text
2+// emitted by `git diff` / Forgejo's `.diff` endpoint and produces the
3+// structured shape c51_render_commit consumes. No I/O, no I/O assumptions
4+// — handed a string, returns a tree.
5+
6+export type DiffLineKind = "context" | "added" | "removed";
7+
8+export interface DiffLine {
9+ kind: DiffLineKind;
10+ text: string;
11+ // 1-based line numbers in the old / new file. Null for the side
12+ // that doesn't have this line (e.g. additions have oldNum:null).
13+ oldNum: number | null;
14+ newNum: number | null;
15+}
16+
17+export interface DiffHunk {
18+ oldStart: number;
19+ oldLength: number;
20+ newStart: number;
21+ newLength: number;
22+ // The "@@ ... @@" suffix Forgejo/git puts after the second @@. Often
23+ // the surrounding function/section name. Free text, may be empty.
24+ heading: string;
25+ lines: DiffLine[];
26+}
27+
28+export interface DiffFile {
29+ // Path on the new side. For deletes this is the old path mirrored
30+ // here so one field is enough to render a row.
31+ path: string;
32+ // Old path, set only on renames + deletes. Equal to `path` for
33+ // straightforward edits.
34+ oldPath: string;
35+ status: "added" | "removed" | "modified" | "renamed";
36+ hunks: DiffHunk[];
37+ added: number;
38+ removed: number;
39+}
40+
41+export interface ParsedDiff {
42+ files: DiffFile[];
43+}
44+
45+// Parse a `@@ -oldStart,oldLength +newStart,newLength @@ heading` header.
46+// Returns null when the line doesn't match. The length parts are
47+// optional in unified-diff (defaults to 1) — handle both shapes.
48+const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
49+
50+const parseHunkHeader = (line: string): Omit<DiffHunk, "lines"> | null => {
51+ const m = HUNK_HEADER.exec(line);
52+ if (!m) return null;
53+ return {
54+ oldStart: parseInt(m[1]!, 10),
55+ oldLength: m[2] !== undefined ? parseInt(m[2], 10) : 1,
56+ newStart: parseInt(m[3]!, 10),
57+ newLength: m[4] !== undefined ? parseInt(m[4], 10) : 1,
58+ heading: (m[5] ?? "").trim(),
59+ };
60+};
61+
62+export const parseUnifiedDiff = (raw: string): ParsedDiff => {
63+ const files: DiffFile[] = [];
64+ let currentFile: DiffFile | null = null;
65+ let currentHunk: DiffHunk | null = null;
66+ let oldLineNo = 0;
67+ let newLineNo = 0;
68+
69+ const lines = raw.split("\n");
70+ for (let i = 0; i < lines.length; i++) {
71+ const line = lines[i] ?? "";
72+
73+ if (line.startsWith("diff --git ")) {
74+ // New file boundary. Try to extract paths from "a/X b/Y" — git
75+ // emits them quoted only when special chars are present, which
76+ // we don't expect for our markdown content.
77+ const m = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
78+ const oldPath = m?.[1] ?? "";
79+ const path = m?.[2] ?? "";
80+ currentFile = {
81+ path,
82+ oldPath,
83+ status: "modified",
84+ hunks: [],
85+ added: 0,
86+ removed: 0,
87+ };
88+ currentHunk = null;
89+ files.push(currentFile);
90+ continue;
91+ }
92+
93+ if (currentFile === null) continue; // preamble, skip
94+
95+ if (line.startsWith("new file mode")) {
96+ currentFile.status = "added";
97+ continue;
98+ }
99+ if (line.startsWith("deleted file mode")) {
100+ currentFile.status = "removed";
101+ continue;
102+ }
103+ if (line.startsWith("rename from ") || line.startsWith("rename to ")) {
104+ currentFile.status = "renamed";
105+ continue;
106+ }
107+ // Skip the index, ---/+++ headers — useful info already captured
108+ // from "diff --git" / mode lines.
109+ if (
110+ line.startsWith("index ") ||
111+ line.startsWith("--- ") ||
112+ line.startsWith("+++ ") ||
113+ line.startsWith("similarity index") ||
114+ line.startsWith("Binary files")
115+ ) {
116+ continue;
117+ }
118+
119+ if (line.startsWith("@@")) {
120+ const header = parseHunkHeader(line);
121+ if (!header) continue;
122+ currentHunk = { ...header, lines: [] };
123+ currentFile.hunks.push(currentHunk);
124+ oldLineNo = header.oldStart;
125+ newLineNo = header.newStart;
126+ continue;
127+ }
128+
129+ if (currentHunk === null) continue;
130+
131+ // Body lines — first char is the marker. An empty string at the
132+ // tail of the input (from a trailing "\n") falls through as
133+ // context with text "" — that matches what git emits.
134+ const marker = line[0] ?? " ";
135+ const text = line.slice(1);
136+
137+ if (marker === "+") {
138+ currentHunk.lines.push({ kind: "added", text, oldNum: null, newNum: newLineNo });
139+ newLineNo++;
140+ currentFile.added++;
141+ } else if (marker === "-") {
142+ currentHunk.lines.push({ kind: "removed", text, oldNum: oldLineNo, newNum: null });
143+ oldLineNo++;
144+ currentFile.removed++;
145+ } else if (marker === " " || marker === "") {
146+ // Skip a stray empty line that follows the last hunk before the
147+ // next "diff --git" — it's not a real context line.
148+ const next = lines[i + 1] ?? "";
149+ if (line === "" && (next.startsWith("diff --git ") || next === "")) continue;
150+ currentHunk.lines.push({ kind: "context", text, oldNum: oldLineNo, newNum: newLineNo });
151+ oldLineNo++;
152+ newLineNo++;
153+ } else if (marker === "\\") {
154+ // "\ No newline at end of file" — informational, skip.
155+ continue;
156+ }
157+ }
158+
159+ return { files };
160+};
added src/c31_edit_validation.test.ts +39 −0
@@ -0,0 +1,39 @@
1+import { test, expect } from "bun:test";
2+import {
3+ validateEditBody,
4+ isNoOpEdit,
5+ EditValidationError,
6+ MAX_EDIT_BODY_BYTES,
7+} from "./c31_edit_validation.ts";
8+
9+test("validateEditBody returns the body when valid", () => {
10+ expect(validateEditBody("# title\n\nsome body")).toBe("# title\n\nsome body");
11+});
12+
13+test("validateEditBody rejects non-string input", () => {
14+ expect(() => validateEditBody(42)).toThrow(EditValidationError);
15+ expect(() => validateEditBody(null)).toThrow(EditValidationError);
16+ expect(() => validateEditBody(undefined)).toThrow(EditValidationError);
17+});
18+
19+test("validateEditBody rejects empty / whitespace-only", () => {
20+ expect(() => validateEditBody("")).toThrow(EditValidationError);
21+ expect(() => validateEditBody(" \n\t ")).toThrow(EditValidationError);
22+});
23+
24+test("validateEditBody rejects bodies over the byte cap", () => {
25+ const tooBig = "x".repeat(MAX_EDIT_BODY_BYTES + 1);
26+ expect(() => validateEditBody(tooBig)).toThrow(/exceeds/);
27+});
28+
29+test("validateEditBody accepts a body right at the cap", () => {
30+ const exact = "x".repeat(MAX_EDIT_BODY_BYTES);
31+ expect(validateEditBody(exact)).toBe(exact);
32+});
33+
34+test("isNoOpEdit is byte-equal, not whitespace-tolerant", () => {
35+ expect(isNoOpEdit("a", "a")).toBe(true);
36+ expect(isNoOpEdit("a", "a ")).toBe(false);
37+ expect(isNoOpEdit("a\n", "a")).toBe(false);
38+ expect(isNoOpEdit("", "")).toBe(true);
39+});
added src/c31_edit_validation.ts +38 −0
@@ -0,0 +1,38 @@
1+// c31 — model: validation for an admin edit submission. Pure: no I/O.
2+// The DB no longer stores edits (admin POST goes directly to Forgejo
3+// + filesystem), so this file holds only the body sanity checks that
4+// were previously bundled with the SQLite proposal flow.
5+
6+export const MAX_EDIT_BODY_BYTES = 256 * 1024; // 256 KB
7+
8+export class EditValidationError extends Error {
9+ constructor(message: string) {
10+ super(message);
11+ this.name = "EditValidationError";
12+ }
13+}
14+
15+// Throws EditValidationError when the body is empty, too large, or
16+// otherwise unfit to commit. Returns the trimmed-but-otherwise-untouched
17+// body string on success.
18+export const validateEditBody = (raw: unknown): string => {
19+ if (typeof raw !== "string") {
20+ throw new EditValidationError("body must be a string");
21+ }
22+ if (raw.trim().length === 0) {
23+ throw new EditValidationError("body cannot be empty");
24+ }
25+ const bytes = new TextEncoder().encode(raw).length;
26+ if (bytes > MAX_EDIT_BODY_BYTES) {
27+ throw new EditValidationError(
28+ `body exceeds the ${MAX_EDIT_BODY_BYTES / 1024} KB limit (got ${Math.round(bytes / 1024)} KB)`,
29+ );
30+ }
31+ return raw;
32+};
33+
34+// Byte-identical check between current page content and the proposed
35+// new content. Used to skip a Forgejo round-trip when the user
36+// accidentally submitted without changes.
37+export const isNoOpEdit = (currentBody: string, newBody: string): boolean =>
38+ currentBody === newBody;
added src/c31_git_parse.test.ts +93 −0
@@ -0,0 +1,93 @@
1+import { test, expect } from "bun:test";
2+import {
3+ parseGitCommits,
4+ parseLsTreeLine,
5+ GIT_COMMIT_FORMAT,
6+} from "./c31_git_parse.ts";
7+
8+const FS = "\x1f";
9+const RS = "\x1e";
10+
11+const fakeCommit = (
12+ sha: string,
13+ parents: string,
14+ msg: string,
15+ ts = "2026-05-10T13:00:00+01:00",
16+): string =>
17+ [sha, parents, "syntaxai", "[email protected]", ts, "syntaxai", "[email protected]", ts, msg].join(FS) + RS;
18+
19+test("parses a single commit with one parent and short message", () => {
20+ const raw = fakeCommit("abc123", "def456", "edit content/sama/skill.md\n");
21+ const commits = parseGitCommits(raw);
22+ expect(commits).toHaveLength(1);
23+ const c = commits[0]!;
24+ expect(c.sha).toBe("abc123");
25+ expect(c.parents).toEqual(["def456"]);
26+ expect(c.authorName).toBe("syntaxai");
27+ expect(c.message).toBe("edit content/sama/skill.md");
28+});
29+
30+test("parses multiple commits separated by RS", () => {
31+ const raw =
32+ fakeCommit("aaa", "bbb", "first") +
33+ fakeCommit("bbb", "ccc", "second") +
34+ fakeCommit("ccc", "", "root commit");
35+ const commits = parseGitCommits(raw);
36+ expect(commits.map((c) => c.sha)).toEqual(["aaa", "bbb", "ccc"]);
37+ expect(commits[2]!.parents).toEqual([]);
38+});
39+
40+test("preserves multi-line commit message body", () => {
41+ const msg = "subject line\n\nbody line one\nbody line two\n";
42+ const raw = fakeCommit("xyz", "par", msg);
43+ const c = parseGitCommits(raw)[0]!;
44+ expect(c.message).toBe("subject line\n\nbody line one\nbody line two");
45+});
46+
47+test("merge commit has multiple parents", () => {
48+ const raw = fakeCommit("merge1", "p1 p2 p3", "merge");
49+ const c = parseGitCommits(raw)[0]!;
50+ expect(c.parents).toEqual(["p1", "p2", "p3"]);
51+});
52+
53+test("empty input yields empty array", () => {
54+ expect(parseGitCommits("")).toEqual([]);
55+});
56+
57+test("malformed record throws", () => {
58+ expect(() => parseGitCommits("not enough fields here" + RS)).toThrow();
59+});
60+
61+test("GIT_COMMIT_FORMAT round-trips through %x1e/%x1f hex escapes", () => {
62+ // The format string passes \x1e and \x1f as %x1e / %x1f to git's
63+ // printf-style placeholder language. This guards against accidental
64+ // edits that break the round-trip.
65+ expect(GIT_COMMIT_FORMAT).toContain("%x1f");
66+ expect(GIT_COMMIT_FORMAT).toEndWith("%x1e");
67+});
68+
69+test("parseLsTreeLine accepts a regular blob row", () => {
70+ const r = parseLsTreeLine("100644 blob abc123def456\tcontent/sama/skill.md");
71+ expect(r).toEqual({
72+ mode: "100644",
73+ type: "blob",
74+ sha: "abc123def456",
75+ path: "content/sama/skill.md",
76+ });
77+});
78+
79+test("parseLsTreeLine accepts a tree row", () => {
80+ const r = parseLsTreeLine("040000 tree treesha\tcontent");
81+ expect(r?.type).toBe("tree");
82+});
83+
84+test("parseLsTreeLine returns null for blank or malformed input", () => {
85+ expect(parseLsTreeLine("")).toBeNull();
86+ expect(parseLsTreeLine("not even tab separated")).toBeNull();
87+ expect(parseLsTreeLine("100644 weirdtype sha\tpath")).toBeNull();
88+});
89+
90+test("parseLsTreeLine preserves paths with embedded spaces", () => {
91+ const r = parseLsTreeLine("100644 blob abc\tcontent/with space/file.md");
92+ expect(r?.path).toBe("content/with space/file.md");
93+});
added src/c31_git_parse.ts +83 −0
@@ -0,0 +1,83 @@
1+// c31 — model: parsers for `git` plumbing output. Pure: a function
2+// from string to a typed object. The c14_git layer owns the actual
3+// `Bun.spawn` calls; this file makes their stdout/stderr legible.
4+
5+export interface GitCommit {
6+ sha: string;
7+ parents: string[];
8+ authorName: string;
9+ authorEmail: string;
10+ authorDate: string; // ISO 8601 with timezone
11+ committerName: string;
12+ committerEmail: string;
13+ committerDate: string;
14+ message: string; // full message: subject + blank + body
15+}
16+
17+// Format string for `git log` / `git show` that this parser consumes.
18+// Uses ASCII record separators so commit messages with newlines pass
19+// through unmangled. Mirrors the technique already used in
20+// scripts/p620/snapshot-git-history.ts.
21+//
22+// %H full sha
23+// %P parent shas (space-separated)
24+// %an %ae %aI author name/email/iso-strict-with-timezone
25+// %cn %ce %cI committer
26+// %B raw body (subject + blank + rest)
27+export const GIT_COMMIT_FORMAT =
28+ ["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%B"].join("%x1f") + "%x1e";
29+
30+const RECORD_SEP = "\x1e";
31+const FIELD_SEP = "\x1f";
32+
33+// Parse one or more commits emitted with GIT_COMMIT_FORMAT. Trailing
34+// record separator is fine (we trim before splitting).
35+export const parseGitCommits = (raw: string): GitCommit[] => {
36+ const records = raw.split(RECORD_SEP).map((s) => s.trim()).filter(Boolean);
37+ return records.map(parseOneCommit);
38+};
39+
40+const parseOneCommit = (record: string): GitCommit => {
41+ const parts = record.split(FIELD_SEP);
42+ if (parts.length < 9) {
43+ throw new Error(`malformed git commit record: expected 9+ fields, got ${parts.length}`);
44+ }
45+ const [sha, parentsRaw, an, ae, aI, cn, ce, cI, ...rest] = parts;
46+ const message = (rest.join(FIELD_SEP) ?? "").replace(/\n+$/, "");
47+ return {
48+ sha: sha!,
49+ parents: (parentsRaw ?? "").trim().split(/\s+/).filter(Boolean),
50+ authorName: an!,
51+ authorEmail: ae!,
52+ authorDate: aI!,
53+ committerName: cn!,
54+ committerEmail: ce!,
55+ committerDate: cI!,
56+ message,
57+ };
58+};
59+
60+// Parse `git ls-tree <ref> -- <path>` output: one tab-separated row of
61+// `<mode> <type> <sha>\t<path>`. Returns null when the path doesn't
62+// exist at that ref (empty stdout from git).
63+export interface LsTreeEntry {
64+ mode: string;
65+ type: "blob" | "tree" | "commit";
66+ sha: string;
67+ path: string;
68+}
69+
70+export const parseLsTreeLine = (line: string): LsTreeEntry | null => {
71+ const trimmed = line.trim();
72+ if (!trimmed) return null;
73+ // `<mode> <type> <sha>\t<path>` — tab is mandatory between sha+path,
74+ // spaces before. Split on first tab to keep paths with spaces intact.
75+ const tabIdx = trimmed.indexOf("\t");
76+ if (tabIdx === -1) return null;
77+ const head = trimmed.slice(0, tabIdx).split(/\s+/);
78+ if (head.length !== 3) return null;
79+ const [mode, type, sha] = head;
80+ const path = trimmed.slice(tabIdx + 1);
81+ if (type !== "blob" && type !== "tree" && type !== "commit") return null;
82+ return { mode: mode!, type, sha: sha!, path };
83+};
removed src/c31_proposals.test.ts +0 −54
@@ -1,54 +0,0 @@
1-import { test, expect } from "bun:test";
2-import {
3- parseProposalSubmission,
4- isNoOpProposal,
5- ProposalValidationError,
6- MAX_PROPOSAL_BODY_BYTES,
7-} from "./c31_proposals.ts";
8-
9-const valid = {
10- pageUrl: "/sama/sorted",
11- editPath: "content/sama/sorted.md",
12- title: "S — Sorted",
13- body: "# Sorted\n\nNew text.",
14- author: "octocat",
15-};
16-
17-test("accepts a well-formed submission", () => {
18- const out = parseProposalSubmission(valid);
19- expect(out.body).toBe(valid.body);
20- expect(out.author).toBe("octocat");
21-});
22-
23-test("rejects non-object input", () => {
24- expect(() => parseProposalSubmission(null)).toThrow(ProposalValidationError);
25- expect(() => parseProposalSubmission("string")).toThrow(ProposalValidationError);
26-});
27-
28-test("rejects missing fields", () => {
29- expect(() => parseProposalSubmission({ ...valid, body: undefined })).toThrow(ProposalValidationError);
30- expect(() => parseProposalSubmission({ ...valid, author: 42 })).toThrow(ProposalValidationError);
31-});
32-
33-test("rejects empty body", () => {
34- expect(() => parseProposalSubmission({ ...valid, body: " \n " })).toThrow(/empty/);
35-});
36-
37-test("rejects empty author", () => {
38- expect(() => parseProposalSubmission({ ...valid, author: "" })).toThrow(/author/);
39-});
40-
41-test("enforces the body size cap", () => {
42- const huge = "x".repeat(MAX_PROPOSAL_BODY_BYTES + 1);
43- expect(() => parseProposalSubmission({ ...valid, body: huge })).toThrow(/exceeds/);
44-});
45-
46-test("accepts a body right at the size cap", () => {
47- const limit = "x".repeat(MAX_PROPOSAL_BODY_BYTES);
48- expect(parseProposalSubmission({ ...valid, body: limit }).body.length).toBe(MAX_PROPOSAL_BODY_BYTES);
49-});
50-
51-test("isNoOpProposal flags identical bodies", () => {
52- expect(isNoOpProposal("a", "a")).toBe(true);
53- expect(isNoOpProposal("a", "a ")).toBe(false);
54-});
removed src/c31_proposals.ts +0 −63
@@ -1,63 +0,0 @@
1-// c31 — model: parser for proposal submissions. The DB row shape lives
2-// in c13_database.ts (ProposalRow / NewProposal); this file validates
3-// untyped form input before it reaches the DB.
4-//
5-// A proposal is a markdown-body edit suggestion for a doc page. We
6-// cap the body size and reject empty submissions so the proposals
7-// table stays bounded and admins aren't reviewing accidental no-ops.
8-
9-import type { NewProposal } from "./c13_database.ts";
10-
11-export const MAX_PROPOSAL_BODY_BYTES = 256 * 1024; // 256 KB
12-
13-export interface ProposalSubmissionInput {
14- pageUrl: string;
15- editPath: string;
16- title: string;
17- body: string;
18- author: string;
19-}
20-
21-export class ProposalValidationError extends Error {
22- constructor(message: string) {
23- super(message);
24- this.name = "ProposalValidationError";
25- }
26-}
27-
28-const requireString = (v: unknown, label: string): string => {
29- if (typeof v !== "string") throw new ProposalValidationError(`${label} must be a string`);
30- return v;
31-};
32-
33-export const parseProposalSubmission = (input: unknown): NewProposal => {
34- if (input === null || typeof input !== "object") {
35- throw new ProposalValidationError("submission must be an object");
36- }
37- const obj = input as Record<string, unknown>;
38- const body = requireString(obj.body, "body");
39- if (body.trim().length === 0) {
40- throw new ProposalValidationError("body cannot be empty");
41- }
42- const bodyBytes = new TextEncoder().encode(body).length;
43- if (bodyBytes > MAX_PROPOSAL_BODY_BYTES) {
44- throw new ProposalValidationError(`body exceeds the ${MAX_PROPOSAL_BODY_BYTES / 1024} KB limit (got ${Math.round(bodyBytes / 1024)} KB)`);
45- }
46- const author = requireString(obj.author, "author");
47- if (author.trim().length === 0) {
48- throw new ProposalValidationError("author cannot be empty");
49- }
50- return {
51- pageUrl: requireString(obj.pageUrl, "pageUrl"),
52- editPath: requireString(obj.editPath, "editPath"),
53- title: requireString(obj.title, "title"),
54- body,
55- author,
56- };
57-};
58-
59-// Returns true when the proposed body is byte-identical to the
60-// current page body. Used by the edit handler to skip storing
61-// no-op submissions.
62-export const isNoOpProposal = (currentBody: string, proposedBody: string): boolean =>
63- currentBody === proposedBody;
modified src/c31_site_config.ts +2 −6
@@ -9,11 +9,7 @@ export const LIVE_REPO_NAME = "tdd.md";
99 // in-container git-history bundle.
1010 export const LIVE_FETCH_COUNT = 100;
1111
12-// Owner / admin GitHub login. Gates /admin/* routes (proposal review).
12+// Owner / admin GitHub login. The CMS edit handler (c21_handlers_edit)
13+// only allows POSTs from this username — anyone else gets a 403 wall.
1314 // Override per-environment via TDD_ADMIN_USER if needed.
1415 export const ADMIN_USERNAME = process.env.TDD_ADMIN_USER ?? "syntaxai";
15-
16-// Self-hosted Forgejo mirror — what we link to when the docs layout
17-// says "view source on our git". The link goes to the canonical
18-// rendered file in the mirror, NOT to a web editor (we have our own).
19-export const SELF_HOSTED_REPO_BLOB_BASE = "https://git.tdd.md/syntaxai/tdd.md/src/branch/main";
modified src/c32_edit_resolve.test.ts +14 −0
@@ -42,3 +42,17 @@ test("rejects unsafe slug shapes", () => {
4242 expect(resolveEdit("sama", "with space")).toBeNull();
4343 expect(resolveEdit("sama", "-leading-dash")).toBeNull();
4444 });
45+
46+test("resolves nav-only sama pages (e.g. /sama/skill) via SITE_NAV fallback", () => {
47+ const r = resolveEdit("sama", "skill");
48+ expect(r).not.toBeNull();
49+ expect(r?.pageUrl).toBe("/sama/skill");
50+ expect(r?.filePath).toBe("content/sama/skill.md");
51+ expect(r?.title).toMatch(/SKILL/i);
52+});
53+
54+test("non-editable nav links (editPath:null) stay unresolvable", () => {
55+ // /sama/verify is in SITE_NAV but has editPath: null because it's
56+ // a verifier form, not a content/<...>.md doc.
57+ expect(resolveEdit("sama", "verify")).toBeNull();
58+});
modified src/c32_edit_resolve.ts +17 −7
@@ -12,6 +12,7 @@
1212 import { ALL_SAMA } from "./c31_sama.ts";
1313 import { ALL_GUIDES } from "./c31_guides.ts";
1414 import { ALL_POSTS } from "./c31_blog.ts";
15+import { SITE_NAV } from "./c31_docs_nav.ts";
1516
1617 export type EditableSection = "sama" | "guides" | "blog";
1718
@@ -32,15 +33,24 @@ const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
3233 const lookupTitle = (section: EditableSection, slug: string): string | null => {
3334 if (section === "sama") {
3435 const e = ALL_SAMA.find((d) => d.slug === slug);
35- return e ? `${e.letter} — ${e.title}` : null;
36- }
37- if (section === "guides") {
36+ if (e) return `${e.letter} — ${e.title}`;
37+ } else if (section === "guides") {
3838 const e = ALL_GUIDES.find((g) => g.slug === slug);
39- return e?.title ?? null;
39+ if (e) return e.title;
40+ } else {
41+ const e = ALL_POSTS.find((p) => p.slug === slug);
42+ if (e) return e.title;
4043 }
41- // blog
42- const e = ALL_POSTS.find((p) => p.slug === slug);
43- return e?.title ?? null;
44+ // Fallback to SITE_NAV: nav-only editable pages (e.g. /sama/skill)
45+ // have a content/<...>.md backing file but no entry in the discipline
46+ // / guide / blog registries. They're listed in SITE_NAV with a
47+ // non-null editPath, which is the single source of truth for
48+ // "this docs page is editable".
49+ const navSection = SITE_NAV.find((s) => s.id === section);
50+ const link = navSection?.links.find(
51+ (l) => l.href === `/${section}/${slug}` && l.editPath !== null,
52+ );
53+ return link?.label ?? null;
4454 };
4555
4656 export const resolveEdit = (section: string, slug: string): ResolvedEdit | null => {
added src/c51_render_commit.ts +127 −0
@@ -0,0 +1,127 @@
1+// c51 — UI: SAMA-native commit detail page. Replaces what visitors
2+// would see at git.tdd.md/<owner>/<repo>/commit/<sha> with the same
3+// information rendered through tdd.md's chrome. Consumes the parsed
4+// diff (c31_diff_parse) and commit metadata (any source — c14_git or
5+// c14_forgejo can both produce it).
6+
7+import { renderPage, escape } from "./c51_render_layout.ts";
8+import type { DiffFile, DiffHunk, ParsedDiff } from "./c31_diff_parse.ts";
9+
10+// Source-agnostic commit shape this renderer consumes. Both c14_git's
11+// GitCommit and c14_forgejo's ForgejoCommitDetail fit this surface.
12+export interface CommitForView {
13+ sha: string;
14+ parents: string[];
15+ authorName: string;
16+ authorEmail: string;
17+ authorDate: string;
18+ committerName: string;
19+ committerEmail: string;
20+ committerDate: string;
21+ message: string;
22+}
23+
24+const shortSha = (sha: string): string => sha.slice(0, 7);
25+
26+// "2026-05-10 12:31:07 +01:00" — ISO-ish, easy to scan.
27+const ts = (iso: string): string => {
28+ // Trust Forgejo's ISO format; only chop the timezone/seconds for compactness.
29+ return iso.replace("T", " ").replace(/\+\d{2}:\d{2}$/, (m) => " " + m);
30+};
31+
32+// First line of the commit message is the subject; rest is body.
33+const splitMessage = (msg: string): { subject: string; body: string } => {
34+ const newline = msg.indexOf("\n");
35+ if (newline === -1) return { subject: msg, body: "" };
36+ return {
37+ subject: msg.slice(0, newline),
38+ body: msg.slice(newline + 1).trim(),
39+ };
40+};
41+
42+const statusBadge = (status: DiffFile["status"]): string => {
43+ const label =
44+ status === "added" ? "added" :
45+ status === "removed" ? "removed" :
46+ status === "renamed" ? "renamed" : "modified";
47+ return `<span class="commit-file-status commit-file-status-${status}">${label}</span>`;
48+};
49+
50+const renderHunk = (hunk: DiffHunk): string => {
51+ const headingHtml = hunk.heading
52+ ? `<span class="commit-hunk-heading">${escape(hunk.heading)}</span>`
53+ : "";
54+ const headerRow = `<tr class="commit-hunk-header"><td colspan="3">@@ -${hunk.oldStart},${hunk.oldLength} +${hunk.newStart},${hunk.newLength} @@ ${headingHtml}</td></tr>`;
55+ const lineRows = hunk.lines.map((line) => {
56+ const marker = line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " ";
57+ const oldNum = line.oldNum === null ? "" : String(line.oldNum);
58+ const newNum = line.newNum === null ? "" : String(line.newNum);
59+ return `<tr class="commit-line commit-line-${line.kind}"><td class="commit-line-old">${oldNum}</td><td class="commit-line-new">${newNum}</td><td class="commit-line-text">${escape(marker + line.text)}</td></tr>`;
60+ }).join("");
61+ return headerRow + lineRows;
62+};
63+
64+const renderFile = (file: DiffFile): string => {
65+ const renamed = file.status === "renamed" && file.oldPath !== file.path
66+ ? `<span class="commit-file-rename"><code>${escape(file.oldPath)}</code> → </span>`
67+ : "";
68+ return `<section class="commit-file">
69+ <header class="commit-file-header">
70+ ${statusBadge(file.status)}
71+ ${renamed}<code class="commit-file-path">${escape(file.path)}</code>
72+ <span class="commit-file-stats">
73+ <span class="commit-file-add">+${file.added}</span>
74+ <span class="commit-file-rem">−${file.removed}</span>
75+ </span>
76+ </header>
77+ <table class="commit-diff-table"><tbody>${file.hunks.map(renderHunk).join("")}</tbody></table>
78+</section>`;
79+};
80+
81+export const renderCommitView = async (params: {
82+ owner: string;
83+ repo: string;
84+ detail: CommitForView;
85+ diff: ParsedDiff;
86+}): Promise<string> => {
87+ const { owner, repo, detail, diff } = params;
88+ const { subject, body } = splitMessage(detail.message);
89+ const parentLinks = detail.parents.length === 0
90+ ? `<span class="commit-meta-empty">no parent (root commit)</span>`
91+ : detail.parents.map((p) =>
92+ `<a class="commit-parent" href="/GIT/${escape(owner)}/${escape(repo)}/commit/${escape(p)}"><code>${escape(shortSha(p))}</code></a>`,
93+ ).join(" · ");
94+
95+ const totalAdded = diff.files.reduce((s, f) => s + f.added, 0);
96+ const totalRemoved = diff.files.reduce((s, f) => s + f.removed, 0);
97+ const filesSummary = diff.files.length === 0
98+ ? `<p class="commit-empty">No file changes (empty / merge commit).</p>`
99+ : `<p class="commit-files-summary">${diff.files.length} file${diff.files.length === 1 ? "" : "s"} changed · <span class="commit-file-add">+${totalAdded}</span> <span class="commit-file-rem">−${totalRemoved}</span></p>`;
100+
101+ const inner = `<main class="md commit-view">
102+ <header class="commit-header">
103+ <p class="commit-breadcrumb"><a href="/${escape(owner)}/${escape(repo)}">${escape(owner)}/${escape(repo)}</a> · commit <code>${escape(shortSha(detail.sha))}</code></p>
104+ <h1 class="commit-subject">${escape(subject)}</h1>
105+ ${body ? `<pre class="commit-body">${escape(body)}</pre>` : ""}
106+ <dl class="commit-meta">
107+ <dt>author</dt><dd><strong>${escape(detail.authorName)}</strong> <span class="commit-meta-email">&lt;${escape(detail.authorEmail)}&gt;</span></dd>
108+ <dt>date</dt><dd>${escape(ts(detail.authorDate))}</dd>
109+ <dt>parent</dt><dd>${parentLinks}</dd>
110+ <dt>commit</dt><dd><code>${escape(detail.sha)}</code></dd>
111+ </dl>
112+ </header>
113+ ${filesSummary}
114+ ${diff.files.map(renderFile).join("")}
115+ <p class="commit-footer">
116+ <a href="/GIT/${escape(owner)}/${escape(repo)}/commit/${escape(detail.sha)}.diff">raw .diff</a>
117+ </p>
118+</main>`;
119+
120+ return renderPage({
121+ title: `${shortSha(detail.sha)} · ${subject} — tdd.md`,
122+ bodyHtml: inner,
123+ description: `Commit ${shortSha(detail.sha)} on ${owner}/${repo}: ${subject}`,
124+ noindex: true,
125+ bodyClass: "commit-body-page",
126+ });
127+};
modified src/c51_render_docs_layout.ts +5 −3
@@ -19,7 +19,6 @@ import {
1919 escape,
2020 type PageOptions,
2121 } from "./c51_render_layout.ts";
22-import { SELF_HOSTED_REPO_BLOB_BASE } from "./c31_site_config.ts";
2322
2423 export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> {
2524 // The route path the user is on, e.g. "/sama/sorted". Used to
@@ -76,13 +75,16 @@ const sectionSlugFromEditPath = (editPath: string): { section: string; slug: str
7675
7776 const renderEditLink = (editPath: string | null): string => {
7877 if (!editPath) return "";
79- const sourceUrl = `${SELF_HOSTED_REPO_BLOB_BASE}/${editPath}`;
78+ // Source view is served from tdd.md itself (c21_handlers_source);
79+ // we no longer depend on the git.tdd.md (Forgejo) subdomain for
80+ // the docs site's "view source" link.
8081 const ss = sectionSlugFromEditPath(editPath);
82+ const sourceHref = ss ? `/content/${ss.section}/${ss.slug}.md` : `/${editPath}`;
8183 const editHref = ss ? `/edit/${ss.section}/${ss.slug}` : null;
8284 const editAnchor = editHref
8385 ? `<a href="${escape(editHref)}">propose an edit →</a> · `
8486 : "";
85- return `<p class="docs-edit">${editAnchor}<a href="${escape(sourceUrl)}" rel="noopener" target="_blank">view source on git.tdd.md →</a></p>`;
87+ return `<p class="docs-edit">${editAnchor}<a href="${escape(sourceHref)}">view source →</a></p>`;
8688 };
8789
8890 const renderPrevNext = (loc: ResolvedDocsLocation | null): string => {
modified src/c51_render_edit.ts +79 −107
@@ -1,16 +1,17 @@
1-// c51 (edit) — UI: edit-form, login-required prompt, admin proposal
2-// list, and side-by-side current vs proposed view. Composes the
3-// docs layout's chrome via renderPage with bodyHtml so the form
4-// can use real <form> elements (markdown would escape them).
1+// c51 (edit) — UI: edit-form, login-required prompt, applied-live
2+// success page, commit-failure page, non-admin "read-only" wall.
3+// Composes the docs layout's chrome via renderPage with bodyHtml so
4+// the form can use real <form> elements (markdown would escape them).
55
66 import {
77 renderPage,
88 escape,
99 } from "./c51_render_layout.ts";
10-import type { ProposalRow } from "./c13_database.ts";
1110 import type { ResolvedEdit } from "./c32_edit_resolve.ts";
12-
13-const ts = (n: number): string => new Date(n).toISOString().replace("T", " ").slice(0, 19) + " UTC";
11+import type {
12+ GitCommitOk,
13+ GitCommitFailure,
14+} from "./c14_git.ts";
1415
1516 const layoutWrap = (innerHtml: string): string =>
1617 `<main class="md edit-page"><div class="edit-container">${innerHtml}</div></main>`;
@@ -19,7 +20,16 @@ const layoutWrap = (innerHtml: string): string =>
1920 // full-width form controls, not the doc-layout's three columns.
2021 const editBodyClass = "edit-body";
2122
22-// -------- /edit/:section/:slug — form for a logged-in user --------
23+const shortSha = (sha: string): string => sha.slice(0, 7);
24+
25+// SAMA-native commit URL on tdd.md itself. The /GIT/ prefix routes to
26+// c21_handlers_commit_view which reads the data from Forgejo's API and
27+// renders it through tdd.md's chrome — visitor never leaves the main
28+// domain.
29+const tddCommitUrl = (sha: string): string =>
30+ `/GIT/syntaxai/tdd.md/commit/${sha}`;
31+
32+// -------- /edit/:section/:slug — form for the admin --------
2333
2434 export const renderEditFormPage = async (
2535 resolved: ResolvedEdit,
@@ -29,26 +39,27 @@ export const renderEditFormPage = async (
2939 const inner = `<h1>edit · ${escape(resolved.title)}</h1>
3040 <p class="edit-meta">
3141 Editing <code>${escape(resolved.filePath)}</code> as <strong>${escape(viewer)}</strong>.
32- Submitting saves a <em>proposal</em> — the live page does not change.
42+ Saving will commit directly to <code>syntaxai/tdd.md@main</code> on git.tdd.md
43+ and refresh the live page.
3344 <a href="${escape(resolved.pageUrl)}">view the live page</a> ·
3445 <a href="/auth/logout">log out</a>
3546 </p>
3647 <form method="post" action="/edit/${escape(resolved.section)}/${escape(resolved.slug)}" class="edit-form">
3748 <textarea name="body" class="edit-textarea" rows="32" spellcheck="false">${escape(currentBody)}</textarea>
3849 <div class="edit-actions">
39- <button type="submit">submit proposal</button>
50+ <button type="submit">save (commit + live)</button>
4051 <a class="edit-cancel" href="${escape(resolved.pageUrl)}">cancel</a>
4152 </div>
4253 </form>
4354 <p class="edit-note">
44- Proposals are queued for review at <code>/admin/proposals</code>. The owner downloads
45- the proposed body, applies it via git, and the next deploy publishes the change. No edits
46- bypass git or the deploy pipeline.
55+ This editor commits to git via Forgejo's contents API — the container has
56+ no <code>.git</code> directory, no SSH keys, only an HTTP token. Every save
57+ becomes a real commit you can review at git.tdd.md.
4758 </p>`;
4859 return renderPage({
4960 title: `edit · ${resolved.title} — tdd.md`,
5061 bodyHtml: layoutWrap(inner),
51- description: `Suggest an edit to ${resolved.title} on tdd.md. Proposals are queued for owner review and never bypass git.`,
62+ description: `Edit ${resolved.title} on tdd.md. Admin-only; saves commit directly to git.tdd.md.`,
5263 ogPath: `https://tdd.md/edit/${resolved.section}/${resolved.slug}`,
5364 noindex: true,
5465 bodyClass: editBodyClass,
@@ -62,133 +73,94 @@ export const renderEditLoginWall = async (
6273 ): Promise<string> => {
6374 const returnTo = `/edit/${resolved.section}/${resolved.slug}`;
6475 const inner = `<h1>edit · ${escape(resolved.title)}</h1>
65-<p>To suggest an edit you need to sign in via GitHub. We use GitHub only for identity — no edits or commits go through GitHub from here.</p>
76+<p>To edit a page you need to sign in via GitHub. Editing is admin-only — only the site owner's GitHub account can save changes. We use GitHub for identity only; saves commit to git.tdd.md, never to GitHub.</p>
6677 <p><a class="edit-login-button" href="/auth/github/start?to=${encodeURIComponent(returnTo)}">sign in with GitHub →</a></p>
67-<p class="edit-meta">After sign-in you'll land back on this edit form. Your proposal stays in our SQLite store until the owner approves and applies it via git.</p>
78+<p class="edit-meta">If you have an edit suggestion and you're not the admin, open an issue at <a href="https://git.tdd.md/syntaxai/tdd.md/issues" rel="noopener" target="_blank">git.tdd.md/syntaxai/tdd.md/issues</a>.</p>
6879 <p><a href="${escape(resolved.pageUrl)}">← back to the page</a></p>`;
6980 return renderPage({
7081 title: `sign in to edit · ${resolved.title} — tdd.md`,
7182 bodyHtml: layoutWrap(inner),
72- description: `Sign in via GitHub to propose an edit to ${resolved.title} on tdd.md.`,
83+ description: `Sign in via GitHub to edit ${resolved.title} on tdd.md.`,
7384 noindex: true,
7485 bodyClass: editBodyClass,
7586 });
7687 };
7788
78-// -------- thank-you page after submit --------
89+// -------- non-admin signed-in wall --------
7990
80-export const renderEditThanks = async (
91+export const renderEditNonAdminWall = async (
8192 resolved: ResolvedEdit,
82- proposalId: number,
83-): Promise<string> => {
84- const inner = `<h1>thanks — proposal #${proposalId} queued</h1>
85-<p>Your edit to <a href="${escape(resolved.pageUrl)}"><code>${escape(resolved.pageUrl)}</code></a> is in the review queue.</p>
86-<p class="edit-meta">The owner will review pending proposals at <code>/admin/proposals</code>. The live page won't change until they approve and apply the patch via git.</p>
87-<p><a href="${escape(resolved.pageUrl)}">← back to the page</a> · <a href="/edit/${escape(resolved.section)}/${escape(resolved.slug)}">propose another edit</a></p>`;
88- return renderPage({
89- title: `proposal #${proposalId} queued — tdd.md`,
90- bodyHtml: layoutWrap(inner),
91- noindex: true,
92- bodyClass: editBodyClass,
93- });
94-};
95-
96-// -------- admin: proposal list --------
97-
98-const statusBadge = (s: ProposalRow["status"]): string => {
99- const cls = s === "approved" ? "edit-status edit-status-approved"
100- : s === "rejected" ? "edit-status edit-status-rejected"
101- : "edit-status edit-status-pending";
102- return `<span class="${cls}">${s}</span>`;
103-};
104-
105-export const renderAdminProposalList = async (
106- proposals: ProposalRow[],
10793 viewer: string,
10894 ): Promise<string> => {
109- const rows = proposals.length === 0
110- ? `<tr><td colspan="6" class="edit-empty">no proposals yet</td></tr>`
111- : proposals.map((p) => `<tr>
112- <td>#${p.id}</td>
113- <td><a href="${escape(p.pageUrl)}"><code>${escape(p.pageUrl)}</code></a></td>
114- <td>${escape(p.author)}</td>
115- <td>${escape(ts(p.submittedAt))}</td>
116- <td>${statusBadge(p.status)}</td>
117- <td><a href="/admin/proposals/${p.id}">review →</a></td>
118-</tr>`).join("\n");
119- const inner = `<h1>proposal queue</h1>
120-<p class="edit-meta">${proposals.length} proposal${proposals.length === 1 ? "" : "s"} · signed in as <strong>${escape(viewer)}</strong>.</p>
121-<table class="edit-table">
122- <thead><tr><th>id</th><th>page</th><th>author</th><th>submitted</th><th>status</th><th></th></tr></thead>
123- <tbody>${rows}</tbody>
124-</table>`;
95+ const inner = `<h1>edit · ${escape(resolved.title)}</h1>
96+<p>Signed in as <strong>${escape(viewer)}</strong>, but editing is admin-only. Only the site owner can save changes from here.</p>
97+<p>If you'd like to suggest an edit, open an issue at <a href="https://git.tdd.md/syntaxai/tdd.md/issues" rel="noopener" target="_blank">git.tdd.md/syntaxai/tdd.md/issues</a> describing the change.</p>
98+<p><a href="${escape(resolved.pageUrl)}">← back to the page</a> · <a href="/auth/logout">log out</a></p>`;
12599 return renderPage({
126- title: "proposals — tdd.md",
100+ title: `edit · ${resolved.title} — tdd.md`,
127101 bodyHtml: layoutWrap(inner),
128102 noindex: true,
129103 bodyClass: editBodyClass,
130104 });
131105 };
132106
133-// -------- admin: single proposal review (current vs proposed) --------
107+// -------- admin direct-edit applied live --------
134108
135-export const renderAdminProposalDetail = async (
136- proposal: ProposalRow,
137- currentBody: string,
138- viewer: string,
109+export const renderEditAppliedLive = async (
110+ resolved: ResolvedEdit,
111+ commit: GitCommitOk,
139112 ): Promise<string> => {
140- const reviewedLine = proposal.status === "pending"
141- ? ""
142- : `<p class="edit-meta">${escape(proposal.status)} by ${escape(proposal.reviewedBy ?? "?")} at ${escape(ts(proposal.reviewedAt ?? 0))}${proposal.rejectReason ? ` · reason: ${escape(proposal.rejectReason)}` : ""}</p>`;
143- const actions = proposal.status === "pending"
144- ? `<form method="post" action="/admin/proposals/${proposal.id}/approve" class="edit-form-inline">
145- <button type="submit">approve</button>
146-</form>
147-<form method="post" action="/admin/proposals/${proposal.id}/reject" class="edit-form-inline">
148- <input type="text" name="reason" placeholder="reject reason (optional)" />
149- <button type="submit">reject</button>
150-</form>
151-<a class="edit-action-link" href="/admin/proposals/${proposal.id}/patch">download patch (.md)</a>`
152- : `<a class="edit-action-link" href="/admin/proposals/${proposal.id}/patch">download patch (.md)</a>`;
153- const inner = `<h1>proposal #${proposal.id} · ${escape(proposal.title)}</h1>
113+ const sha = commit.commitSha;
114+ const inner = `<h1>applied live · ${escape(resolved.title)}</h1>
115+<p>Your edit to <a href="${escape(resolved.pageUrl)}"><code>${escape(resolved.pageUrl)}</code></a> is now live <strong>and committed</strong>.</p>
154116 <p class="edit-meta">
155- Page: <a href="${escape(proposal.pageUrl)}"><code>${escape(proposal.pageUrl)}</code></a> ·
156- File: <code>${escape(proposal.editPath)}</code> ·
157- Author: <strong>${escape(proposal.author)}</strong> ·
158- Submitted: ${escape(ts(proposal.submittedAt))} ·
159- Status: ${statusBadge(proposal.status)}
117+ Commit <a href="${escape(tddCommitUrl(sha))}"><code>${escape(shortSha(sha))}</code></a>
118+ landed in the local bare repo (<code>/app/repo</code> in the container,
119+ <code>~/repos/tdd.md.git</code> on p620) via <code>git</code> plumbing.
120+ No HTTP, no Forgejo, no SSH involved — just a real git commit on disk.
160121 </p>
161-${reviewedLine}
162-<p class="edit-meta">Reviewing as <strong>${escape(viewer)}</strong>. Approving sets the status; the live page does not change until you commit the patch on dev and run a deploy.</p>
163-<div class="edit-actions edit-actions-row">${actions}</div>
164-<h2>diff (current ⇢ proposed)</h2>
165-<div class="edit-diff">
166- <div class="edit-diff-pane">
167- <p class="edit-diff-label">current</p>
168- <pre><code>${escape(currentBody)}</code></pre>
169- </div>
170- <div class="edit-diff-pane">
171- <p class="edit-diff-label">proposed</p>
172- <pre><code>${escape(proposal.body)}</code></pre>
173- </div>
174-</div>
175-<p><a href="/admin/proposals">← back to queue</a></p>`;
122+<p class="edit-note">
123+ The container's <code>content/</code> dir is copied from the working
124+ tree at image build, and the next deploy fetches new commits from the
125+ local bare repo before rebuilding — so this commit will outlive any
126+ container restart.
127+</p>
128+<p><a href="${escape(resolved.pageUrl)}">→ view the live page</a> · <a href="/edit/${escape(resolved.section)}/${escape(resolved.slug)}">edit again</a></p>`;
176129 return renderPage({
177- title: `proposal #${proposal.id} — tdd.md`,
130+ title: `applied · ${resolved.title} — tdd.md`,
178131 bodyHtml: layoutWrap(inner),
179132 noindex: true,
180133 bodyClass: editBodyClass,
181134 });
182135 };
183136
184-// -------- admin gate page (not the owner) --------
137+// -------- admin commit failed (Forgejo conflict / network / other) --------
185138
186-export const renderAdminGate = async (viewer: string | null): Promise<string> => {
187- const inner = viewer
188- ? `<h1>admin · access denied</h1><p>Signed in as <strong>${escape(viewer)}</strong>, but this area is owner-only. <a href="/">← back home</a></p>`
189- : `<h1>admin · sign in</h1><p><a class="edit-login-button" href="/auth/github/start?to=${encodeURIComponent("/admin/proposals")}">sign in with GitHub →</a></p>`;
139+export const renderEditCommitFailed = async (
140+ resolved: ResolvedEdit,
141+ failure: GitCommitFailure,
142+): Promise<string> => {
143+ const explanation =
144+ failure.kind === "conflict"
145+ ? "The branch tip moved while you were editing — someone else committed in between. Refresh the editor to load the latest version, then re-apply your change."
146+ : failure.kind === "permission"
147+ ? "The container can't write to the bare repo. Check that /home/scri/repos/tdd.md.git on p620 is mounted read-write into /app/repo."
148+ : failure.kind === "not_found"
149+ ? "The 'main' branch doesn't exist in the bare repo. Verify that ~/repos/tdd.md.git on p620 has a refs/heads/main."
150+ : "git rejected the commit for an unexpected reason. See the message below.";
151+ const inner = `<h1>commit failed · ${escape(resolved.title)}</h1>
152+<p>Your edit to <a href="${escape(resolved.pageUrl)}"><code>${escape(resolved.pageUrl)}</code></a> was <strong>not applied</strong>. The live page is unchanged.</p>
153+<p class="edit-meta">
154+ git returned <strong>${escape(failure.kind)}</strong>.
155+</p>
156+<p>${escape(explanation)}</p>
157+<details class="edit-note">
158+ <summary>git stderr</summary>
159+ <pre><code>${escape(failure.message.slice(0, 2000))}</code></pre>
160+</details>
161+<p><a href="/edit/${escape(resolved.section)}/${escape(resolved.slug)}">← back to the editor (refreshes the form)</a></p>`;
190162 return renderPage({
191- title: "admin — tdd.md",
163+ title: `commit failed · ${resolved.title} — tdd.md`,
192164 bodyHtml: layoutWrap(inner),
193165 noindex: true,
194166 bodyClass: editBodyClass,