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]>
32 files changed · +1611 −591
.gitignore
+4
−0
| @@ -7,3 +7,7 @@ node_modules/ | ||
| 7 | 7 | .claude/ |
| 8 | 8 | content/git-history/ |
| 9 | 9 | public/sama-cli |
| 10 | +playwright-report/ | |
| 11 | +test-results/ | |
| 12 | +.playwright/ | |
| 13 | +.auth/ | |
bun.lock
+9
−0
| @@ -8,19 +8,28 @@ | ||
| 8 | 8 | "marked": "^14.1.4", |
| 9 | 9 | }, |
| 10 | 10 | "devDependencies": { |
| 11 | + "@playwright/test": "^1.59.1", | |
| 11 | 12 | "@types/bun": "latest", |
| 12 | 13 | }, |
| 13 | 14 | }, |
| 14 | 15 | }, |
| 15 | 16 | "packages": { |
| 17 | + "@playwright/test": ["@playwright/[email protected]", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], | |
| 18 | + | |
| 16 | 19 | "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], |
| 17 | 20 | |
| 18 | 21 | "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], |
| 19 | 22 | |
| 20 | 23 | "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], |
| 21 | 24 | |
| 25 | + "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], | |
| 26 | + | |
| 22 | 27 | "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="], |
| 23 | 28 | |
| 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 | + | |
| 24 | 33 | "undici-types": ["[email protected]", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], |
| 25 | 34 | } |
| 26 | 35 | } |
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" | |
package.json
+4
−1
| @@ -5,12 +5,15 @@ | ||
| 5 | 5 | "module": "src/c11_server.ts", |
| 6 | 6 | "scripts": { |
| 7 | 7 | "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" | |
| 9 | 11 | }, |
| 10 | 12 | "dependencies": { |
| 11 | 13 | "marked": "^14.1.4" |
| 12 | 14 | }, |
| 13 | 15 | "devDependencies": { |
| 16 | + "@playwright/test": "^1.59.1", | |
| 14 | 17 | "@types/bun": "latest" |
| 15 | 18 | } |
| 16 | 19 | } |
public/style.css
+144
−0
| @@ -861,3 +861,147 @@ main.md table.test-stability td.test-stab-num { | ||
| 861 | 861 | @media (max-width: 900px) { |
| 862 | 862 | .edit-diff { grid-template-columns: 1fr; } |
| 863 | 863 | } |
| 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 | +} | |
scripts/p620/deploy-tdd-md.sh
+130
−70
| @@ -1,19 +1,36 @@ | ||
| 1 | 1 | #!/usr/bin/env bash |
| 2 | 2 | # Deploy de tdd.md Bun-server naar p620 (default ssh-host). |
| 3 | 3 | # |
| 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. | |
| 10 | 9 | # |
| 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. | |
| 13 | 28 | # |
| 14 | 29 | # 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 | |
| 17 | 34 | |
| 18 | 35 | set -euo pipefail |
| 19 | 36 | |
| @@ -21,57 +38,121 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 21 | 38 | REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" |
| 22 | 39 | |
| 23 | 40 | 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 | |
| 25 | 43 | IMAGE_TAG="localhost/tdd-md:latest" |
| 44 | +MODE="git" # git | rsync | bootstrap | |
| 26 | 45 | |
| 27 | 46 | while [[ $# -gt 0 ]]; do |
| 28 | 47 | 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 ;; | |
| 32 | 53 | esac |
| 33 | 54 | done |
| 34 | 55 | |
| 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; } | |
| 38 | 59 | |
| 39 | 60 | need_restart=0 |
| 40 | 61 | |
| 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 | |
| 71 | 153 | |
| 72 | 154 | 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}'") | |
| 75 | 156 | existing_label=$(ssh "$SSH_HOST" "podman image inspect $IMAGE_TAG --format '{{index .Labels \"src-hash\"}}' 2>/dev/null || true") |
| 76 | 157 | |
| 77 | 158 | if [[ "$src_hash" != "$existing_label" ]]; then |
| @@ -101,27 +182,6 @@ sync_quadlet() { | ||
| 101 | 182 | sync_quadlet tdd.pod |
| 102 | 183 | sync_quadlet tdd-md.container |
| 103 | 184 | |
| 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 | - | |
| 125 | 185 | echo "→ systemd apply (need_restart=$need_restart)" |
| 126 | 186 | ssh "$SSH_HOST" 'systemctl --user daemon-reload' |
| 127 | 187 | if [[ "$need_restart" -eq 1 ]]; then |
scripts/p620/tdd-md.container
+10
−0
| @@ -20,8 +20,18 @@ Environment=BASE_URL=https://tdd.md | ||
| 20 | 20 | Volume=tdd-md-data:/app/data:Z |
| 21 | 21 | Environment=TDD_DB_PATH=/app/data/runs.db |
| 22 | 22 | |
| 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 | + | |
| 23 | 31 | # Praat met Forgejo via host-network (Forgejo publisht :44400 op de host). |
| 24 | 32 | # 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. | |
| 25 | 35 | Environment=FORGEJO_URL=http://host.containers.internal:44400 |
| 26 | 36 | |
| 27 | 37 | # GitHub OAuth client_id is publiek (verschijnt sowieso in redirect URLs); |
src/c13_database.ts
+10
−122
| @@ -35,23 +35,17 @@ const getDb = (): Database => { | ||
| 35 | 35 | ); |
| 36 | 36 | CREATE INDEX IF NOT EXISTS idx_projects_registered_by |
| 37 | 37 | 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); | |
| 54 | 38 | `); |
| 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 | + | |
| 55 | 49 | return db; |
| 56 | 50 | }; |
| 57 | 51 | |
| @@ -210,112 +204,6 @@ export const listActiveProjects = (): ProjectRow[] => { | ||
| 210 | 204 | return rows.map(rowToProject); |
| 211 | 205 | }; |
| 212 | 206 | |
| 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 | - | |
| 319 | 207 | // Latest verdict per (owner, repo) across all agents — drives the |
| 320 | 208 | // leaderboard and the /agents index. |
| 321 | 209 | export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => { |
src/c14_forgejo.ts
+8
−0
| @@ -265,6 +265,14 @@ export const registerAgent = async (params: { | ||
| 265 | 265 | }; |
| 266 | 266 | }; |
| 267 | 267 | |
| 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 | + | |
| 268 | 276 | // --------------------------------------------------------------------- |
| 269 | 277 | // Read-side helpers used by c21 handlers + c51 rendering. |
| 270 | 278 | // --------------------------------------------------------------------- |
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 | +}; | |
src/c21_app.ts
+12
−12
| @@ -58,13 +58,8 @@ import { | ||
| 58 | 58 | samaSlugHandler, |
| 59 | 59 | } from "./c21_handlers_sama.ts"; |
| 60 | 60 | 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"; | |
| 68 | 63 | |
| 69 | 64 | const HOME_MD = "./content/home.md"; |
| 70 | 65 | const GAME_DIR = "./content/games"; |
| @@ -651,11 +646,16 @@ ${rows} | ||
| 651 | 646 | |
| 652 | 647 | "/edit/:section/:slug": editPageHandler, |
| 653 | 648 | |
| 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, | |
| 659 | 659 | |
| 660 | 660 | "/auth/github/start": (req) => startGithubOauth(req), |
| 661 | 661 | |
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 | -}; | |
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 | +}; | |
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. | |
| 7 | 6 | |
| 8 | 7 | import { renderNotFound, htmlResponse } from "./c51_render_layout.ts"; |
| 9 | 8 | import { getViewer } from "./c32_session.ts"; |
| 10 | -import { resolveEdit } from "./c32_edit_resolve.ts"; | |
| 9 | +import { resolveEdit, type ResolvedEdit } from "./c32_edit_resolve.ts"; | |
| 11 | 10 | 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"; | |
| 17 | 22 | import { |
| 18 | 23 | renderEditFormPage, |
| 19 | 24 | renderEditLoginWall, |
| 20 | - renderEditThanks, | |
| 25 | + renderEditNonAdminWall, | |
| 26 | + renderEditAppliedLive, | |
| 27 | + renderEditCommitFailed, | |
| 21 | 28 | } from "./c51_render_edit.ts"; |
| 22 | 29 | |
| 23 | 30 | const readCurrentBody = async (filePath: string): Promise<string | null> => { |
| @@ -26,6 +33,14 @@ const readCurrentBody = async (filePath: string): Promise<string | null> => { | ||
| 26 | 33 | return await file.text(); |
| 27 | 34 | }; |
| 28 | 35 | |
| 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 | + | |
| 29 | 44 | // GET + POST /edit/:section/:slug — single handler, branches on method. |
| 30 | 45 | export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise<Response> => { |
| 31 | 46 | const resolved = resolveEdit(req.params.section, req.params.slug); |
| @@ -40,35 +55,62 @@ export const editPageHandler = async (req: Request & { params: { section: string | ||
| 40 | 55 | return htmlResponse(html, 401); |
| 41 | 56 | } |
| 42 | 57 | |
| 58 | + if (viewer !== ADMIN_USERNAME) { | |
| 59 | + const html = await renderEditNonAdminWall(resolved, viewer); | |
| 60 | + return htmlResponse(html, 403); | |
| 61 | + } | |
| 62 | + | |
| 43 | 63 | if (req.method === "POST") { |
| 44 | 64 | 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 | + } | |
| 46 | 74 | 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. | |
| 50 | 78 | return new Response(null, { |
| 51 | 79 | status: 303, |
| 52 | 80 | headers: { Location: `/edit/${resolved.section}/${resolved.slug}` }, |
| 53 | 81 | }); |
| 54 | 82 | } |
| 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({ | |
| 60 | 97 | title: resolved.title, |
| 61 | - body, | |
| 62 | 98 | 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); | |
| 70 | 111 | } |
| 71 | - const html = await renderEditThanks(resolved, id); | |
| 112 | + await applyLiveEdit(resolved, body); | |
| 113 | + const html = await renderEditAppliedLive(resolved, outcome); | |
| 72 | 114 | return htmlResponse(html); |
| 73 | 115 | } |
| 74 | 116 | |
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 | +}; | |
src/c31_blog.ts
+6
−0
| @@ -12,6 +12,12 @@ export interface BlogEntry { | ||
| 12 | 12 | } |
| 13 | 13 | |
| 14 | 14 | 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 | + }, | |
| 15 | 21 | { |
| 16 | 22 | slug: "from-rules-to-checks", |
| 17 | 23 | title: "From rules to checks: shipping what the corpus post promised", |
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 | +}); | |
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`; | |
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 | +}); | |
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 | +}; | |
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 | +}); | |
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; | |
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 | +}); | |
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 | +}; | |
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 | -}); | |
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; | |
src/c31_site_config.ts
+2
−6
| @@ -9,11 +9,7 @@ export const LIVE_REPO_NAME = "tdd.md"; | ||
| 9 | 9 | // in-container git-history bundle. |
| 10 | 10 | export const LIVE_FETCH_COUNT = 100; |
| 11 | 11 | |
| 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. | |
| 13 | 14 | // Override per-environment via TDD_ADMIN_USER if needed. |
| 14 | 15 | 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"; | |
src/c32_edit_resolve.test.ts
+14
−0
| @@ -42,3 +42,17 @@ test("rejects unsafe slug shapes", () => { | ||
| 42 | 42 | expect(resolveEdit("sama", "with space")).toBeNull(); |
| 43 | 43 | expect(resolveEdit("sama", "-leading-dash")).toBeNull(); |
| 44 | 44 | }); |
| 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 | +}); | |
src/c32_edit_resolve.ts
+17
−7
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | import { ALL_SAMA } from "./c31_sama.ts"; |
| 13 | 13 | import { ALL_GUIDES } from "./c31_guides.ts"; |
| 14 | 14 | import { ALL_POSTS } from "./c31_blog.ts"; |
| 15 | +import { SITE_NAV } from "./c31_docs_nav.ts"; | |
| 15 | 16 | |
| 16 | 17 | export type EditableSection = "sama" | "guides" | "blog"; |
| 17 | 18 | |
| @@ -32,15 +33,24 @@ const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/; | ||
| 32 | 33 | const lookupTitle = (section: EditableSection, slug: string): string | null => { |
| 33 | 34 | if (section === "sama") { |
| 34 | 35 | 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") { | |
| 38 | 38 | 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; | |
| 40 | 43 | } |
| 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; | |
| 44 | 54 | }; |
| 45 | 55 | |
| 46 | 56 | export const resolveEdit = (section: string, slug: string): ResolvedEdit | null => { |
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"><${escape(detail.authorEmail)}></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 | +}; | |
src/c51_render_docs_layout.ts
+5
−3
| @@ -19,7 +19,6 @@ import { | ||
| 19 | 19 | escape, |
| 20 | 20 | type PageOptions, |
| 21 | 21 | } from "./c51_render_layout.ts"; |
| 22 | -import { SELF_HOSTED_REPO_BLOB_BASE } from "./c31_site_config.ts"; | |
| 23 | 22 | |
| 24 | 23 | export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> { |
| 25 | 24 | // 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 | ||
| 76 | 75 | |
| 77 | 76 | const renderEditLink = (editPath: string | null): string => { |
| 78 | 77 | 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. | |
| 80 | 81 | const ss = sectionSlugFromEditPath(editPath); |
| 82 | + const sourceHref = ss ? `/content/${ss.section}/${ss.slug}.md` : `/${editPath}`; | |
| 81 | 83 | const editHref = ss ? `/edit/${ss.section}/${ss.slug}` : null; |
| 82 | 84 | const editAnchor = editHref |
| 83 | 85 | ? `<a href="${escape(editHref)}">propose an edit →</a> · ` |
| 84 | 86 | : ""; |
| 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>`; | |
| 86 | 88 | }; |
| 87 | 89 | |
| 88 | 90 | const renderPrevNext = (loc: ResolvedDocsLocation | null): string => { |
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). | |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | 7 | renderPage, |
| 8 | 8 | escape, |
| 9 | 9 | } from "./c51_render_layout.ts"; |
| 10 | -import type { ProposalRow } from "./c13_database.ts"; | |
| 11 | 10 | 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"; | |
| 14 | 15 | |
| 15 | 16 | const layoutWrap = (innerHtml: string): string => |
| 16 | 17 | `<main class="md edit-page"><div class="edit-container">${innerHtml}</div></main>`; |
| @@ -19,7 +20,16 @@ const layoutWrap = (innerHtml: string): string => | ||
| 19 | 20 | // full-width form controls, not the doc-layout's three columns. |
| 20 | 21 | const editBodyClass = "edit-body"; |
| 21 | 22 | |
| 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 -------- | |
| 23 | 33 | |
| 24 | 34 | export const renderEditFormPage = async ( |
| 25 | 35 | resolved: ResolvedEdit, |
| @@ -29,26 +39,27 @@ export const renderEditFormPage = async ( | ||
| 29 | 39 | const inner = `<h1>edit · ${escape(resolved.title)}</h1> |
| 30 | 40 | <p class="edit-meta"> |
| 31 | 41 | 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. | |
| 33 | 44 | <a href="${escape(resolved.pageUrl)}">view the live page</a> · |
| 34 | 45 | <a href="/auth/logout">log out</a> |
| 35 | 46 | </p> |
| 36 | 47 | <form method="post" action="/edit/${escape(resolved.section)}/${escape(resolved.slug)}" class="edit-form"> |
| 37 | 48 | <textarea name="body" class="edit-textarea" rows="32" spellcheck="false">${escape(currentBody)}</textarea> |
| 38 | 49 | <div class="edit-actions"> |
| 39 | - <button type="submit">submit proposal</button> | |
| 50 | + <button type="submit">save (commit + live)</button> | |
| 40 | 51 | <a class="edit-cancel" href="${escape(resolved.pageUrl)}">cancel</a> |
| 41 | 52 | </div> |
| 42 | 53 | </form> |
| 43 | 54 | <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. | |
| 47 | 58 | </p>`; |
| 48 | 59 | return renderPage({ |
| 49 | 60 | title: `edit · ${resolved.title} — tdd.md`, |
| 50 | 61 | 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.`, | |
| 52 | 63 | ogPath: `https://tdd.md/edit/${resolved.section}/${resolved.slug}`, |
| 53 | 64 | noindex: true, |
| 54 | 65 | bodyClass: editBodyClass, |
| @@ -62,133 +73,94 @@ export const renderEditLoginWall = async ( | ||
| 62 | 73 | ): Promise<string> => { |
| 63 | 74 | const returnTo = `/edit/${resolved.section}/${resolved.slug}`; |
| 64 | 75 | 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> | |
| 66 | 77 | <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> | |
| 68 | 79 | <p><a href="${escape(resolved.pageUrl)}">← back to the page</a></p>`; |
| 69 | 80 | return renderPage({ |
| 70 | 81 | title: `sign in to edit · ${resolved.title} — tdd.md`, |
| 71 | 82 | 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.`, | |
| 73 | 84 | noindex: true, |
| 74 | 85 | bodyClass: editBodyClass, |
| 75 | 86 | }); |
| 76 | 87 | }; |
| 77 | 88 | |
| 78 | -// -------- thank-you page after submit -------- | |
| 89 | +// -------- non-admin signed-in wall -------- | |
| 79 | 90 | |
| 80 | -export const renderEditThanks = async ( | |
| 91 | +export const renderEditNonAdminWall = async ( | |
| 81 | 92 | 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[], | |
| 107 | 93 | viewer: string, |
| 108 | 94 | ): 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>`; | |
| 125 | 99 | return renderPage({ |
| 126 | - title: "proposals — tdd.md", | |
| 100 | + title: `edit · ${resolved.title} — tdd.md`, | |
| 127 | 101 | bodyHtml: layoutWrap(inner), |
| 128 | 102 | noindex: true, |
| 129 | 103 | bodyClass: editBodyClass, |
| 130 | 104 | }); |
| 131 | 105 | }; |
| 132 | 106 | |
| 133 | -// -------- admin: single proposal review (current vs proposed) -------- | |
| 107 | +// -------- admin direct-edit applied live -------- | |
| 134 | 108 | |
| 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, | |
| 139 | 112 | ): 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> | |
| 154 | 116 | <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. | |
| 160 | 121 | </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>`; | |
| 176 | 129 | return renderPage({ |
| 177 | - title: `proposal #${proposal.id} — tdd.md`, | |
| 130 | + title: `applied · ${resolved.title} — tdd.md`, | |
| 178 | 131 | bodyHtml: layoutWrap(inner), |
| 179 | 132 | noindex: true, |
| 180 | 133 | bodyClass: editBodyClass, |
| 181 | 134 | }); |
| 182 | 135 | }; |
| 183 | 136 | |
| 184 | -// -------- admin gate page (not the owner) -------- | |
| 137 | +// -------- admin commit failed (Forgejo conflict / network / other) -------- | |
| 185 | 138 | |
| 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>`; | |
| 190 | 162 | return renderPage({ |
| 191 | - title: "admin — tdd.md", | |
| 163 | + title: `commit failed · ${resolved.title} — tdd.md`, | |
| 192 | 164 | bodyHtml: layoutWrap(inner), |
| 193 | 165 | noindex: true, |
| 194 | 166 | bodyClass: editBodyClass, |