code.pablomurad.com

patchwork

Publishes the revisions of your pages as a feed, with notes and readable diffs, written in Go

Publish revisions as a JSON API, Atom, JSON Feed and terminal text

The store now becomes something readers and sites can use. One build
renders every file; `serve` serves it over HTTP and rebuilds after each
scan that changed something, and `export` writes the same bytes to a
directory.

- render: public model (retracted revisions and removed pages left
  out), the v1 JSON API, Atom and JSON Feed entries marked up with
  <del>/<ins>, and plain or ANSI text for curl
- server: routes for slugs with slashes, .json aliases, ETag and 304,
  JSON errors, /healthz
- export: atomic swap of the output directory
- engine: retract (later diffs are rebased), amend, rebuild-diffs,
  backfill of older copies
- schema/v1: strict JSON Schema for the API, checked against every
  generated file
- cli: serve, export, retract, amend, snapshot, rebuild-diffs, backup,
  health
- excerpts sum up long runs of deleted blocks, so a rewrite's new text
  isn't cut off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
author
Pablo Murad
date
commit
973b474c5406570d567e9a1f6daebfef25bbeb74
parent
f18feed50581a95104287ccd4a42e411f9743177
changes
51 files +3649 −22
@@ -4,7 +4,7 @@ Your RSS feed tells readers what you published. Patchwork tells them what you changed. 
 Patchwork keeps the version history of the pages you revise and publishes each revision you choose to announce as a feed entry. An entry carries your note, the kind of change, how grown the page is, and a readable diff of the passages that changed. It's meant for digital gardens, living notes and documentation: pages that keep growing after their first publication.
 
-> Status: early development. The core (versions, diffs, publish rules) is being built; feeds and the HTTP API come next. Nothing here is stable yet.
+> Status: early development. Folder mode, the feeds and the HTTP API work; git mode and a CI action are next. The v1 JSON contract may still change before the first release.
 
 ## How it works
 
@@ -19,20 +19,86 @@ maturity: sprout          # seed | sprout | bloom ---
 ```
 
-Edits without a new note are kept as history and never reach the feed, so fixing a typo makes no noise. Several quiet edits followed by one with a note show up as a single revision, diffed against the last one you published.
+The rules, in short:
 
-Patchwork then serves (or exports as files):
+- The first time Patchwork sees a file, it records version 1. That version is never announced.
+- Edits without a new note are kept as history and never reach the feed, so fixing a typo makes no noise.
+- A new note publishes a revision, diffed against the last one you published. Several quiet edits followed by one with a note show up as a single revision.
+- Changing only the note rewrites the last revision's note instead of adding an entry.
+- An upload with the same note within 20 minutes of publishing is folded into that revision (the typo you spot right after uploading).
+- A page leaves the feeds when its file is missing on two scans in a row.
 
-- an Atom feed and a JSON Feed of revisions, for the whole site and for each page;
-- a JSON API with each page's history, every published version, and structured diffs your site can render in its own style.
+Mark a block that changes on its own, like a "last checked" line, so it never counts as a change:
 
-The first site using it is [moonflowersociety.org](https://moonflowersociety.org), a nocturnal garden of ideas.
+```markdown
+<!-- patchwork:ignore -->
+Last checked: 2026-10-20
+<!-- /patchwork:ignore -->
+```
 
 ## Install
 
 ```sh
 go install git.pablomurad.com/pablo/patchwork/cmd/patchwork@latest
 ```
+
+Patchwork is one binary with no cgo. SQLite is compiled in.
+
+## Quick start
+
+```sh
+patchwork init                 # writes a commented patchwork.yaml
+patchwork doctor               # checks the config, the content and the database
+patchwork scan                 # records version 1 of every page
+patchwork serve                # watches the folder and serves the API and feeds
+```
+
+Keep the database on a local disk. SQLite's locking isn't safe on network shares, which is why the default path is in your user cache directory.
+
+## Commands
+
+| Command | What it does |
+| --- | --- |
+| `init` | Write a commented `patchwork.yaml`. |
+| `scan [-dry-run]` | Scan the folder once and apply the publish rules. `-dry-run` shows what would happen. |
+| `watch` | Keep scanning as files change (file events, plus polling). |
+| `serve` | `watch`, and serve the API and feeds over HTTP. |
+| `export -out DIR` | Write the API and feeds as static files. |
+| `log <page>` | A page's public history. |
+| `diff <page> [n \| a b]` | What changed in a revision, in color. |
+| `retract [-undo] <page> <n>` | Hide a published revision from the feeds and the API. |
+| `amend -note TEXT <page> <n>` | Rewrite a published revision's note or kind. |
+| `snapshot -path P -file F -at T` | Import an older copy of a page, oldest first, to build history from before Patchwork. |
+| `rebuild-diffs [-force]` | Recompute cached diffs. |
+| `backup -to FILE` | Copy the database. |
+| `doctor` | Check everything without changing anything. |
+| `health` | Exit 0 if a running `serve` answers; for container health checks. |
+
+`<page>` is a slug or a path inside the content folder.
+
+## What it publishes
+
+`serve` and `export` publish the same files:
+
+| Path | Content |
+| --- | --- |
+| `/api/v1/pages` | Every page, with its maturity and revision count. |
+| `/api/v1/pages/{slug}` | One page and its public history. |
+| `/api/v1/pages/{slug}/versions/{n}` | One version: its Markdown body and its full diff against the previous version. |
+| `/api/v1/revisions` | Recent revisions across the site. |
+| `/feeds/revisions.atom`, `/feeds/revisions.json` | Atom and JSON Feed of revisions. |
+| `/feeds/pages/{slug}.atom`, `.json` | The same, for one page. |
+| `/text/revisions`, `/text/pages/{slug}/history`, `/text/pages/{slug}/versions/{n}` | Plain text for terminals, colored by default; `?plain` and `?w=100` adjust it. |
+
+API paths also answer with a `.json` suffix. Responses carry an ETag and answer conditional requests with 304. The JSON shapes are described by the JSON Schema files in [`schema/v1`](schema/v1).
+
+Diffs come as structured segments (`eq`, `del`, `ins`) for your site to render in its own style. Feed entries use `<del>` and `<ins>`, which keep their meaning in feed readers that strip CSS.
+
+Configure `site.base_url` and the public feed paths in `patchwork.yaml`, so feeds link to your site and not to Patchwork's own address. Pick `site.tag_authority` once: it's part of every feed entry id, and changing it later makes readers see every entry as new.
+
+## First site
+
+[moonflowersociety.org](https://moonflowersociety.org), a nocturnal garden of ideas.
 
 ## License
 
@@ -0,0 +1,173 @@+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// pageAndNumber parses "<slug|path> <n>" arguments.
+func (a *app) pageAndNumber(ctx context.Context, args []string) (int64, int, error) {
+	if len(args) != 2 {
+		return 0, 0, fmt.Errorf("want <slug|path> <version number>")
+	}
+	p, err := a.findPage(ctx, args[0])
+	if err != nil {
+		return 0, 0, err
+	}
+	n, err := strconv.Atoi(strings.TrimPrefix(args[1], "#"))
+	if err != nil {
+		return 0, 0, fmt.Errorf("version %q is not a number", args[1])
+	}
+	return p.ID, n, nil
+}
+
+func cmdRetract(ctx context.Context, args []string) error {
+	fs := newFlags("retract", "[-undo] <slug|path> <n>")
+	cfgPath := configFlag(fs)
+	undo := fs.Bool("undo", false, "publish a retracted revision again")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	a, err := openApp(*cfgPath)
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	id, n, err := a.pageAndNumber(ctx, fs.Args())
+	if err != nil {
+		return err
+	}
+	if err := a.engine.Retract(ctx, id, n, *undo); err != nil {
+		return err
+	}
+	if *undo {
+		fmt.Fprintf(stdout, "#%d is public again\n", n)
+	} else {
+		fmt.Fprintf(stdout, "#%d retracted: it's gone from the feeds and the API. Its text stays in the database; the current page is unchanged.\n", n)
+	}
+	return nil
+}
+
+func cmdAmend(ctx context.Context, args []string) error {
+	fs := newFlags("amend", "-note TEXT [-kind KIND] <slug|path> <n>")
+	cfgPath := configFlag(fs)
+	note := fs.String("note", "", "new revision note")
+	kind := fs.String("kind", "", "new kind: correction, expansion, position or reorganization")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if *note == "" && *kind == "" {
+		fs.Usage()
+		return errProblems
+	}
+	a, err := openApp(*cfgPath)
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	id, n, err := a.pageAndNumber(ctx, fs.Args())
+	if err != nil {
+		return err
+	}
+	if err := a.engine.Amend(ctx, id, n, *note, *kind); err != nil {
+		return err
+	}
+	fmt.Fprintf(stdout, "#%d amended. The note in the file still wins on its next upload, so change it there too.\n", n)
+	return nil
+}
+
+func cmdSnapshot(ctx context.Context, args []string) error {
+	fs := newFlags("snapshot", "-path RELPATH -file COPY -at TIME")
+	cfgPath := configFlag(fs)
+	rel := fs.String("path", "", "the page's path inside the content folder")
+	file := fs.String("file", "", "an older copy of that page")
+	at := fs.String("at", "", "when that copy was current (RFC 3339, or YYYY-MM-DD)")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if *rel == "" || *file == "" || *at == "" {
+		fs.Usage()
+		return errProblems
+	}
+	a, err := openApp(*cfgPath)
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	t, err := time.Parse(time.RFC3339, *at)
+	if err != nil {
+		t, err = time.ParseInLocation("2006-01-02", *at, a.cfg.Location())
+		if err != nil {
+			return fmt.Errorf("-at %q: want RFC 3339 or YYYY-MM-DD", *at)
+		}
+	}
+	raw, err := os.ReadFile(*file)
+	if err != nil {
+		return err
+	}
+	res, err := a.engine.Backfill(ctx, path.Clean(strings.ReplaceAll(*rel, `\`, "/")), raw, t)
+	if err != nil {
+		return err
+	}
+	fmt.Fprintln(stdout, formatResult(res))
+	for _, w := range res.Warnings {
+		fmt.Fprintf(os.Stderr, "  warning: %s\n", w)
+	}
+	return nil
+}
+
+func cmdRebuildDiffs(ctx context.Context, args []string) error {
+	fs := newFlags("rebuild-diffs", "[-force] [slug|path]")
+	cfgPath := configFlag(fs)
+	force := fs.Bool("force", false, "recompute every diff, not only outdated ones")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	a, err := openApp(*cfgPath)
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	var id int64
+	if fs.NArg() == 1 {
+		p, err := a.findPage(ctx, fs.Arg(0))
+		if err != nil {
+			return err
+		}
+		id = p.ID
+	}
+	n, err := a.engine.RebuildDiffs(ctx, id, *force)
+	if err != nil {
+		return err
+	}
+	fmt.Fprintf(stdout, "%d revision diff(s) updated\n", n)
+	return nil
+}
+
+func cmdBackup(ctx context.Context, args []string) error {
+	fs := newFlags("backup", "-to FILE")
+	cfgPath := configFlag(fs)
+	to := fs.String("to", "", "file to write (must not exist)")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if *to == "" {
+		fs.Usage()
+		return errProblems
+	}
+	a, err := openApp(*cfgPath)
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	if err := a.store.Backup(ctx, *to); err != nil {
+		return err
+	}
+	fmt.Fprintf(stdout, "backed up %s to %s\n", a.cfg.DB, *to)
+	return nil
+}
@@ -26,13 +26,21 @@ Usage:   patchwork <command> [flags]
 
 Commands:
-  init      write a commented patchwork.yaml
-  scan      scan the content folder once and apply the publish rules
-  watch     keep scanning as files change
-  log       show a page's public history
-  diff      show what changed in a revision
-  doctor    check the config, the content and the database
-  version   print the version
+  init           write a commented patchwork.yaml
+  scan           scan the content folder once and apply the publish rules
+  watch          keep scanning as files change
+  serve          watch, and serve the API and feeds over HTTP
+  export         write the API and feeds to a directory
+  log            show a page's public history
+  diff           show what changed in a revision
+  retract        hide a published revision (or bring it back with -undo)
+  amend          rewrite a published revision's note or kind
+  snapshot       import an older copy of a page, to build history
+  rebuild-diffs  recompute cached diffs
+  backup         copy the database
+  doctor         check the config, the content and the database
+  health         check that a running serve answers (for containers)
+  version        print the version
 
 Every command except init and version reads patchwork.yaml from the
 current directory, or the file named by --config or PATCHWORK_CONFIG.
@@ -62,6 +70,22 @@ case "diff": 		err = cmdDiff(ctx, args)
 	case "doctor":
 		err = cmdDoctor(ctx, args)
+	case "serve":
+		err = cmdServe(ctx, args)
+	case "export":
+		err = cmdExport(ctx, args)
+	case "retract":
+		err = cmdRetract(ctx, args)
+	case "amend":
+		err = cmdAmend(ctx, args)
+	case "snapshot":
+		err = cmdSnapshot(ctx, args)
+	case "rebuild-diffs":
+		err = cmdRebuildDiffs(ctx, args)
+	case "backup":
+		err = cmdBackup(ctx, args)
+	case "health":
+		err = cmdHealth(args)
 	case "version", "-v", "--version":
 		fmt.Println("patchwork", version)
 	case "help", "-h", "--help":
@@ -0,0 +1,163 @@+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+	"time"
+
+	"golang.org/x/sync/errgroup"
+
+	"git.pablomurad.com/pablo/patchwork/internal/config"
+	"git.pablomurad.com/pablo/patchwork/internal/engine"
+	"git.pablomurad.com/pablo/patchwork/internal/export"
+	"git.pablomurad.com/pablo/patchwork/internal/render"
+	"git.pablomurad.com/pablo/patchwork/internal/server"
+	"git.pablomurad.com/pablo/patchwork/internal/source/folder"
+)
+
+// cmdServe watches the content folder and serves the API and feeds,
+// rebuilding them after every scan that changed something.
+func cmdServe(ctx context.Context, args []string) error {
+	fs := newFlags("serve", "[-addr host:port] [-poll-only]")
+	cfgPath := configFlag(fs)
+	addr := fs.String("addr", "", "listen address (default: http.addr from the config)")
+	pollOnly := fs.Bool("poll-only", false, "don't use file events")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	a, err := openApp(*cfgPath)
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	if *addr == "" {
+		*addr = a.cfg.HTTP.Addr
+	}
+
+	srv := server.New(a.log)
+	rebuild := func() {
+		start := time.Now()
+		t, err := render.Build(ctx, a.store, a.cfg)
+		if err != nil {
+			a.log.Error("build failed; still serving the previous build", "err", err)
+			return
+		}
+		srv.Swap(t)
+		a.log.Info("published", "files", len(t.Files), "revisions", len(t.Model.Revisions), "took", time.Since(start).Round(time.Millisecond))
+	}
+
+	r := a.runner()
+	r.PollOnly = *pollOnly
+	g, ctx := errgroup.WithContext(ctx)
+	g.Go(func() error {
+		first := true
+		err := r.Watch(ctx, a.cfg.Watch.Debounce.Duration, a.cfg.Watch.Poll.Duration, func(rep folder.Report, err error) {
+			if err != nil {
+				a.log.Error("scan failed", "err", err)
+				return
+			}
+			printReport(rep, false)
+			if first || changed(rep) {
+				rebuild()
+			}
+			first = false
+		})
+		if errors.Is(err, context.Canceled) {
+			return nil
+		}
+		return err
+	})
+	g.Go(func() error {
+		a.log.Info("serving", "addr", *addr, "source", a.cfg.Source.Path, "db", a.cfg.DB)
+		return srv.ListenAndServe(ctx, *addr)
+	})
+	return g.Wait()
+}
+
+func changed(rep folder.Report) bool {
+	for _, r := range rep.Results {
+		if r.Action != engine.Unchanged || r.Restored {
+			return true
+		}
+	}
+	return false
+}
+
+func cmdExport(ctx context.Context, args []string) error {
+	fs := newFlags("export", "-out DIR [-scan]")
+	cfgPath := configFlag(fs)
+	out := fs.String("out", "", "directory to write (replaced as a whole)")
+	scan := fs.Bool("scan", false, "scan the content folder first")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if *out == "" {
+		fs.Usage()
+		return errProblems
+	}
+	a, err := openApp(*cfgPath)
+	if err != nil {
+		return err
+	}
+	defer a.Close()
+	if *scan {
+		r := a.runner()
+		r.Settle = 0
+		rep, err := r.Scan(ctx, false)
+		if err != nil {
+			return err
+		}
+		printReport(rep, false)
+	}
+	t, err := render.Build(ctx, a.store, a.cfg)
+	if err != nil {
+		return err
+	}
+	if err := export.Write(t, *out); err != nil {
+		return err
+	}
+	fmt.Fprintf(stdout, "wrote %d files to %s\n", len(t.Files), *out)
+	return nil
+}
+
+// cmdHealth exits 0 when a running `patchwork serve` answers /healthz. It
+// exists for container health checks, where images have no curl.
+func cmdHealth(args []string) error {
+	fs := newFlags("health", "[-url http://127.0.0.1:8080/healthz]")
+	cfgPath := configFlag(fs)
+	url := fs.String("url", "", "health URL (default: built from http.addr)")
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if *url == "" {
+		// Load applies PATCHWORK_HTTP_ADDR, so the env wins over the file.
+		addr := config.Default().HTTP.Addr
+		if cfg, err := config.Load(*cfgPath); err == nil {
+			addr = cfg.HTTP.Addr
+		} else if env := os.Getenv("PATCHWORK_HTTP_ADDR"); env != "" {
+			addr = env
+		}
+		host, port, err := net.SplitHostPort(addr)
+		if err != nil {
+			return err
+		}
+		if host == "" || host == "0.0.0.0" || host == "::" {
+			host = "127.0.0.1"
+		}
+		*url = "http://" + net.JoinHostPort(host, port) + "/healthz"
+	}
+	c := &http.Client{Timeout: 3 * time.Second}
+	resp, err := c.Get(*url)
+	if err != nil {
+		return err
+	}
+	resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("%s: %s", *url, resp.Status)
+	}
+	return nil
+}
M go.mod go.mod
@@ -3,18 +3,23 @@ go 1.26.4
 
 require (
+	github.com/fsnotify/fsnotify v1.10.1
+	github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
+	github.com/yuin/goldmark v1.8.6
+	golang.org/x/sync v0.24.0
+	golang.org/x/text v0.43.0
+	gopkg.in/yaml.v3 v3.0.1
+	modernc.org/sqlite v1.61.0
+)
+
+require (
 	github.com/dustin/go-humanize v1.1.0 // indirect
-	github.com/fsnotify/fsnotify v1.10.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/mattn/go-isatty v0.0.24 // indirect
 	github.com/ncruces/go-strftime v1.1.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
-	github.com/yuin/goldmark v1.8.6 // indirect
 	golang.org/x/sys v0.49.0 // indirect
-	golang.org/x/text v0.43.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 	modernc.org/libc v1.79.0 // indirect
 	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.12.1 // indirect
-	modernc.org/sqlite v1.61.0 // indirect
 )
M go.sum go.sum
@@ -1,29 +1,64 @@+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
 github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
 github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
 github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
 github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
 github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
 github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
 golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
 golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
 golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
 golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
+golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
+golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
+modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
+modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
+modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
 modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
 modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
 modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
 modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
 modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
 modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
 modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
 modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -30,6 +30,9 @@ Replaced bool `json:"replaced,omitempty"` 	From     *int `json:"from,omitempty"`
 	To       *int `json:"to,omitempty"`
 	Count    int  `json:"count,omitempty"`
+	// Removed marks a gap that stands for deleted blocks an excerpt left
+	// out, rather than unchanged ones.
+	Removed bool `json:"removed,omitempty"`
 }
 
 // Stats summarize a diff.
@@ -193,6 +193,18 @@ t.Fatalf("got %s cut=%v", opsString(got), cut) 	}
 }
 
+func TestExcerptFoldsLongDeletions(t *testing.T) {
+	var ops []Op
+	for i := 0; i < 6; i++ {
+		ops = append(ops, Op{Op: OpDelete})
+	}
+	ops = append(ops, Op{Op: OpInsert})
+	got, cut := Excerpt(ops, 1, 3)
+	if cut || opsString(got) != "delete,delete,gap,insert" || !got[2].Removed || got[2].Count != 4 {
+		t.Fatalf("got %s %+v cut=%v", opsString(got), got, cut)
+	}
+}
+
 func TestMeta(t *testing.T) {
 	opt := doc.DefaultOptions()
 	a := doc.Parse([]byte("---\ntitle: Old\ntags: [a, b]\nmaturity: seed\n---\nx"), opt)
@@ -1,7 +1,13 @@ package diff
 
+// keepDeleted is how many blocks of a run of deletions an excerpt shows
+// before summing up the rest. In a rewrite, a long run of removed
+// paragraphs would otherwise use up the excerpt before the new text.
+const keepDeleted = 2
+
 // Excerpt keeps the changed ops of a full diff, with up to context unchanged
-// blocks around each, and folds the other unchanged runs into gap ops. It
+// blocks around each, and folds the other unchanged runs into gap ops. Runs
+// of deleted blocks longer than keepDeleted are cut to a "removed" gap. It
 // stops after maxChanged changed ops and reports whether it cut anything.
 func Excerpt(ops []Op, context, maxChanged int) ([]Op, bool) {
 	keep := make([]bool, len(ops))
@@ -21,11 +27,24 @@ } 	}
 
 	var out []Op
-	gap, changed := 0, 0
+	gap, changed, deleted := 0, 0, 0
 	for i, op := range ops {
 		if !keep[i] {
 			gap++
 			continue
+		}
+		if op.Op == OpDelete {
+			deleted++
+			if deleted > keepDeleted {
+				if n := len(out); n > 0 && out[n-1].Op == OpGap && out[n-1].Removed {
+					out[n-1].Count++
+				} else {
+					out = append(out, Op{Op: OpGap, Count: 1, Removed: true})
+				}
+				continue
+			}
+		} else {
+			deleted = 0
 		}
 		if op.Op != OpEq {
 			if changed == maxChanged {
@@ -0,0 +1,156 @@+package engine
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	"git.pablomurad.com/pablo/patchwork/internal/diff"
+	"git.pablomurad.com/pablo/patchwork/internal/doc"
+	"git.pablomurad.com/pablo/patchwork/internal/store"
+)
+
+// Retract hides a published revision from the feeds and the API, or brings
+// it back with undo. The revisions after it get their diffs recomputed,
+// because their base changes.
+func (e *Engine) Retract(ctx context.Context, pageID int64, number int, undo bool) error {
+	return e.Store.Tx(ctx, false, func(q *store.Queries) error {
+		r, err := q.RevisionByNumber(ctx, pageID, number)
+		if err != nil {
+			return fmt.Errorf("version %d: %w", number, err)
+		}
+		if r.Baseline() {
+			return errors.New("the first version can't be retracted; it's the base everything else is compared with")
+		}
+		if undo {
+			r.RetractedAt = nil
+		} else {
+			now := e.Now()
+			r.RetractedAt = &now
+		}
+		if err := q.UpdateRevision(ctx, r); err != nil {
+			return err
+		}
+		_, err = e.rebuildPage(ctx, q, pageID, true)
+		return err
+	})
+}
+
+// Amend rewrites a published revision's note and, when kind isn't empty,
+// its kind. It's the CLI twin of editing the note in the file.
+func (e *Engine) Amend(ctx context.Context, pageID int64, number int, note, kind string) error {
+	if kind != "" && !doc.Valid(kind, doc.Kinds) {
+		return fmt.Errorf("kind %q is not one of %v", kind, doc.Kinds)
+	}
+	return e.Store.Tx(ctx, false, func(q *store.Queries) error {
+		r, err := q.RevisionByNumber(ctx, pageID, number)
+		if err != nil {
+			return fmt.Errorf("version %d: %w", number, err)
+		}
+		if r.Baseline() {
+			return errors.New("the first version has no public note to amend")
+		}
+		if note != "" {
+			r.Note = note
+		}
+		if kind != "" {
+			r.Kind = kind
+		}
+		r.UpdatedAt = e.Now()
+		return q.UpdateRevision(ctx, r)
+	})
+}
+
+// RebuildDiffs recomputes cached diffs: all of them with force, otherwise
+// only those made by an older diff algorithm. pageID 0 means every page.
+// It returns how many revisions changed.
+func (e *Engine) RebuildDiffs(ctx context.Context, pageID int64, force bool) (int, error) {
+	n := 0
+	err := e.Store.Tx(ctx, false, func(q *store.Queries) error {
+		var ids []int64
+		if pageID != 0 {
+			ids = []int64{pageID}
+		} else {
+			pages, err := q.Pages(ctx, e.Source, true)
+			if err != nil {
+				return err
+			}
+			for _, p := range pages {
+				ids = append(ids, p.ID)
+			}
+		}
+		for _, id := range ids {
+			c, err := e.rebuildPage(ctx, q, id, force)
+			if err != nil {
+				return err
+			}
+			n += c
+		}
+		return nil
+	})
+	return n, err
+}
+
+func (e *Engine) rebuildPage(ctx context.Context, q *store.Queries, pageID int64, force bool) (int, error) {
+	revs, err := q.Revisions(ctx, pageID)
+	if err != nil {
+		return 0, err
+	}
+	changed := 0
+	var base *store.Snapshot
+	for i := range revs {
+		r := &revs[i]
+		if r.RetractedAt != nil {
+			continue
+		}
+		snap, err := q.Snapshot(ctx, r.SnapshotID)
+		if err != nil {
+			return changed, err
+		}
+		if base != nil && (force || r.DiffVersion != diff.Version) {
+			before := r.DiffJSON
+			e.setDiff(r, base, doc.Parse([]byte(snap.Raw), e.opts))
+			if r.DiffJSON != before {
+				if err := q.UpdateRevision(ctx, r); err != nil {
+					return changed, err
+				}
+				changed++
+			}
+		}
+		base = snap
+	}
+	return changed, nil
+}
+
+// Backfill imports an older copy of a file as a snapshot at a past time,
+// for building history from copies made before Patchwork ran. Copies must
+// be imported oldest first and before any newer snapshot of the page;
+// history can be extended into the past only at its start, never rewritten
+// in the middle.
+func (e *Engine) Backfill(ctx context.Context, relPath string, raw []byte, at time.Time) (Result, error) {
+	if at.IsZero() {
+		return Result{}, errors.New("backfill needs a time")
+	}
+	if at.After(e.Now()) {
+		return Result{}, errors.New("backfill time is in the future")
+	}
+	var res Result
+	err := e.Store.Tx(ctx, false, func(q *store.Queries) error {
+		if p, err := q.PageByPath(ctx, e.Source, relPath); err == nil {
+			last, err := q.LastSnapshot(ctx, p.ID)
+			if err != nil {
+				return err
+			}
+			if !at.After(last.ObservedAt) {
+				return fmt.Errorf("%s already has a snapshot from %s; import older copies first", relPath, last.ObservedAt.Format(time.RFC3339))
+			}
+		} else if !errors.Is(err, store.ErrNotFound) {
+			return err
+		}
+		var err error
+		res, err = e.ingest(ctx, q, Observation{Path: relPath, Raw: raw, At: at})
+		return err
+	})
+	return res, err
+}
@@ -0,0 +1,105 @@+package engine
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestRetractRebasesLaterDiffs(t *testing.T) {
+	h := newHarness(t)
+	ctx := context.Background()
+	h.put("a.md", page("", "Alpha."))
+	h.after(time.Hour).put("a.md", page("Added beta.", "Alpha.\n\nBeta."))
+	h.after(time.Hour).put("a.md", page("Added gamma.", "Alpha.\n\nBeta.\n\nGamma."))
+	p, _ := h.e.Store.PageByPath(ctx, "folder", "a.md")
+
+	if err := h.e.Retract(ctx, p.ID, 2, false); err != nil {
+		t.Fatal(err)
+	}
+	// #3 is now compared with #1, so Beta shows as added too.
+	if f := h.diffOf(h.revisions("a.md")[2]); f.Stats.BlocksChanged != 2 {
+		t.Fatalf("after retract: %+v", f.Stats)
+	}
+	if last, _ := h.e.Store.LastRevision(ctx, p.ID); last.Number != 3 {
+		t.Fatalf("last %d", last.Number)
+	}
+
+	if err := h.e.Retract(ctx, p.ID, 2, true); err != nil {
+		t.Fatal(err)
+	}
+	if f := h.diffOf(h.revisions("a.md")[2]); f.Stats.BlocksChanged != 1 {
+		t.Fatalf("after undo: %+v", f.Stats)
+	}
+	if err := h.e.Retract(ctx, p.ID, 1, false); err == nil {
+		t.Fatal("the baseline must not be retractable")
+	}
+}
+
+func TestPublishingAfterRetractionUsesANewNumber(t *testing.T) {
+	h := newHarness(t)
+	ctx := context.Background()
+	h.put("a.md", page("", "Alpha."))
+	h.after(time.Hour).put("a.md", page("Oops.", "Alpha. Secret."))
+	p, _ := h.e.Store.PageByPath(ctx, "folder", "a.md")
+	h.e.Retract(ctx, p.ID, 2, false)
+	h.expect(h.after(time.Hour).put("a.md", page("Real change.", "Alpha. Public.")), Published, 3)
+	if f := h.diffOf(h.revisions("a.md")[2]); strings.Contains(f.Ops[0].Segments[len(f.Ops[0].Segments)-1].S, "Secret") {
+		t.Fatal("the new revision should be diffed against #1, not the retracted #2")
+	}
+}
+
+func TestAmend(t *testing.T) {
+	h := newHarness(t)
+	ctx := context.Background()
+	h.put("a.md", page("", "Alpha."))
+	h.after(time.Hour).put("a.md", page("Typo in nte.", "Beta."))
+	p, _ := h.e.Store.PageByPath(ctx, "folder", "a.md")
+	if err := h.e.Amend(ctx, p.ID, 2, "Typo in note fixed.", "correction"); err != nil {
+		t.Fatal(err)
+	}
+	r := h.revisions("a.md")[1]
+	if r.Note != "Typo in note fixed." || r.Kind != "correction" {
+		t.Fatalf("%+v", r)
+	}
+	if err := h.e.Amend(ctx, p.ID, 2, "", "rant"); err == nil {
+		t.Fatal("invalid kind should fail")
+	}
+}
+
+func TestBackfill(t *testing.T) {
+	h := newHarness(t)
+	ctx := context.Background()
+	old := h.now.Add(-30 * 24 * time.Hour)
+	if _, err := h.e.Backfill(ctx, "a.md", []byte(page("", "Very old text.")), old); err != nil {
+		t.Fatal(err)
+	}
+	res, err := h.e.Backfill(ctx, "a.md", []byte(page("Rewrote the old text.", "Newer text.")), old.Add(24*time.Hour))
+	if err != nil || res.Action != Published {
+		t.Fatalf("%+v %v", res, err)
+	}
+	if _, err := h.e.Backfill(ctx, "a.md", []byte("x"), old.Add(time.Hour)); err == nil {
+		t.Fatal("backfilling before the latest snapshot must fail")
+	}
+	if _, err := h.e.Backfill(ctx, "a.md", []byte("x"), h.now.Add(time.Hour)); err == nil {
+		t.Fatal("future times must fail")
+	}
+	revs := h.revisions("a.md")
+	if !revs[1].PublishedAt.Equal(old.Add(24 * time.Hour)) {
+		t.Fatalf("backfilled revision time %s", revs[1].PublishedAt)
+	}
+}
+
+func TestRebuildDiffs(t *testing.T) {
+	h := newHarness(t)
+	ctx := context.Background()
+	h.put("a.md", page("", "Alpha."))
+	h.after(time.Hour).put("a.md", page("Note.", "Beta."))
+	if n, err := h.e.RebuildDiffs(ctx, 0, false); err != nil || n != 0 {
+		t.Fatalf("nothing to rebuild: %d %v", n, err)
+	}
+	if n, err := h.e.RebuildDiffs(ctx, 0, true); err != nil || n != 0 {
+		t.Fatalf("forced rebuild of current diffs changes nothing: %d %v", n, err)
+	}
+}
@@ -59,6 +59,8 @@ Store  *store.Store 	Cfg    *config.Config
 	Source string
 	Now    func() time.Time
+	// NewUID makes page uids; tests replace it to get stable feed ids.
+	NewUID func() string
 
 	slugRule slug.Rule
 	opts     doc.Options
@@ -72,7 +74,7 @@ if err != nil { 		return nil, err
 	}
 	return &Engine{
-		Store: st, Cfg: cfg, Source: cfg.Source.Mode, Now: time.Now,
+		Store: st, Cfg: cfg, Source: cfg.Source.Mode, Now: time.Now, NewUID: store.NewUID,
 		slugRule: rule, opts: cfg.DocOptions(), loc: cfg.Location(),
 	}, nil
 }
@@ -199,7 +201,7 @@ } 
 func (e *Engine) baseline(ctx context.Context, q *store.Queries, obs Observation, d *doc.Doc, at time.Time, kind, maturity string, res Result) (Result, error) {
 	page := &store.Page{
-		UID: store.NewUID(), Source: e.Source, Path: obs.Path,
+		UID: e.NewUID(), Source: e.Source, Path: obs.Path,
 		Slug: e.slugRule(obs.Path), FirstSeenAt: at,
 	}
 	page.OriginalDate = e.originalDate(d, obs.Path, at)
@@ -0,0 +1,46 @@+// Package export writes the published tree to a directory, for static
+// hosts and CI.
+package export
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"git.pablomurad.com/pablo/patchwork/internal/render"
+)
+
+// Write puts every file of the tree under dir. It builds the new tree in a
+// sibling directory and swaps it in, so a web server reading dir never sees
+// a half-written export. Files in dir that Patchwork didn't write are lost:
+// give Patchwork a directory of its own.
+func Write(t *render.Tree, dir string) error {
+	dir = filepath.Clean(dir)
+	tmp := dir + ".tmp"
+	old := dir + ".old"
+	if err := os.RemoveAll(tmp); err != nil {
+		return err
+	}
+	for _, p := range t.Paths() {
+		dst := filepath.Join(tmp, filepath.FromSlash(p))
+		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+			return err
+		}
+		if err := os.WriteFile(dst, t.Files[p].Body, 0o644); err != nil {
+			return err
+		}
+	}
+	if err := os.RemoveAll(old); err != nil {
+		return err
+	}
+	if _, err := os.Stat(dir); err == nil {
+		if err := os.Rename(dir, old); err != nil {
+			return fmt.Errorf("move old export aside: %w", err)
+		}
+	}
+	if err := os.Rename(tmp, dir); err != nil {
+		os.Rename(old, dir) // put the previous export back
+		return fmt.Errorf("swap in new export: %w", err)
+	}
+	return os.RemoveAll(old)
+}
@@ -0,0 +1,32 @@+package export
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"git.pablomurad.com/pablo/patchwork/internal/render"
+)
+
+func TestWriteSwapsAtomically(t *testing.T) {
+	dir := filepath.Join(t.TempDir(), "out")
+	first := &render.Tree{Files: map[string]render.File{"a/one.json": {Body: []byte("1")}, "stale.txt": {Body: []byte("x")}}}
+	if err := Write(first, dir); err != nil {
+		t.Fatal(err)
+	}
+	second := &render.Tree{Files: map[string]render.File{"a/one.json": {Body: []byte("2")}}}
+	if err := Write(second, dir); err != nil {
+		t.Fatal(err)
+	}
+	if b, _ := os.ReadFile(filepath.Join(dir, "a", "one.json")); string(b) != "2" {
+		t.Fatalf("content %q", b)
+	}
+	if _, err := os.Stat(filepath.Join(dir, "stale.txt")); !os.IsNotExist(err) {
+		t.Fatal("a file no longer in the tree must disappear")
+	}
+	for _, leftover := range []string{dir + ".tmp", dir + ".old"} {
+		if _, err := os.Stat(leftover); !os.IsNotExist(err) {
+			t.Fatalf("%s left behind", leftover)
+		}
+	}
+}
@@ -0,0 +1,222 @@+package render
+
+import (
+	"bytes"
+	"encoding/xml"
+	"strings"
+	"time"
+)
+
+// Atom (RFC 4287). Each published revision is an entry. An amended or
+// folded revision keeps its id and moves its updated time, which is how
+// readers learn to replace it instead of showing it twice.
+
+type atomFeed struct {
+	XMLName xml.Name    `xml:"http://www.w3.org/2005/Atom feed"`
+	ID      string      `xml:"id"`
+	Title   string      `xml:"title"`
+	Updated string      `xml:"updated"`
+	Author  *atomPerson `xml:"author,omitempty"`
+	Links   []atomLink  `xml:"link"`
+	Gen     atomGen     `xml:"generator"`
+	Entries []atomEntry `xml:"entry"`
+}
+
+type atomPerson struct {
+	Name string `xml:"name"`
+}
+
+type atomLink struct {
+	Rel  string `xml:"rel,attr,omitempty"`
+	Type string `xml:"type,attr,omitempty"`
+	Href string `xml:"href,attr"`
+}
+
+type atomGen struct {
+	URI  string `xml:"uri,attr"`
+	Text string `xml:",chardata"`
+}
+
+type atomEntry struct {
+	ID         string         `xml:"id"`
+	Title      string         `xml:"title"`
+	Published  string         `xml:"published"`
+	Updated    string         `xml:"updated"`
+	Links      []atomLink     `xml:"link"`
+	Categories []atomCategory `xml:"category"`
+	Summary    string         `xml:"summary,omitempty"`
+	Content    atomContent    `xml:"content"`
+}
+
+type atomCategory struct {
+	Term string `xml:"term,attr"`
+}
+
+type atomContent struct {
+	Type string `xml:"type,attr"`
+	Body string `xml:",chardata"`
+}
+
+const generatorURI = "https://git.pablomurad.com/pablo/patchwork"
+
+// Atom renders revisions as an Atom feed. slug is "" for the site feed.
+// selfPattern is the public path the host serves this feed at.
+func (m *Model) Atom(revs []Revision, slug, selfPattern string) ([]byte, error) {
+	site := m.Cfg.Site
+	f := atomFeed{
+		ID:      m.feedID(feedSuffix(slug)),
+		Title:   feedTitle(site.Title, slug, m),
+		Updated: stamp(feedUpdated(revs, m.Updated)),
+		Gen:     atomGen{URI: generatorURI, Text: "Patchwork"},
+		Links: []atomLink{
+			{Rel: "self", Type: "application/atom+xml", Href: m.url(selfPattern, slug, 0)},
+			{Rel: "alternate", Type: "text/html", Href: m.homeURL(slug)},
+		},
+	}
+	if site.Author != "" {
+		f.Author = &atomPerson{Name: site.Author}
+	}
+	for _, r := range revs {
+		e := atomEntry{
+			ID: r.ID, Title: EntryTitle(r), Published: r.PublishedAt, Updated: r.UpdatedAt,
+			Links: []atomLink{
+				{Rel: "alternate", Type: "text/html", Href: r.VersionURL},
+				{Rel: "related", Type: "text/html", Href: r.Page.URL},
+			},
+			Summary: r.Note,
+			Content: atomContent{Type: "html", Body: EntryHTML(r)},
+		}
+		if r.Kind != "" {
+			e.Categories = append(e.Categories, atomCategory{Term: r.Kind})
+		}
+		f.Entries = append(f.Entries, e)
+	}
+	var buf bytes.Buffer
+	buf.WriteString(xml.Header)
+	enc := xml.NewEncoder(&buf)
+	enc.Indent("", "  ")
+	if err := enc.Encode(f); err != nil {
+		return nil, err
+	}
+	buf.WriteByte('\n')
+	return buf.Bytes(), nil
+}
+
+// JSON Feed 1.1 (https://jsonfeed.org/version/1.1).
+
+type jsonFeed struct {
+	Version     string         `json:"version"`
+	Title       string         `json:"title"`
+	HomePageURL string         `json:"home_page_url,omitempty"`
+	FeedURL     string         `json:"feed_url,omitempty"`
+	Authors     []jsonAuthor   `json:"authors,omitempty"`
+	Items       []jsonFeedItem `json:"items"`
+}
+
+type jsonAuthor struct {
+	Name string `json:"name"`
+}
+
+type jsonFeedItem struct {
+	ID            string        `json:"id"`
+	URL           string        `json:"url"`
+	ExternalURL   string        `json:"external_url,omitempty"`
+	Title         string        `json:"title"`
+	Summary       string        `json:"summary,omitempty"`
+	ContentHTML   string        `json:"content_html"`
+	DatePublished string        `json:"date_published"`
+	DateModified  string        `json:"date_modified"`
+	Tags          []string      `json:"tags,omitempty"`
+	Patchwork     jsonExtension `json:"_patchwork"`
+}
+
+// jsonExtension carries the structured revision for readers that know
+// Patchwork; JSON Feed reserves "_" keys for extensions.
+type jsonExtension struct {
+	About      string `json:"about"`
+	Kind       string `json:"kind,omitempty"`
+	Maturity   string `json:"maturity,omitempty"`
+	Number     int    `json:"number"`
+	PageURL    string `json:"page_url"`
+	VersionURL string `json:"version_url"`
+	HistoryURL string `json:"history_url"`
+}
+
+// JSONFeed renders revisions as a JSON Feed. slug is "" for the site feed.
+func (m *Model) JSONFeed(revs []Revision, slug, selfPattern string) ([]byte, error) {
+	f := jsonFeed{
+		Version:     "https://jsonfeed.org/version/1.1",
+		Title:       feedTitle(m.Cfg.Site.Title, slug, m),
+		HomePageURL: m.homeURL(slug),
+		FeedURL:     m.url(selfPattern, slug, 0),
+		Items:       []jsonFeedItem{},
+	}
+	if m.Cfg.Site.Author != "" {
+		f.Authors = []jsonAuthor{{Name: m.Cfg.Site.Author}}
+	}
+	for _, r := range revs {
+		it := jsonFeedItem{
+			ID: r.ID, URL: r.VersionURL, ExternalURL: r.Page.URL, Title: EntryTitle(r), Summary: r.Note,
+			ContentHTML: EntryHTML(r), DatePublished: r.PublishedAt, DateModified: r.UpdatedAt,
+			Patchwork: jsonExtension{
+				About: generatorURI, Kind: r.Kind, Maturity: r.Maturity, Number: r.Number,
+				PageURL: r.Page.URL, VersionURL: r.VersionURL, HistoryURL: r.HistoryURL,
+			},
+		}
+		if r.Kind != "" {
+			it.Tags = []string{r.Kind}
+		}
+		f.Items = append(f.Items, it)
+	}
+	return marshal(f)
+}
+
+func feedSuffix(slug string) string {
+	if slug == "" {
+		return "revisions"
+	}
+	return "pages/" + slug
+}
+
+func feedTitle(site, slug string, m *Model) string {
+	if site == "" {
+		site = "Patchwork"
+	}
+	if slug == "" {
+		return site + ": revisions"
+	}
+	if p, ok := m.Page(slug); ok {
+		return p.Title + " (" + site + "): revisions"
+	}
+	return site + ": revisions of " + slug
+}
+
+func (m *Model) homeURL(slug string) string {
+	if slug != "" {
+		if p, ok := m.Page(slug); ok {
+			return p.URL
+		}
+	}
+	if base := strings.TrimRight(m.Cfg.Site.BaseURL, "/"); base != "" {
+		return base + "/"
+	}
+	return "/"
+}
+
+// feedUpdated is the newest updated time among the entries, so a page feed
+// doesn't change when an unrelated page is revised.
+func feedUpdated(revs []Revision, fallback time.Time) time.Time {
+	var t time.Time
+	for _, r := range revs {
+		if r.updated.After(t) {
+			t = r.updated
+		}
+	}
+	if t.IsZero() {
+		t = fallback
+	}
+	if t.IsZero() {
+		t = time.Unix(0, 0)
+	}
+	return t
+}
@@ -0,0 +1,135 @@+package render
+
+import (
+	"fmt"
+	"html"
+	"strings"
+
+	"git.pablomurad.com/pablo/patchwork/internal/diff"
+	"git.pablomurad.com/pablo/patchwork/internal/doc"
+)
+
+// Kind labels shown to readers.
+var kindLabel = map[string]string{
+	"correction":     "Correction",
+	"expansion":      "Expansion",
+	"position":       "Change of position",
+	"reorganization": "Reorganization",
+}
+
+// EntryTitle is a feed entry's title: what kind of revision, of which page.
+func EntryTitle(r Revision) string {
+	if l, ok := kindLabel[r.Kind]; ok {
+		return l + ": " + r.Page.Title
+	}
+	return "Revised: " + r.Page.Title
+}
+
+// EntryHTML is a feed entry's content: the note, a summary line, the
+// excerpt, and links. Feed readers strip styles, so the diff relies on what
+// <del> and <ins> mean, and every block is a plain paragraph.
+func EntryHTML(r Revision) string {
+	var b strings.Builder
+	if r.Note != "" {
+		fmt.Fprintf(&b, "<p><strong>%s</strong></p>\n", esc(r.Note))
+	}
+	var facts []string
+	if l, ok := kindLabel[r.Kind]; ok {
+		facts = append(facts, l)
+	}
+	if r.Maturity != "" {
+		facts = append(facts, "maturity: "+r.Maturity)
+	}
+	facts = append(facts, fmt.Sprintf("+%d / −%d words", r.Stats.WordsAdded, r.Stats.WordsRemoved))
+	fmt.Fprintf(&b, "<p><small>%s</small></p>\n", esc(strings.Join(facts, " · ")))
+
+	for _, m := range r.MetaChanges {
+		if m.Key == "maturity" {
+			continue // already in the summary line
+		}
+		fmt.Fprintf(&b, "<p>%s: <del>%s</del> <ins>%s</ins></p>\n", esc(m.Key), esc(metaText(m.Old)), esc(metaText(m.New)))
+	}
+	b.WriteString("<hr>\n")
+	for _, op := range r.Excerpt.Blocks {
+		b.WriteString(opHTML(op))
+	}
+	if r.Excerpt.Truncated {
+		b.WriteString("<p>…</p>\n")
+	}
+	fmt.Fprintf(&b, "<p><a href=\"%s\">Read version %d</a> · <a href=\"%s\">Current page</a> · <a href=\"%s\">History</a></p>\n",
+		esc(r.VersionURL), r.Number, esc(r.Page.URL), esc(r.HistoryURL))
+	return b.String()
+}
+
+func opHTML(op diff.Op) string {
+	wrap := func(inner string) string {
+		switch op.Kind {
+		case doc.KindCode, doc.KindTable:
+			return "<pre>" + inner + "</pre>\n"
+		case doc.KindHeading:
+			return "<h3>" + inner + "</h3>\n"
+		case doc.KindQuote:
+			return "<blockquote>" + inner + "</blockquote>\n"
+		case doc.KindListItem:
+			return "<ul><li>" + inner + "</li></ul>\n"
+		}
+		return "<p>" + inner + "</p>\n"
+	}
+	switch op.Op {
+	case diff.OpGap:
+		if op.Removed {
+			return fmt.Sprintf("<p><del><small>[%d more removed]</small></del></p>\n", op.Count)
+		}
+		return fmt.Sprintf("<p><small>[%d unchanged]</small></p>\n", op.Count)
+	case diff.OpEq:
+		return wrap(esc(op.Text))
+	case diff.OpInsert:
+		return wrap("<ins>" + esc(op.Text) + "</ins>")
+	case diff.OpDelete:
+		return wrap("<del>" + esc(op.Text) + "</del>")
+	case diff.OpMove:
+		return "<p><small>[moved]</small></p>\n" + wrap(esc(op.Text))
+	case diff.OpModify:
+		if op.Replaced {
+			// Rewritten block: old and new side by side would run together.
+			var out strings.Builder
+			for _, s := range op.Segments {
+				tag := "ins"
+				if s.T == diff.SegDel {
+					tag = "del"
+				}
+				out.WriteString(wrap("<" + tag + ">" + esc(s.S) + "</" + tag + ">"))
+			}
+			return out.String()
+		}
+		var in strings.Builder
+		for _, s := range op.Segments {
+			switch s.T {
+			case diff.SegDel:
+				in.WriteString("<del>" + esc(s.S) + "</del>")
+			case diff.SegIns:
+				in.WriteString("<ins>" + esc(s.S) + "</ins>")
+			default:
+				in.WriteString(esc(s.S))
+			}
+		}
+		return wrap(in.String())
+	}
+	return ""
+}
+
+func esc(s string) string { return html.EscapeString(s) }
+
+func metaText(v any) string {
+	switch t := v.(type) {
+	case nil:
+		return "(none)"
+	case []any:
+		parts := make([]string, len(t))
+		for i, x := range t {
+			parts[i] = fmt.Sprint(x)
+		}
+		return strings.Join(parts, ", ")
+	}
+	return fmt.Sprint(v)
+}
@@ -0,0 +1,228 @@+package render
+
+import (
+	"context"
+	"encoding/json"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"git.pablomurad.com/pablo/patchwork/internal/config"
+	"git.pablomurad.com/pablo/patchwork/internal/diff"
+	"git.pablomurad.com/pablo/patchwork/internal/doc"
+	"git.pablomurad.com/pablo/patchwork/internal/store"
+)
+
+// Model is everything public, read from the store in one pass. Retracted
+// revisions, removed pages and pages without a slug are left out.
+type Model struct {
+	Cfg       *config.Config
+	Pages     []PageDetail          // by path
+	Revisions []Revision            // newest first
+	Versions  map[string][]*Version // by slug, oldest first
+	Updated   time.Time             // latest change of anything public
+}
+
+// Load reads the public state of the store.
+func Load(ctx context.Context, st *store.Store, cfg *config.Config) (*Model, error) {
+	m := &Model{Cfg: cfg, Versions: map[string][]*Version{}}
+	opt := cfg.DocOptions()
+	pages, err := st.Pages(ctx, "", false)
+	if err != nil {
+		return nil, err
+	}
+	for _, p := range pages {
+		if p.Slug == "" {
+			continue
+		}
+		revs, err := st.Revisions(ctx, p.ID)
+		if err != nil {
+			return nil, err
+		}
+		last, err := st.LastSnapshot(ctx, p.ID)
+		if err != nil {
+			return nil, err
+		}
+
+		summary := PageSummary{
+			UID: p.UID, Slug: p.Slug, Path: p.Path, Title: titleOr(last.Title, p.Slug),
+			URL: m.url(cfg.Site.PageURL, p.Slug, 0), HistoryURL: m.url(cfg.Site.HistoryURL, p.Slug, 0),
+			Maturity: last.Maturity, OriginalDate: p.OriginalDate, FirstSeenAt: stamp(p.FirstSeenAt),
+		}
+		ref := PageRef{UID: p.UID, Slug: p.Slug, Title: summary.Title, URL: summary.URL}
+
+		var public []store.Revision
+		for _, r := range revs {
+			if r.RetractedAt == nil {
+				public = append(public, r)
+			}
+		}
+		var versions []*Version
+		var history []VersionSummary
+		var prev *store.Revision
+		var prevDoc *doc.Doc
+		for i := range public {
+			r := &public[i]
+			snap, err := st.Snapshot(ctx, r.SnapshotID)
+			if err != nil {
+				return nil, err
+			}
+			d := doc.Parse([]byte(snap.Raw), opt)
+			v := &Version{
+				Page: ref, Number: r.Number, Baseline: r.Baseline(),
+				PublishedAt: stamp(r.PublishedAt), UpdatedAt: stamp(r.UpdatedAt),
+				Note: publicNote(r), Kind: r.Kind, Maturity: r.Maturity,
+				URL: m.url(cfg.Site.VersionURL, p.Slug, r.Number), Title: titleOr(d.Title, p.Slug),
+				BodyMarkdown: strings.TrimLeft(d.Body, "\n"), Frontmatter: visibleFrontmatter(d, opt.DiffKeys),
+			}
+			if prev != nil {
+				full, err := cachedDiff(r, prevDoc, d, opt.DiffKeys)
+				if err != nil {
+					return nil, err
+				}
+				if full.Ops == nil {
+					full.Ops = []diff.Op{} // the contract has a list, never null
+				}
+				v.BaseNumber, v.Diff = prev.Number, &full
+				m.Revisions = append(m.Revisions, m.revision(ref, summary, r, prev.Number, full))
+				summary.RevisionCount++
+				summary.LastRevisedAt = stamp(r.PublishedAt)
+			}
+			summary.LatestNumber = r.Number
+			if r.UpdatedAt.After(m.Updated) {
+				m.Updated = r.UpdatedAt
+			}
+			versions = append(versions, v)
+			history = append(history, VersionSummary{
+				Number: r.Number, Baseline: r.Baseline(), PublishedAt: v.PublishedAt, UpdatedAt: v.UpdatedAt,
+				Note: v.Note, Kind: r.Kind, Maturity: r.Maturity, URL: v.URL,
+				WordsAdded: r.WordsAdded, WordsRemoved: r.WordsRemoved,
+			})
+			prev, prevDoc = r, d
+		}
+		for _, v := range versions {
+			v.LatestNumber = summary.LatestNumber
+		}
+		for i, j := 0, len(history)-1; i < j; i, j = i+1, j-1 {
+			history[i], history[j] = history[j], history[i]
+		}
+		m.Versions[p.Slug] = versions
+		m.Pages = append(m.Pages, PageDetail{PageSummary: summary, Versions: history})
+	}
+	sort.SliceStable(m.Revisions, func(i, j int) bool {
+		a, b := m.Revisions[i], m.Revisions[j]
+		if !a.published.Equal(b.published) {
+			return a.published.After(b.published)
+		}
+		return a.ID > b.ID
+	})
+	return m, nil
+}
+
+func (m *Model) revision(ref PageRef, s PageSummary, r *store.Revision, base int, full diff.Full) Revision {
+	ex, cut := diff.Excerpt(full.Ops, 1, m.Cfg.Feeds.ExcerptBlocks)
+	if ex == nil {
+		ex = []diff.Op{}
+	}
+	meta := full.Meta
+	if meta == nil {
+		meta = []diff.MetaChange{}
+	}
+	return Revision{
+		ID: m.entryID(s.UID, r.Number), Number: r.Number, BaseNumber: base, Page: ref,
+		Kind: r.Kind, Maturity: r.Maturity, Note: r.Note,
+		PublishedAt: stamp(r.PublishedAt), UpdatedAt: stamp(r.UpdatedAt), OriginalDate: s.OriginalDate,
+		VersionURL: m.url(m.Cfg.Site.VersionURL, s.Slug, r.Number), HistoryURL: s.HistoryURL,
+		Stats: full.Stats, MetaChanges: meta, Excerpt: Excerpt{Truncated: cut, Blocks: ex},
+		published: r.PublishedAt, updated: r.UpdatedAt,
+	}
+}
+
+// cachedDiff uses the diff stored with the revision when the current
+// algorithm made it, and recomputes otherwise. The engine refreshes a page's
+// cached diffs whenever a retraction changes which version is the base, so
+// a cache of the current version is always against the right base.
+func cachedDiff(r *store.Revision, base, cur *doc.Doc, keys []string) (diff.Full, error) {
+	if r.DiffJSON != "" && r.DiffVersion == diff.Version {
+		var full diff.Full
+		err := json.Unmarshal([]byte(r.DiffJSON), &full)
+		return full, err
+	}
+	return diff.Compare(base, cur, keys), nil
+}
+
+// publicNote hides the baseline's note: it was written before Patchwork
+// saw the page and was never announced.
+func publicNote(r *store.Revision) string {
+	if r.Baseline() {
+		return ""
+	}
+	return r.Note
+}
+
+func visibleFrontmatter(d *doc.Doc, keys []string) map[string]any {
+	out := map[string]any{}
+	for _, k := range keys {
+		if v, ok := d.Frontmatter[k]; ok {
+			out[k] = v
+		}
+	}
+	return out
+}
+
+// url expands a site URL pattern and makes it absolute when base_url is set.
+func (m *Model) url(pattern, slug string, n int) string {
+	u := strings.ReplaceAll(pattern, "{slug}", slug)
+	u = strings.ReplaceAll(u, "{n}", strconv.Itoa(n))
+	if base := strings.TrimRight(m.Cfg.Site.BaseURL, "/"); base != "" && strings.HasPrefix(u, "/") {
+		return base + u
+	}
+	return u
+}
+
+// entryID is a feed entry id that never changes: a tag: URI when the site
+// declares an authority, otherwise a URN built from the page's random uid.
+func (m *Model) entryID(uid string, n int) string {
+	if a := m.Cfg.Site.TagAuthority; a != "" {
+		return "tag:" + a + ":patchwork/" + uid + "/r" + strconv.Itoa(n)
+	}
+	return "urn:patchwork:" + uid + ":r" + strconv.Itoa(n)
+}
+
+func (m *Model) feedID(suffix string) string {
+	if a := m.Cfg.Site.TagAuthority; a != "" {
+		return "tag:" + a + ":patchwork/" + suffix
+	}
+	return "urn:patchwork:" + strings.ReplaceAll(suffix, "/", ":")
+}
+
+// Page returns a page's detail by slug.
+func (m *Model) Page(slug string) (*PageDetail, bool) {
+	for i := range m.Pages {
+		if m.Pages[i].Slug == slug {
+			return &m.Pages[i], true
+		}
+	}
+	return nil, false
+}
+
+// PageRevisions returns one page's published revisions, newest first.
+func (m *Model) PageRevisions(slug string) []Revision {
+	var out []Revision
+	for _, r := range m.Revisions {
+		if r.Page.Slug == slug {
+			out = append(out, r)
+		}
+	}
+	return out
+}
+
+func stamp(t time.Time) string { return t.UTC().Format(time.RFC3339) }
+
+func titleOr(title, slug string) string {
+	if title != "" {
+		return title
+	}
+	return slug
+}
@@ -0,0 +1,128 @@+// Package render builds everything Patchwork publishes from the store: the
+// JSON API, Atom and JSON Feed, and plain text for terminals. Build returns
+// one in-memory tree; the server serves it and export writes it to disk, so
+// both always publish the same bytes.
+package render
+
+import (
+	"time"
+
+	"git.pablomurad.com/pablo/patchwork/internal/diff"
+)
+
+// The JSON contract, version 1. schema/v1 describes the same shapes; change
+// both together, and never remove or rename a field within v1.
+
+// PageRef identifies a page inside other objects.
+type PageRef struct {
+	UID   string `json:"uid"`
+	Slug  string `json:"slug"`
+	Title string `json:"title"`
+	URL   string `json:"url"`
+}
+
+// PageSummary is one entry of /api/v1/pages.
+type PageSummary struct {
+	UID          string `json:"uid"`
+	Slug         string `json:"slug"`
+	Path         string `json:"path"`
+	Title        string `json:"title"`
+	URL          string `json:"url"`
+	HistoryURL   string `json:"history_url"`
+	Maturity     string `json:"maturity,omitempty"`
+	OriginalDate string `json:"original_date"`
+	FirstSeenAt  string `json:"first_seen_at"`
+	// LastRevisedAt is the latest published revision; empty when the page
+	// has only its first version.
+	LastRevisedAt string `json:"last_revised_at,omitempty"`
+	RevisionCount int    `json:"revision_count"`
+	LatestNumber  int    `json:"latest_number"`
+}
+
+// VersionSummary is one entry of a page's history.
+type VersionSummary struct {
+	Number       int    `json:"number"`
+	Baseline     bool   `json:"baseline,omitempty"`
+	PublishedAt  string `json:"published_at"`
+	UpdatedAt    string `json:"updated_at"`
+	Note         string `json:"note,omitempty"`
+	Kind         string `json:"kind,omitempty"`
+	Maturity     string `json:"maturity,omitempty"`
+	URL          string `json:"url"`
+	WordsAdded   int    `json:"words_added"`
+	WordsRemoved int    `json:"words_removed"`
+}
+
+// PageDetail is /api/v1/pages/{slug}: the summary plus the history, newest
+// first.
+type PageDetail struct {
+	PageSummary
+	Versions []VersionSummary `json:"versions"`
+}
+
+// Excerpt is the part of a diff shown in feeds and lists.
+type Excerpt struct {
+	Truncated bool      `json:"truncated"`
+	Blocks    []diff.Op `json:"blocks"`
+}
+
+// Revision is one published revision: an entry in /api/v1/revisions and in
+// the feeds.
+type Revision struct {
+	ID           string            `json:"id"`
+	Number       int               `json:"number"`
+	BaseNumber   int               `json:"base_number"`
+	Page         PageRef           `json:"page"`
+	Kind         string            `json:"kind,omitempty"`
+	Maturity     string            `json:"maturity,omitempty"`
+	Note         string            `json:"note"`
+	PublishedAt  string            `json:"published_at"`
+	UpdatedAt    string            `json:"updated_at"`
+	OriginalDate string            `json:"original_date"`
+	VersionURL   string            `json:"version_url"`
+	HistoryURL   string            `json:"history_url"`
+	Stats        diff.Stats        `json:"stats"`
+	MetaChanges  []diff.MetaChange `json:"meta_changes"`
+	Excerpt      Excerpt           `json:"excerpt"`
+
+	published, updated time.Time
+}
+
+// Version is /api/v1/pages/{slug}/versions/{n}: one public version with its
+// text and its full diff against the previous public version.
+type Version struct {
+	Page         PageRef        `json:"page"`
+	Number       int            `json:"number"`
+	LatestNumber int            `json:"latest_number"`
+	Baseline     bool           `json:"baseline,omitempty"`
+	PublishedAt  string         `json:"published_at"`
+	UpdatedAt    string         `json:"updated_at"`
+	Note         string         `json:"note,omitempty"`
+	Kind         string         `json:"kind,omitempty"`
+	Maturity     string         `json:"maturity,omitempty"`
+	URL          string         `json:"url"`
+	Title        string         `json:"title"`
+	BodyMarkdown string         `json:"body_markdown"`
+	Frontmatter  map[string]any `json:"frontmatter"`
+	BaseNumber   int            `json:"base_number,omitempty"`
+	// Diff is null for the first version.
+	Diff *diff.Full `json:"diff"`
+}
+
+// RevisionList is /api/v1/revisions.
+type RevisionList struct {
+	Revisions []Revision `json:"revisions"`
+}
+
+// PageList is /api/v1/pages.
+type PageList struct {
+	Pages []PageSummary `json:"pages"`
+}
+
+// Error is the body of every API error.
+type Error struct {
+	Error struct {
+		Code    string `json:"code"`
+		Message string `json:"message"`
+	} `json:"error"`
+}
@@ -0,0 +1,232 @@+package render
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"encoding/xml"
+	"flag"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"git.pablomurad.com/pablo/patchwork/internal/config"
+	"git.pablomurad.com/pablo/patchwork/internal/engine"
+	"git.pablomurad.com/pablo/patchwork/internal/store"
+)
+
+var update = flag.Bool("update", false, "rewrite testdata/golden")
+
+// scenario builds a small moonflower-like site with a known history:
+// a page with two revisions (the second amended), one page never revised,
+// and one page whose only revision was retracted.
+func scenario(t *testing.T) (*store.Store, *config.Config) {
+	t.Helper()
+	st, err := store.Open(filepath.Join(t.TempDir(), "pw.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { st.Close() })
+
+	cfg := config.Default()
+	cfg.Site = config.Site{
+		Title: "Moon Flower Society", BaseURL: "https://moonflowersociety.org", Timezone: "America/Sao_Paulo",
+		Author: "Pablo Murad", TagAuthority: "moonflowersociety.org,2026",
+		PageURL: "/idea/{slug}", VersionURL: "/idea/{slug}/v/{n}", HistoryURL: "/idea/{slug}/history",
+		Feeds: config.SiteFeeds{Atom: "/revisions.atom", JSON: "/revisions.json", PageAtom: "/idea/{slug}/revisions.atom", PageJSON: "/idea/{slug}/revisions.json"},
+	}
+	cfg.Source.Slug = "datestrip-slugify"
+
+	e, err := engine.New(st, cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	now := time.Date(2026, 10, 1, 15, 0, 0, 0, time.UTC)
+	e.Now = func() time.Time { return now }
+	uid := 0
+	e.NewUID = func() string { uid++; return fmt.Sprintf("p_test%04d", uid) }
+
+	ctx := context.Background()
+	put := func(path, body string) {
+		t.Helper()
+		if _, err := e.Ingest(ctx, engine.Observation{Path: path, Raw: []byte(body)}, false); err != nil {
+			t.Fatal(err)
+		}
+		now = now.Add(24 * time.Hour)
+	}
+
+	remote := "2026-06-21-remote-work.md"
+	put(remote, "---\ntitle: Remote work\nmaturity: seed\n---\n# Remote work\n\nThe hybrid model is the worst of both worlds.\n\nSmall teams should pick one place.\n")
+	put("notes.md", "# Notes\n\nA page that never changes.\n")
+	put(remote, "---\ntitle: Remote work\nmaturity: sprout\nrevision: Two years in, I changed my mind.\nrevision_kind: position\n---\n# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n")
+	put(remote, "---\ntitle: Remote work\nmaturity: sprout\nrevision: Added what I'd tell a new team <and> why.\nrevision_kind: expansion\n---\n# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n\n- meet in person every month\n- write things down\n")
+	put(remote, "---\ntitle: Remote work\nmaturity: sprout\nrevision: Added what I'd tell a new team & why.\nrevision_kind: expansion\n---\n# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n\n- meet in person every month\n- write things down\n")
+
+	put("draft.md", "A draft.\n")
+	put("draft.md", "---\nrevision: Oops, published by mistake.\n---\nA draft with a secret.\n")
+	p, err := st.PageByPath(ctx, "folder", "draft.md")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := e.Retract(ctx, p.ID, 2, false); err != nil {
+		t.Fatal(err)
+	}
+	return st, cfg
+}
+
+func build(t *testing.T) *Tree {
+	t.Helper()
+	st, cfg := scenario(t)
+	tree, err := Build(context.Background(), st, cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return tree
+}
+
+// TestTreeGolden pins every published byte. Review a diff in testdata/golden
+// like a diff in the API: it's what sites and feed readers will see.
+func TestTreeGolden(t *testing.T) {
+	tree := build(t)
+	root := filepath.Join("testdata", "golden")
+	if *update {
+		os.RemoveAll(root)
+	}
+	seen := map[string]bool{}
+	for _, p := range tree.Paths() {
+		seen[p] = true
+		path := filepath.Join(root, filepath.FromSlash(p))
+		body := tree.Files[p].Body
+		if *update {
+			os.MkdirAll(filepath.Dir(path), 0o755)
+			if err := os.WriteFile(path, body, 0o644); err != nil {
+				t.Fatal(err)
+			}
+			continue
+		}
+		want, err := os.ReadFile(path)
+		if err != nil {
+			t.Errorf("%s: %v (run with -update)", p, err)
+			continue
+		}
+		if !bytes.Equal(bytes.ReplaceAll(want, []byte("\r\n"), []byte("\n")), body) {
+			t.Errorf("%s differs from golden; got:\n%s", p, body)
+		}
+	}
+	filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
+		if err == nil && !d.IsDir() {
+			rel, _ := filepath.Rel(root, path)
+			if !seen[filepath.ToSlash(rel)] {
+				t.Errorf("golden file %s is no longer produced", rel)
+			}
+		}
+		return nil
+	})
+}
+
+func TestRetractedRevisionIsGone(t *testing.T) {
+	tree := build(t)
+	for p, f := range tree.Files {
+		if strings.Contains(string(f.Body), "secret") && !strings.Contains(p, "/versions/1") {
+			// Version 1 of draft.md never had the secret; any mention of it
+			// means the retracted text leaked.
+			t.Errorf("retracted text published in %s", p)
+		}
+	}
+	if _, ok := tree.Files["api/v1/pages/draft/versions/2.json"]; ok {
+		t.Error("retracted version is still served")
+	}
+}
+
+func TestAtomRoundTrip(t *testing.T) {
+	tree := build(t)
+	var f atomFeed
+	if err := xml.Unmarshal(tree.Files["feeds/revisions.atom"].Body, &f); err != nil {
+		t.Fatal(err)
+	}
+	if len(f.Entries) != 2 {
+		t.Fatalf("entries %d", len(f.Entries))
+	}
+	ids := map[string]bool{}
+	for _, e := range f.Entries {
+		if ids[e.ID] {
+			t.Errorf("duplicate id %s", e.ID)
+		}
+		ids[e.ID] = true
+		if !strings.HasPrefix(e.ID, "tag:moonflowersociety.org,2026:patchwork/") {
+			t.Errorf("id %s", e.ID)
+		}
+		if _, err := time.Parse(time.RFC3339, e.Updated); err != nil {
+			t.Errorf("updated %q: %v", e.Updated, err)
+		}
+	}
+	// The amended revision keeps its id and moves its updated time.
+	newest := f.Entries[0]
+	if newest.Published == newest.Updated || !strings.Contains(newest.Content.Body, "&amp; why") {
+		t.Errorf("amended entry %+v", newest)
+	}
+	self := ""
+	for _, l := range f.Links {
+		if l.Rel == "self" {
+			self = l.Href
+		}
+	}
+	if self != "https://moonflowersociety.org/revisions.atom" {
+		t.Errorf("self link %q", self)
+	}
+}
+
+func TestJSONFeedShape(t *testing.T) {
+	tree := build(t)
+	var f map[string]any
+	if err := json.Unmarshal(tree.Files["feeds/pages/remote-work.json"].Body, &f); err != nil {
+		t.Fatal(err)
+	}
+	if f["version"] != "https://jsonfeed.org/version/1.1" || f["feed_url"] != "https://moonflowersociety.org/idea/remote-work/revisions.json" {
+		t.Fatalf("feed %v", f)
+	}
+	items := f["items"].([]any)
+	if len(items) != 2 {
+		t.Fatalf("items %d", len(items))
+	}
+}
+
+func TestTextWrapsAndColors(t *testing.T) {
+	st, cfg := scenario(t)
+	m, err := Load(context.Background(), st, cfg)
+	if err != nil {
+		t.Fatal(err)
+	}
+	out := m.TextRevisions(m.Revisions, TextOptions{Width: 48, Color: true})
+	if !strings.Contains(out, "\x1b[32m") || !strings.Contains(out, "\x1b[31m") {
+		t.Error("colored output should mark insertions and deletions")
+	}
+	for _, line := range strings.Split(stripANSI(out), "\n") {
+		// A single unbreakable word (a URL) may overflow: cutting it would
+		// break copy and paste.
+		if visibleWidth(line) > 48 && strings.Contains(strings.TrimSpace(line), " ") {
+			t.Errorf("line wider than 48: %q", line)
+		}
+	}
+	plain := m.TextRevisions(m.Revisions, TextOptions{Width: 80})
+	if strings.Contains(plain, "\x1b[") || !strings.Contains(plain, "\n  - ") || !strings.Contains(plain, "\n  + ") {
+		t.Errorf("plain output should mark changes without ANSI:\n%s", plain)
+	}
+}
+
+func stripANSI(s string) string {
+	var b strings.Builder
+	for i := 0; i < len(s); i++ {
+		if s[i] == 0x1b {
+			for i < len(s) && s[i] != 'm' {
+				i++
+			}
+			continue
+		}
+		b.WriteByte(s[i])
+	}
+	return b.String()
+}
@@ -0,0 +1,92 @@+package render
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/santhosh-tekuri/jsonschema/v6"
+)
+
+const schemaBase = "https://git.pablomurad.com/pablo/patchwork/raw/branch/main/schema/v1/"
+
+// TestOutputMatchesSchema validates every JSON file of the tree against
+// schema/v1, the contract published for sites that consume the API. The
+// schemas are strict about unknown fields, so adding a field without
+// documenting it fails here.
+func TestOutputMatchesSchema(t *testing.T) {
+	c := jsonschema.NewCompiler()
+	c.AssertFormat()
+	files, err := filepath.Glob(filepath.Join("..", "..", "schema", "v1", "*.json"))
+	if err != nil || len(files) == 0 {
+		t.Fatalf("schemas: %v", err)
+	}
+	for _, f := range files {
+		raw, err := os.ReadFile(f)
+		if err != nil {
+			t.Fatal(err)
+		}
+		doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
+		if err != nil {
+			t.Fatalf("%s: %v", f, err)
+		}
+		if err := c.AddResource(schemaBase+filepath.Base(f), doc); err != nil {
+			t.Fatal(err)
+		}
+	}
+	compile := func(name string) *jsonschema.Schema {
+		s, err := c.Compile(schemaBase + name)
+		if err != nil {
+			t.Fatalf("%s: %v", name, err)
+		}
+		return s
+	}
+	schemas := map[string]*jsonschema.Schema{
+		"pages": compile("pages.json"), "page": compile("page.json"),
+		"version": compile("version.json"), "revisions": compile("revisions.json"),
+	}
+
+	tree := build(t)
+	checked := 0
+	for _, p := range tree.Paths() {
+		var name string
+		switch {
+		case p == "api/v1/pages.json":
+			name = "pages"
+		case p == "api/v1/revisions.json":
+			name = "revisions"
+		case strings.Contains(p, "/versions/"):
+			name = "version"
+		case strings.HasPrefix(p, "api/v1/pages/"):
+			name = "page"
+		default:
+			continue
+		}
+		inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(tree.Files[p].Body))
+		if err != nil {
+			t.Fatalf("%s: %v", p, err)
+		}
+		if err := schemas[name].Validate(inst); err != nil {
+			t.Errorf("%s does not match %s.json:\n%v", p, name, err)
+		}
+		checked++
+	}
+	if checked < 8 {
+		t.Fatalf("only %d files checked", checked)
+	}
+
+	// The schemas must actually reject drift.
+	reject := map[string]string{
+		"pages":   `{"pages":[{"uid":"u","slug":"s","path":"p","title":"t","url":"/s","history_url":"/h","original_date":"2026-01-01","first_seen_at":"2026-01-01T00:00:00Z","revision_count":0,"latest_number":1,"bogus":1}]}`,
+		"page":    `{"uid":"u","slug":"s","path":"p","title":"t","url":"/s","history_url":"/h","original_date":"2026-01-01","first_seen_at":"2026-01-01T00:00:00Z","revision_count":0,"latest_number":1,"versions":[],"bogus":1}`,
+		"version": `{"page":{"uid":"u","slug":"s","title":"t","url":"/s"},"number":1,"latest_number":1,"published_at":"yesterday","updated_at":"2026-01-01T00:00:00Z","url":"/s","title":"t","body_markdown":"","frontmatter":{},"diff":null}`,
+	}
+	for name, body := range reject {
+		inst, _ := jsonschema.UnmarshalJSON(strings.NewReader(body))
+		if err := schemas[name].Validate(inst); err == nil {
+			t.Errorf("%s.json accepted an invalid document", name)
+		}
+	}
+}
@@ -0,0 +1,42 @@+{
+  "pages": [
+    {
+      "uid": "p_test0001",
+      "slug": "remote-work",
+      "path": "2026-06-21-remote-work.md",
+      "title": "Remote work",
+      "url": "https://moonflowersociety.org/idea/remote-work",
+      "history_url": "https://moonflowersociety.org/idea/remote-work/history",
+      "maturity": "sprout",
+      "original_date": "2026-06-21",
+      "first_seen_at": "2026-10-01T15:00:00Z",
+      "last_revised_at": "2026-10-04T15:00:00Z",
+      "revision_count": 2,
+      "latest_number": 3
+    },
+    {
+      "uid": "p_test0003",
+      "slug": "draft",
+      "path": "draft.md",
+      "title": "draft",
+      "url": "https://moonflowersociety.org/idea/draft",
+      "history_url": "https://moonflowersociety.org/idea/draft/history",
+      "original_date": "2026-10-06",
+      "first_seen_at": "2026-10-06T15:00:00Z",
+      "revision_count": 0,
+      "latest_number": 1
+    },
+    {
+      "uid": "p_test0002",
+      "slug": "notes",
+      "path": "notes.md",
+      "title": "Notes",
+      "url": "https://moonflowersociety.org/idea/notes",
+      "history_url": "https://moonflowersociety.org/idea/notes/history",
+      "original_date": "2026-10-02",
+      "first_seen_at": "2026-10-02T15:00:00Z",
+      "revision_count": 0,
+      "latest_number": 1
+    }
+  ]
+}
@@ -0,0 +1,23 @@+{
+  "uid": "p_test0003",
+  "slug": "draft",
+  "path": "draft.md",
+  "title": "draft",
+  "url": "https://moonflowersociety.org/idea/draft",
+  "history_url": "https://moonflowersociety.org/idea/draft/history",
+  "original_date": "2026-10-06",
+  "first_seen_at": "2026-10-06T15:00:00Z",
+  "revision_count": 0,
+  "latest_number": 1,
+  "versions": [
+    {
+      "number": 1,
+      "baseline": true,
+      "published_at": "2026-10-06T15:00:00Z",
+      "updated_at": "2026-10-06T15:00:00Z",
+      "url": "https://moonflowersociety.org/idea/draft/v/1",
+      "words_added": 0,
+      "words_removed": 0
+    }
+  ]
+}
@@ -0,0 +1,18 @@+{
+  "page": {
+    "uid": "p_test0003",
+    "slug": "draft",
+    "title": "draft",
+    "url": "https://moonflowersociety.org/idea/draft"
+  },
+  "number": 1,
+  "latest_number": 1,
+  "baseline": true,
+  "published_at": "2026-10-06T15:00:00Z",
+  "updated_at": "2026-10-06T15:00:00Z",
+  "url": "https://moonflowersociety.org/idea/draft/v/1",
+  "title": "draft",
+  "body_markdown": "A draft.\n",
+  "frontmatter": {},
+  "diff": null
+}
@@ -0,0 +1,23 @@+{
+  "uid": "p_test0002",
+  "slug": "notes",
+  "path": "notes.md",
+  "title": "Notes",
+  "url": "https://moonflowersociety.org/idea/notes",
+  "history_url": "https://moonflowersociety.org/idea/notes/history",
+  "original_date": "2026-10-02",
+  "first_seen_at": "2026-10-02T15:00:00Z",
+  "revision_count": 0,
+  "latest_number": 1,
+  "versions": [
+    {
+      "number": 1,
+      "baseline": true,
+      "published_at": "2026-10-02T15:00:00Z",
+      "updated_at": "2026-10-02T15:00:00Z",
+      "url": "https://moonflowersociety.org/idea/notes/v/1",
+      "words_added": 0,
+      "words_removed": 0
+    }
+  ]
+}
@@ -0,0 +1,18 @@+{
+  "page": {
+    "uid": "p_test0002",
+    "slug": "notes",
+    "title": "Notes",
+    "url": "https://moonflowersociety.org/idea/notes"
+  },
+  "number": 1,
+  "latest_number": 1,
+  "baseline": true,
+  "published_at": "2026-10-02T15:00:00Z",
+  "updated_at": "2026-10-02T15:00:00Z",
+  "url": "https://moonflowersociety.org/idea/notes/v/1",
+  "title": "Notes",
+  "body_markdown": "# Notes\n\nA page that never changes.\n",
+  "frontmatter": {},
+  "diff": null
+}
@@ -0,0 +1,48 @@+{
+  "uid": "p_test0001",
+  "slug": "remote-work",
+  "path": "2026-06-21-remote-work.md",
+  "title": "Remote work",
+  "url": "https://moonflowersociety.org/idea/remote-work",
+  "history_url": "https://moonflowersociety.org/idea/remote-work/history",
+  "maturity": "sprout",
+  "original_date": "2026-06-21",
+  "first_seen_at": "2026-10-01T15:00:00Z",
+  "last_revised_at": "2026-10-04T15:00:00Z",
+  "revision_count": 2,
+  "latest_number": 3,
+  "versions": [
+    {
+      "number": 3,
+      "published_at": "2026-10-04T15:00:00Z",
+      "updated_at": "2026-10-05T15:00:00Z",
+      "note": "Added what I'd tell a new team & why.",
+      "kind": "expansion",
+      "maturity": "sprout",
+      "url": "https://moonflowersociety.org/idea/remote-work/v/3",
+      "words_added": 8,
+      "words_removed": 0
+    },
+    {
+      "number": 2,
+      "published_at": "2026-10-03T15:00:00Z",
+      "updated_at": "2026-10-03T15:00:00Z",
+      "note": "Two years in, I changed my mind.",
+      "kind": "position",
+      "maturity": "sprout",
+      "url": "https://moonflowersociety.org/idea/remote-work/v/2",
+      "words_added": 12,
+      "words_removed": 9
+    },
+    {
+      "number": 1,
+      "baseline": true,
+      "published_at": "2026-10-01T15:00:00Z",
+      "updated_at": "2026-10-01T15:00:00Z",
+      "maturity": "seed",
+      "url": "https://moonflowersociety.org/idea/remote-work/v/1",
+      "words_added": 0,
+      "words_removed": 0
+    }
+  ]
+}
@@ -0,0 +1,21 @@+{
+  "page": {
+    "uid": "p_test0001",
+    "slug": "remote-work",
+    "title": "Remote work",
+    "url": "https://moonflowersociety.org/idea/remote-work"
+  },
+  "number": 1,
+  "latest_number": 3,
+  "baseline": true,
+  "published_at": "2026-10-01T15:00:00Z",
+  "updated_at": "2026-10-01T15:00:00Z",
+  "maturity": "seed",
+  "url": "https://moonflowersociety.org/idea/remote-work/v/1",
+  "title": "Remote work",
+  "body_markdown": "# Remote work\n\nThe hybrid model is the worst of both worlds.\n\nSmall teams should pick one place.\n",
+  "frontmatter": {
+    "title": "Remote work"
+  },
+  "diff": null
+}
@@ -0,0 +1,63 @@+{
+  "page": {
+    "uid": "p_test0001",
+    "slug": "remote-work",
+    "title": "Remote work",
+    "url": "https://moonflowersociety.org/idea/remote-work"
+  },
+  "number": 2,
+  "latest_number": 3,
+  "published_at": "2026-10-03T15:00:00Z",
+  "updated_at": "2026-10-03T15:00:00Z",
+  "note": "Two years in, I changed my mind.",
+  "kind": "position",
+  "maturity": "sprout",
+  "url": "https://moonflowersociety.org/idea/remote-work/v/2",
+  "title": "Remote work",
+  "body_markdown": "# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n",
+  "frontmatter": {
+    "title": "Remote work"
+  },
+  "base_number": 1,
+  "diff": {
+    "ops": [
+      {
+        "op": "eq",
+        "kind": "heading",
+        "text": "Remote work"
+      },
+      {
+        "op": "modify",
+        "kind": "paragraph",
+        "segments": [
+          {
+            "t": "del",
+            "s": "The hybrid model is the worst of both worlds."
+          },
+          {
+            "t": "ins",
+            "s": "The hybrid model works when the team agrees on fixed office days."
+          }
+        ],
+        "replaced": true
+      },
+      {
+        "op": "eq",
+        "kind": "paragraph",
+        "text": "Small teams should pick one place."
+      }
+    ],
+    "stats": {
+      "words_added": 12,
+      "words_removed": 9,
+      "blocks_changed": 1
+    },
+    "meta_changes": [
+      {
+        "key": "maturity",
+        "old": "seed",
+        "new": "sprout"
+      }
+    ]
+  }
+}
@@ -0,0 +1,56 @@+{
+  "page": {
+    "uid": "p_test0001",
+    "slug": "remote-work",
+    "title": "Remote work",
+    "url": "https://moonflowersociety.org/idea/remote-work"
+  },
+  "number": 3,
+  "latest_number": 3,
+  "published_at": "2026-10-04T15:00:00Z",
+  "updated_at": "2026-10-05T15:00:00Z",
+  "note": "Added what I'd tell a new team & why.",
+  "kind": "expansion",
+  "maturity": "sprout",
+  "url": "https://moonflowersociety.org/idea/remote-work/v/3",
+  "title": "Remote work",
+  "body_markdown": "# Remote work\n\nThe hybrid model works when the team agrees on fixed office days.\n\nSmall teams should pick one place.\n\n- meet in person every month\n- write things down\n",
+  "frontmatter": {
+    "title": "Remote work"
+  },
+  "base_number": 2,
+  "diff": {
+    "ops": [
+      {
+        "op": "eq",
+        "kind": "heading",
+        "text": "Remote work"
+      },
+      {
+        "op": "eq",
+        "kind": "paragraph",
+        "text": "The hybrid model works when the team agrees on fixed office days."
+      },
+      {
+        "op": "eq",
+        "kind": "paragraph",
+        "text": "Small teams should pick one place."
+      },
+      {
+        "op": "insert",
+        "kind": "list_item",
+        "text": "meet in person every month"
+      },
+      {
+        "op": "insert",
+        "kind": "list_item",
+        "text": "write things down"
+      }
+    ],
+    "stats": {
+      "words_added": 8,
+      "words_removed": 0,
+      "blocks_changed": 2
+    }
+  }
+}
@@ -0,0 +1,114 @@+{
+  "revisions": [
+    {
+      "id": "tag:moonflowersociety.org,2026:patchwork/p_test0001/r3",
+      "number": 3,
+      "base_number": 2,
+      "page": {
+        "uid": "p_test0001",
+        "slug": "remote-work",
+        "title": "Remote work",
+        "url": "https://moonflowersociety.org/idea/remote-work"
+      },
+      "kind": "expansion",
+      "maturity": "sprout",
+      "note": "Added what I'd tell a new team & why.",
+      "published_at": "2026-10-04T15:00:00Z",
+      "updated_at": "2026-10-05T15:00:00Z",
+      "original_date": "2026-06-21",
+      "version_url": "https://moonflowersociety.org/idea/remote-work/v/3",
+      "history_url": "https://moonflowersociety.org/idea/remote-work/history",
+      "stats": {
+        "words_added": 8,
+        "words_removed": 0,
+        "blocks_changed": 2
+      },
+      "meta_changes": [],
+      "excerpt": {
+        "truncated": false,
+        "blocks": [
+          {
+            "op": "gap",
+            "count": 2
+          },
+          {
+            "op": "eq",
+            "kind": "paragraph",
+            "text": "Small teams should pick one place."
+          },
+          {
+            "op": "insert",
+            "kind": "list_item",
+            "text": "meet in person every month"
+          },
+          {
+            "op": "insert",
+            "kind": "list_item",
+            "text": "write things down"
+          }
+        ]
+      }
+    },
+    {
+      "id": "tag:moonflowersociety.org,2026:patchwork/p_test0001/r2",
+      "number": 2,
+      "base_number": 1,
+      "page": {
+        "uid": "p_test0001",
+        "slug": "remote-work",
+        "title": "Remote work",
+        "url": "https://moonflowersociety.org/idea/remote-work"
+      },
+      "kind": "position",
+      "maturity": "sprout",
+      "note": "Two years in, I changed my mind.",
+      "published_at": "2026-10-03T15:00:00Z",
+      "updated_at": "2026-10-03T15:00:00Z",
+      "original_date": "2026-06-21",
+      "version_url": "https://moonflowersociety.org/idea/remote-work/v/2",
+      "history_url": "https://moonflowersociety.org/idea/remote-work/history",
+      "stats": {
+        "words_added": 12,
+        "words_removed": 9,
+        "blocks_changed": 1
+      },
+      "meta_changes": [
+        {
+          "key": "maturity",
+          "old": "seed",
+          "new": "sprout"
+        }
+      ],
+      "excerpt": {
+        "truncated": false,
+        "blocks": [
+          {
+            "op": "eq",
+            "kind": "heading",
+            "text": "Remote work"
+          },
+          {
+            "op": "modify",
+            "kind": "paragraph",
+            "segments": [
+              {
+                "t": "del",
+                "s": "The hybrid model is the worst of both worlds."
+              },
+              {
+                "t": "ins",
+                "s": "The hybrid model works when the team agrees on fixed office days."
+              }
+            ],
+            "replaced": true
+          },
+          {
+            "op": "eq",
+            "kind": "paragraph",
+            "text": "Small teams should pick one place."
+          }
+        ]
+      }
+    }
+  ]
+}
@@ -0,0 +1,12 @@+<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <id>tag:moonflowersociety.org,2026:patchwork/pages/draft</id>
+  <title>draft (Moon Flower Society): revisions</title>
+  <updated>2026-10-06T15:00:00Z</updated>
+  <author>
+    <name>Pablo Murad</name>
+  </author>
+  <link rel="self" type="application/atom+xml" href="https://moonflowersociety.org/idea/draft/revisions.atom"></link>
+  <link rel="alternate" type="text/html" href="https://moonflowersociety.org/idea/draft"></link>
+  <generator uri="https://git.pablomurad.com/pablo/patchwork">Patchwork</generator>
+</feed>
@@ -0,0 +1,12 @@+{
+  "version": "https://jsonfeed.org/version/1.1",
+  "title": "draft (Moon Flower Society): revisions",
+  "home_page_url": "https://moonflowersociety.org/idea/draft",
+  "feed_url": "https://moonflowersociety.org/idea/draft/revisions.json",
+  "authors": [
+    {
+      "name": "Pablo Murad"
+    }
+  ],
+  "items": []
+}
@@ -0,0 +1,12 @@+<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <id>tag:moonflowersociety.org,2026:patchwork/pages/notes</id>
+  <title>Notes (Moon Flower Society): revisions</title>
+  <updated>2026-10-06T15:00:00Z</updated>
+  <author>
+    <name>Pablo Murad</name>
+  </author>
+  <link rel="self" type="application/atom+xml" href="https://moonflowersociety.org/idea/notes/revisions.atom"></link>
+  <link rel="alternate" type="text/html" href="https://moonflowersociety.org/idea/notes"></link>
+  <generator uri="https://git.pablomurad.com/pablo/patchwork">Patchwork</generator>
+</feed>
@@ -0,0 +1,12 @@+{
+  "version": "https://jsonfeed.org/version/1.1",
+  "title": "Notes (Moon Flower Society): revisions",
+  "home_page_url": "https://moonflowersociety.org/idea/notes",
+  "feed_url": "https://moonflowersociety.org/idea/notes/revisions.json",
+  "authors": [
+    {
+      "name": "Pablo Murad"
+    }
+  ],
+  "items": []
+}
@@ -0,0 +1,34 @@+<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <id>tag:moonflowersociety.org,2026:patchwork/pages/remote-work</id>
+  <title>Remote work (Moon Flower Society): revisions</title>
+  <updated>2026-10-05T15:00:00Z</updated>
+  <author>
+    <name>Pablo Murad</name>
+  </author>
+  <link rel="self" type="application/atom+xml" href="https://moonflowersociety.org/idea/remote-work/revisions.atom"></link>
+  <link rel="alternate" type="text/html" href="https://moonflowersociety.org/idea/remote-work"></link>
+  <generator uri="https://git.pablomurad.com/pablo/patchwork">Patchwork</generator>
+  <entry>
+    <id>tag:moonflowersociety.org,2026:patchwork/p_test0001/r3</id>
+    <title>Expansion: Remote work</title>
+    <published>2026-10-04T15:00:00Z</published>
+    <updated>2026-10-05T15:00:00Z</updated>
+    <link rel="alternate" type="text/html" href="https://moonflowersociety.org/idea/remote-work/v/3"></link>
+    <link rel="related" type="text/html" href="https://moonflowersociety.org/idea/remote-work"></link>
+    <category term="expansion"></category>
+    <summary>Added what I&#39;d tell a new team &amp; why.</summary>
+    <content type="html">&lt;p&gt;&lt;strong&gt;Added what I&amp;#39;d tell a new team &amp;amp; why.&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;small&gt;Expansion · maturity: sprout · +8 / −0 words&lt;/small&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;&lt;small&gt;[2 unchanged]&lt;/small&gt;&lt;/p&gt;&#xA;&lt;p&gt;Small teams should pick one place.&lt;/p&gt;&#xA;&lt;ul&gt;&lt;li&gt;&lt;ins&gt;meet in person every month&lt;/ins&gt;&lt;/li&gt;&lt;/ul&gt;&#xA;&lt;ul&gt;&lt;li&gt;&lt;ins&gt;write things down&lt;/ins&gt;&lt;/li&gt;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/v/3&#34;&gt;Read version 3&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work&#34;&gt;Current page&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/history&#34;&gt;History&lt;/a&gt;&lt;/p&gt;&#xA;</content>
+  </entry>
+  <entry>
+    <id>tag:moonflowersociety.org,2026:patchwork/p_test0001/r2</id>
+    <title>Change of position: Remote work</title>
+    <published>2026-10-03T15:00:00Z</published>
+    <updated>2026-10-03T15:00:00Z</updated>
+    <link rel="alternate" type="text/html" href="https://moonflowersociety.org/idea/remote-work/v/2"></link>
+    <link rel="related" type="text/html" href="https://moonflowersociety.org/idea/remote-work"></link>
+    <category term="position"></category>
+    <summary>Two years in, I changed my mind.</summary>
+    <content type="html">&lt;p&gt;&lt;strong&gt;Two years in, I changed my mind.&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;small&gt;Change of position · maturity: sprout · +12 / −9 words&lt;/small&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h3&gt;Remote work&lt;/h3&gt;&#xA;&lt;p&gt;&lt;del&gt;The hybrid model is the worst of both worlds.&lt;/del&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;ins&gt;The hybrid model works when the team agrees on fixed office days.&lt;/ins&gt;&lt;/p&gt;&#xA;&lt;p&gt;Small teams should pick one place.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/v/2&#34;&gt;Read version 2&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work&#34;&gt;Current page&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/history&#34;&gt;History&lt;/a&gt;&lt;/p&gt;&#xA;</content>
+  </entry>
+</feed>
@@ -0,0 +1,57 @@+{
+  "version": "https://jsonfeed.org/version/1.1",
+  "title": "Remote work (Moon Flower Society): revisions",
+  "home_page_url": "https://moonflowersociety.org/idea/remote-work",
+  "feed_url": "https://moonflowersociety.org/idea/remote-work/revisions.json",
+  "authors": [
+    {
+      "name": "Pablo Murad"
+    }
+  ],
+  "items": [
+    {
+      "id": "tag:moonflowersociety.org,2026:patchwork/p_test0001/r3",
+      "url": "https://moonflowersociety.org/idea/remote-work/v/3",
+      "external_url": "https://moonflowersociety.org/idea/remote-work",
+      "title": "Expansion: Remote work",
+      "summary": "Added what I'd tell a new team & why.",
+      "content_html": "<p><strong>Added what I&#39;d tell a new team &amp; why.</strong></p>\n<p><small>Expansion · maturity: sprout · +8 / −0 words</small></p>\n<hr>\n<p><small>[2 unchanged]</small></p>\n<p>Small teams should pick one place.</p>\n<ul><li><ins>meet in person every month</ins></li></ul>\n<ul><li><ins>write things down</ins></li></ul>\n<p><a href=\"https://moonflowersociety.org/idea/remote-work/v/3\">Read version 3</a> · <a href=\"https://moonflowersociety.org/idea/remote-work\">Current page</a> · <a href=\"https://moonflowersociety.org/idea/remote-work/history\">History</a></p>\n",
+      "date_published": "2026-10-04T15:00:00Z",
+      "date_modified": "2026-10-05T15:00:00Z",
+      "tags": [
+        "expansion"
+      ],
+      "_patchwork": {
+        "about": "https://git.pablomurad.com/pablo/patchwork",
+        "kind": "expansion",
+        "maturity": "sprout",
+        "number": 3,
+        "page_url": "https://moonflowersociety.org/idea/remote-work",
+        "version_url": "https://moonflowersociety.org/idea/remote-work/v/3",
+        "history_url": "https://moonflowersociety.org/idea/remote-work/history"
+      }
+    },
+    {
+      "id": "tag:moonflowersociety.org,2026:patchwork/p_test0001/r2",
+      "url": "https://moonflowersociety.org/idea/remote-work/v/2",
+      "external_url": "https://moonflowersociety.org/idea/remote-work",
+      "title": "Change of position: Remote work",
+      "summary": "Two years in, I changed my mind.",
+      "content_html": "<p><strong>Two years in, I changed my mind.</strong></p>\n<p><small>Change of position · maturity: sprout · +12 / −9 words</small></p>\n<hr>\n<h3>Remote work</h3>\n<p><del>The hybrid model is the worst of both worlds.</del></p>\n<p><ins>The hybrid model works when the team agrees on fixed office days.</ins></p>\n<p>Small teams should pick one place.</p>\n<p><a href=\"https://moonflowersociety.org/idea/remote-work/v/2\">Read version 2</a> · <a href=\"https://moonflowersociety.org/idea/remote-work\">Current page</a> · <a href=\"https://moonflowersociety.org/idea/remote-work/history\">History</a></p>\n",
+      "date_published": "2026-10-03T15:00:00Z",
+      "date_modified": "2026-10-03T15:00:00Z",
+      "tags": [
+        "position"
+      ],
+      "_patchwork": {
+        "about": "https://git.pablomurad.com/pablo/patchwork",
+        "kind": "position",
+        "maturity": "sprout",
+        "number": 2,
+        "page_url": "https://moonflowersociety.org/idea/remote-work",
+        "version_url": "https://moonflowersociety.org/idea/remote-work/v/2",
+        "history_url": "https://moonflowersociety.org/idea/remote-work/history"
+      }
+    }
+  ]
+}
@@ -0,0 +1,34 @@+<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <id>tag:moonflowersociety.org,2026:patchwork/revisions</id>
+  <title>Moon Flower Society: revisions</title>
+  <updated>2026-10-05T15:00:00Z</updated>
+  <author>
+    <name>Pablo Murad</name>
+  </author>
+  <link rel="self" type="application/atom+xml" href="https://moonflowersociety.org/revisions.atom"></link>
+  <link rel="alternate" type="text/html" href="https://moonflowersociety.org/"></link>
+  <generator uri="https://git.pablomurad.com/pablo/patchwork">Patchwork</generator>
+  <entry>
+    <id>tag:moonflowersociety.org,2026:patchwork/p_test0001/r3</id>
+    <title>Expansion: Remote work</title>
+    <published>2026-10-04T15:00:00Z</published>
+    <updated>2026-10-05T15:00:00Z</updated>
+    <link rel="alternate" type="text/html" href="https://moonflowersociety.org/idea/remote-work/v/3"></link>
+    <link rel="related" type="text/html" href="https://moonflowersociety.org/idea/remote-work"></link>
+    <category term="expansion"></category>
+    <summary>Added what I&#39;d tell a new team &amp; why.</summary>
+    <content type="html">&lt;p&gt;&lt;strong&gt;Added what I&amp;#39;d tell a new team &amp;amp; why.&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;small&gt;Expansion · maturity: sprout · +8 / −0 words&lt;/small&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;p&gt;&lt;small&gt;[2 unchanged]&lt;/small&gt;&lt;/p&gt;&#xA;&lt;p&gt;Small teams should pick one place.&lt;/p&gt;&#xA;&lt;ul&gt;&lt;li&gt;&lt;ins&gt;meet in person every month&lt;/ins&gt;&lt;/li&gt;&lt;/ul&gt;&#xA;&lt;ul&gt;&lt;li&gt;&lt;ins&gt;write things down&lt;/ins&gt;&lt;/li&gt;&lt;/ul&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/v/3&#34;&gt;Read version 3&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work&#34;&gt;Current page&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/history&#34;&gt;History&lt;/a&gt;&lt;/p&gt;&#xA;</content>
+  </entry>
+  <entry>
+    <id>tag:moonflowersociety.org,2026:patchwork/p_test0001/r2</id>
+    <title>Change of position: Remote work</title>
+    <published>2026-10-03T15:00:00Z</published>
+    <updated>2026-10-03T15:00:00Z</updated>
+    <link rel="alternate" type="text/html" href="https://moonflowersociety.org/idea/remote-work/v/2"></link>
+    <link rel="related" type="text/html" href="https://moonflowersociety.org/idea/remote-work"></link>
+    <category term="position"></category>
+    <summary>Two years in, I changed my mind.</summary>
+    <content type="html">&lt;p&gt;&lt;strong&gt;Two years in, I changed my mind.&lt;/strong&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;small&gt;Change of position · maturity: sprout · +12 / −9 words&lt;/small&gt;&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h3&gt;Remote work&lt;/h3&gt;&#xA;&lt;p&gt;&lt;del&gt;The hybrid model is the worst of both worlds.&lt;/del&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;ins&gt;The hybrid model works when the team agrees on fixed office days.&lt;/ins&gt;&lt;/p&gt;&#xA;&lt;p&gt;Small teams should pick one place.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/v/2&#34;&gt;Read version 2&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work&#34;&gt;Current page&lt;/a&gt; · &lt;a href=&#34;https://moonflowersociety.org/idea/remote-work/history&#34;&gt;History&lt;/a&gt;&lt;/p&gt;&#xA;</content>
+  </entry>
+</feed>
@@ -0,0 +1,57 @@+{
+  "version": "https://jsonfeed.org/version/1.1",
+  "title": "Moon Flower Society: revisions",
+  "home_page_url": "https://moonflowersociety.org/",
+  "feed_url": "https://moonflowersociety.org/revisions.json",
+  "authors": [
+    {
+      "name": "Pablo Murad"
+    }
+  ],
+  "items": [
+    {
+      "id": "tag:moonflowersociety.org,2026:patchwork/p_test0001/r3",
+      "url": "https://moonflowersociety.org/idea/remote-work/v/3",
+      "external_url": "https://moonflowersociety.org/idea/remote-work",
+      "title": "Expansion: Remote work",
+      "summary": "Added what I'd tell a new team & why.",
+      "content_html": "<p><strong>Added what I&#39;d tell a new team &amp; why.</strong></p>\n<p><small>Expansion · maturity: sprout · +8 / −0 words</small></p>\n<hr>\n<p><small>[2 unchanged]</small></p>\n<p>Small teams should pick one place.</p>\n<ul><li><ins>meet in person every month</ins></li></ul>\n<ul><li><ins>write things down</ins></li></ul>\n<p><a href=\"https://moonflowersociety.org/idea/remote-work/v/3\">Read version 3</a> · <a href=\"https://moonflowersociety.org/idea/remote-work\">Current page</a> · <a href=\"https://moonflowersociety.org/idea/remote-work/history\">History</a></p>\n",
+      "date_published": "2026-10-04T15:00:00Z",
+      "date_modified": "2026-10-05T15:00:00Z",
+      "tags": [
+        "expansion"
+      ],
+      "_patchwork": {
+        "about": "https://git.pablomurad.com/pablo/patchwork",
+        "kind": "expansion",
+        "maturity": "sprout",
+        "number": 3,
+        "page_url": "https://moonflowersociety.org/idea/remote-work",
+        "version_url": "https://moonflowersociety.org/idea/remote-work/v/3",
+        "history_url": "https://moonflowersociety.org/idea/remote-work/history"
+      }
+    },
+    {
+      "id": "tag:moonflowersociety.org,2026:patchwork/p_test0001/r2",
+      "url": "https://moonflowersociety.org/idea/remote-work/v/2",
+      "external_url": "https://moonflowersociety.org/idea/remote-work",
+      "title": "Change of position: Remote work",
+      "summary": "Two years in, I changed my mind.",
+      "content_html": "<p><strong>Two years in, I changed my mind.</strong></p>\n<p><small>Change of position · maturity: sprout · +12 / −9 words</small></p>\n<hr>\n<h3>Remote work</h3>\n<p><del>The hybrid model is the worst of both worlds.</del></p>\n<p><ins>The hybrid model works when the team agrees on fixed office days.</ins></p>\n<p>Small teams should pick one place.</p>\n<p><a href=\"https://moonflowersociety.org/idea/remote-work/v/2\">Read version 2</a> · <a href=\"https://moonflowersociety.org/idea/remote-work\">Current page</a> · <a href=\"https://moonflowersociety.org/idea/remote-work/history\">History</a></p>\n",
+      "date_published": "2026-10-03T15:00:00Z",
+      "date_modified": "2026-10-03T15:00:00Z",
+      "tags": [
+        "position"
+      ],
+      "_patchwork": {
+        "about": "https://git.pablomurad.com/pablo/patchwork",
+        "kind": "position",
+        "maturity": "sprout",
+        "number": 2,
+        "page_url": "https://moonflowersociety.org/idea/remote-work",
+        "version_url": "https://moonflowersociety.org/idea/remote-work/v/2",
+        "history_url": "https://moonflowersociety.org/idea/remote-work/history"
+      }
+    }
+  ]
+}
@@ -0,0 +1,6 @@+draft
+first published 2026-10-06 · 0 revisions
+────────────────────────────────────────────────────────────────────────────────
+
+v1  first version
+    Oct 6, 2026 12:00
@@ -0,0 +1,6 @@+Notes
+first published 2026-10-02 · 0 revisions
+────────────────────────────────────────────────────────────────────────────────
+
+v1  first version
+    Oct 2, 2026 12:00
@@ -0,0 +1,14 @@+Remote work
+first published 2026-06-21 · 2 revisions · sprout
+────────────────────────────────────────────────────────────────────────────────
+
+v3  Expansion  +8 -0
+    “Added what I'd tell a new team & why.”
+    Oct 4, 2026 12:00
+
+v2  Change of position  +12 -9
+    “Two years in, I changed my mind.”
+    Oct 3, 2026 12:00
+
+v1  first version
+    Oct 1, 2026 12:00
@@ -0,0 +1,33 @@+Moon Flower Society: revisions
+What changed in pages that keep growing.
+────────────────────────────────────────────────────────────────────────────────
+
+Expansion: Remote work
+Oct 4, 2026 12:00 · version 3 · +8 -0 words · sprout
+
+  “Added what I'd tell a new team & why.”
+
+    … 2 unchanged
+
+    Small teams should pick one place.
+
+  + meet in person every month
+
+  + write things down
+
+https://moonflowersociety.org/idea/remote-work/v/3
+────────────────────────────────────────────────────────────────────────────────
+
+Change of position: Remote work
+Oct 3, 2026 12:00 · version 2 · +12 -9 words · sprout
+
+  “Two years in, I changed my mind.”
+
+    Remote work
+
+  - The hybrid model is the worst of both worlds.
+  + The hybrid model works when the team agrees on fixed office days.
+
+    Small teams should pick one place.
+
+https://moonflowersociety.org/idea/remote-work/v/2
@@ -0,0 +1,315 @@+package render
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"golang.org/x/text/width"
+
+	"git.pablomurad.com/pablo/patchwork/internal/diff"
+)
+
+// TextOptions shape terminal output.
+type TextOptions struct {
+	Width int  // columns; 48..160
+	Color bool // ANSI colors
+}
+
+func (o TextOptions) normalized() TextOptions {
+	switch {
+	case o.Width <= 0:
+		o.Width = 80
+	case o.Width < 48:
+		o.Width = 48
+	case o.Width > 160:
+		o.Width = 160
+	}
+	return o
+}
+
+type style int
+
+const (
+	plain style = iota
+	dim
+	bold
+	red   // deleted words inside a changed block
+	green // inserted words inside a changed block
+	// Whole deleted or inserted blocks: colored, but in plain output the
+	// line's own "-" or "+" already says it, so no brackets.
+	redBlock
+	greenBlock
+)
+
+var ansi = map[style]string{
+	dim: "\x1b[2m", bold: "\x1b[1m",
+	red: "\x1b[31m", green: "\x1b[32m", redBlock: "\x1b[31m", greenBlock: "\x1b[32m",
+}
+
+type run struct {
+	s  string
+	st style
+}
+
+// textWriter accumulates lines of styled text.
+type textWriter struct {
+	o     TextOptions
+	lines []string
+}
+
+func (w *textWriter) paint(st style, s string) string {
+	if !w.o.Color || st == plain || s == "" {
+		return s
+	}
+	return ansi[st] + s + "\x1b[0m"
+}
+
+func (w *textWriter) blank() { w.lines = append(w.lines, "") }
+
+func (w *textWriter) line(st style, s string) { w.lines = append(w.lines, w.paint(st, s)) }
+
+// para word-wraps runs into lines of at most o.Width columns. The first line
+// starts with indent (which may carry a marker like "  + "); the rest are
+// indented to the same width with spaces. Without colors, deleted words are
+// marked [-like this-] and inserted words {+like this+}, so the diff
+// survives a pipe or a file.
+func (w *textWriter) para(indent string, runs ...run) {
+	first, rest := indent, strings.Repeat(" ", visibleWidth(indent))
+	if !w.o.Color {
+		for i, r := range runs {
+			switch r.st {
+			case red:
+				runs[i] = run{"[-" + r.s + "-]", plain}
+			case green:
+				runs[i] = run{"{+" + r.s + "+}", plain}
+			}
+		}
+	}
+	max := w.o.Width - visibleWidth(indent)
+	var line strings.Builder
+	col := 0
+	flush := func() {
+		w.lines = append(w.lines, strings.TrimRight(first+line.String(), " "))
+		first = rest
+		line.Reset()
+		col = 0
+	}
+	for _, r := range runs {
+		for i, piece := range strings.Split(r.s, "\n") {
+			if i > 0 {
+				flush()
+			}
+			for _, word := range splitKeepSpace(piece) {
+				n := visibleWidth(word)
+				if strings.TrimSpace(word) == "" {
+					if col > 0 && col+n <= max {
+						line.WriteString(word)
+						col += n
+					}
+					continue
+				}
+				if col > 0 && col+n > max {
+					flush()
+				}
+				line.WriteString(w.paint(r.st, word))
+				col += n
+			}
+		}
+	}
+	if col > 0 || len(runs) > 0 {
+		flush()
+	}
+}
+
+func (w *textWriter) String() string { return strings.Join(w.lines, "\n") + "\n" }
+
+// splitKeepSpace splits into words and the spaces between them.
+func splitKeepSpace(s string) []string {
+	var out []string
+	start := 0
+	for i := 1; i <= len(s); i++ {
+		if i == len(s) || (s[i] == ' ') != (s[i-1] == ' ') {
+			out = append(out, s[start:i])
+			start = i
+		}
+	}
+	return out
+}
+
+// visibleWidth counts terminal columns: East Asian wide and fullwidth runes
+// take two.
+func visibleWidth(s string) int {
+	n := 0
+	for _, r := range s {
+		switch width.LookupRune(r).Kind() {
+		case width.EastAsianWide, width.EastAsianFullwidth:
+			n += 2
+		default:
+			n++
+		}
+	}
+	return n
+}
+
+func (w *textWriter) rule() { w.line(dim, strings.Repeat("─", w.o.Width)) }
+
+func (w *textWriter) ops(ops []diff.Op) {
+	for _, op := range ops {
+		switch op.Op {
+		case diff.OpGap:
+			if op.Removed {
+				w.line(redBlock, fmt.Sprintf("  - … %d more removed", op.Count))
+			} else {
+				w.line(dim, fmt.Sprintf("    … %d unchanged", op.Count))
+			}
+		case diff.OpEq:
+			w.para("    ", run{op.Text, dim})
+		case diff.OpInsert:
+			w.para("  + ", run{op.Text, greenBlock})
+		case diff.OpDelete:
+			w.para("  - ", run{op.Text, redBlock})
+		case diff.OpMove:
+			w.line(dim, "    (moved)")
+			w.para("    ", run{op.Text, plain})
+		case diff.OpModify:
+			if op.Replaced {
+				for _, s := range op.Segments {
+					if s.T == diff.SegDel {
+						w.para("  - ", run{s.S, redBlock})
+					} else {
+						w.para("  + ", run{s.S, greenBlock})
+					}
+				}
+				break
+			}
+			runs := make([]run, 0, len(op.Segments))
+			for _, s := range op.Segments {
+				st := plain
+				switch s.T {
+				case diff.SegDel:
+					st = red
+				case diff.SegIns:
+					st = green
+				}
+				runs = append(runs, run{s.S, st})
+			}
+			w.para("  ~ ", runs...)
+		}
+		w.blank()
+	}
+}
+
+func (w *textWriter) revision(r Revision, m *Model) {
+	when := humanDate(r.published, m.Cfg.Location())
+	w.para("", run{EntryTitle(r), bold})
+	facts := []string{when, fmt.Sprintf("version %d", r.Number), fmt.Sprintf("+%d -%d words", r.Stats.WordsAdded, r.Stats.WordsRemoved)}
+	if r.Maturity != "" {
+		facts = append(facts, r.Maturity)
+	}
+	w.para("", run{strings.Join(facts, " · "), dim})
+	if r.Note != "" {
+		w.blank()
+		w.para("  ", run{"“" + r.Note + "”", plain})
+	}
+	w.blank()
+	w.ops(r.Excerpt.Blocks)
+	if r.Excerpt.Truncated {
+		w.line(dim, "    … more changes in the full version")
+		w.blank()
+	}
+	w.para("", run{r.VersionURL, dim})
+}
+
+// TextRevisions is the recent revisions across the site.
+func (m *Model) TextRevisions(revs []Revision, o TextOptions) string {
+	w := &textWriter{o: o.normalized()}
+	title := m.Cfg.Site.Title
+	if title == "" {
+		title = "Patchwork"
+	}
+	w.para("", run{title + ": revisions", bold})
+	w.para("", run{"What changed in pages that keep growing.", dim})
+	w.rule()
+	if len(revs) == 0 {
+		w.blank()
+		w.line(plain, "No revisions yet.")
+		return w.String()
+	}
+	for i, r := range revs {
+		if i > 0 {
+			w.rule()
+		}
+		w.blank()
+		w.revision(r, m)
+	}
+	return w.String()
+}
+
+// TextHistory lists one page's public versions.
+func (m *Model) TextHistory(slug string, o TextOptions) string {
+	w := &textWriter{o: o.normalized()}
+	p, ok := m.Page(slug)
+	if !ok {
+		w.line(plain, "No page "+slug+".")
+		return w.String()
+	}
+	w.para("", run{p.Title, bold})
+	facts := []string{"first published " + p.OriginalDate, fmt.Sprintf("%d revisions", p.RevisionCount)}
+	if p.Maturity != "" {
+		facts = append(facts, p.Maturity)
+	}
+	w.para("", run{strings.Join(facts, " · "), dim})
+	w.rule()
+	for _, v := range p.Versions {
+		w.blank()
+		label := fmt.Sprintf("v%d", v.Number)
+		if v.Baseline {
+			w.para("", run{label + "  first version", bold})
+		} else {
+			kind := kindLabel[v.Kind]
+			if kind == "" {
+				kind = "Revised"
+			}
+			w.para("", run{label + "  " + kind, bold}, run{fmt.Sprintf("  +%d -%d", v.WordsAdded, v.WordsRemoved), dim})
+			if v.Note != "" {
+				w.para("    ", run{"“" + v.Note + "”", plain})
+			}
+		}
+		if t, err := time.Parse(time.RFC3339, v.PublishedAt); err == nil {
+			w.para("    ", run{humanDate(t, m.Cfg.Location()), dim})
+		}
+	}
+	return w.String()
+}
+
+// TextVersion shows one version's diff against the previous public version.
+func (m *Model) TextVersion(slug string, n int, o TextOptions) (string, bool) {
+	w := &textWriter{o: o.normalized()}
+	for _, v := range m.Versions[slug] {
+		if v.Number != n {
+			continue
+		}
+		w.para("", run{fmt.Sprintf("%s · version %d of %d", v.Title, v.Number, v.LatestNumber), bold})
+		if v.Note != "" {
+			w.para("  ", run{"“" + v.Note + "”", plain})
+		}
+		w.rule()
+		w.blank()
+		if v.Diff == nil {
+			w.para("", run{v.BodyMarkdown, plain})
+			return w.String(), true
+		}
+		for _, mc := range v.Diff.Meta {
+			w.para("  ", run{mc.Key + ": ", dim}, run{metaText(mc.Old), red}, run{" → ", dim}, run{metaText(mc.New), green})
+		}
+		ops, _ := diff.Excerpt(v.Diff.Ops, 1, len(v.Diff.Ops))
+		w.ops(ops)
+		return w.String(), true
+	}
+	return "", false
+}
+
+func humanDate(t time.Time, loc *time.Location) string {
+	return t.In(loc).Format("Jan 2, 2006 15:04")
+}
@@ -0,0 +1,145 @@+package render
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"sort"
+	"strconv"
+
+	"git.pablomurad.com/pablo/patchwork/internal/config"
+	"git.pablomurad.com/pablo/patchwork/internal/store"
+)
+
+// File is one published file.
+type File struct {
+	Body        []byte
+	ContentType string
+	ETag        string
+}
+
+// Tree is the whole published output, keyed by path without a leading
+// slash ("api/v1/pages.json").
+type Tree struct {
+	Files map[string]File
+	Model *Model
+}
+
+// Paths lists the tree's paths in order.
+func (t *Tree) Paths() []string {
+	out := make([]string, 0, len(t.Files))
+	for p := range t.Files {
+		out = append(out, p)
+	}
+	sort.Strings(out)
+	return out
+}
+
+const (
+	typeJSON     = "application/json; charset=utf-8"
+	typeAtom     = "application/atom+xml; charset=utf-8"
+	typeJSONFeed = "application/feed+json; charset=utf-8"
+	typeText     = "text/plain; charset=utf-8"
+)
+
+// Build loads the store and renders every file.
+func Build(ctx context.Context, st *store.Store, cfg *config.Config) (*Tree, error) {
+	m, err := Load(ctx, st, cfg)
+	if err != nil {
+		return nil, err
+	}
+	return m.Tree()
+}
+
+// Tree renders a loaded model.
+func (m *Model) Tree() (*Tree, error) {
+	t := &Tree{Files: map[string]File{}, Model: m}
+	put := func(path, ctype string, body []byte) {
+		sum := sha256.Sum256(body)
+		t.Files[path] = File{Body: body, ContentType: ctype, ETag: `"` + hex.EncodeToString(sum[:12]) + `"`}
+	}
+	putJSON := func(path string, v any) error {
+		b, err := marshal(v)
+		if err != nil {
+			return err
+		}
+		put(path, typeJSON, b)
+		return nil
+	}
+
+	summaries := make([]PageSummary, 0, len(m.Pages))
+	for _, p := range m.Pages {
+		summaries = append(summaries, p.PageSummary)
+	}
+	if err := putJSON("api/v1/pages.json", PageList{Pages: summaries}); err != nil {
+		return nil, err
+	}
+	recent := m.Revisions
+	if len(recent) > m.Cfg.Feeds.Limit {
+		recent = recent[:m.Cfg.Feeds.Limit]
+	}
+	if err := putJSON("api/v1/revisions.json", RevisionList{Revisions: nonNil(recent)}); err != nil {
+		return nil, err
+	}
+
+	atom, err := m.Atom(recent, "", m.Cfg.Site.Feeds.Atom)
+	if err != nil {
+		return nil, err
+	}
+	put("feeds/revisions.atom", typeAtom, atom)
+	jf, err := m.JSONFeed(recent, "", m.Cfg.Site.Feeds.JSON)
+	if err != nil {
+		return nil, err
+	}
+	put("feeds/revisions.json", typeJSONFeed, jf)
+	put("text/revisions.txt", typeText, []byte(m.TextRevisions(recent, TextOptions{Width: 80})))
+
+	for _, p := range m.Pages {
+		if err := putJSON("api/v1/pages/"+p.Slug+".json", p); err != nil {
+			return nil, err
+		}
+		for _, v := range m.Versions[p.Slug] {
+			if err := putJSON("api/v1/pages/"+p.Slug+"/versions/"+strconv.Itoa(v.Number)+".json", v); err != nil {
+				return nil, err
+			}
+		}
+		revs := m.PageRevisions(p.Slug)
+		if len(revs) > m.Cfg.Feeds.Limit {
+			revs = revs[:m.Cfg.Feeds.Limit]
+		}
+		atom, err := m.Atom(revs, p.Slug, m.Cfg.Site.Feeds.PageAtom)
+		if err != nil {
+			return nil, err
+		}
+		put("feeds/pages/"+p.Slug+".atom", typeAtom, atom)
+		jf, err := m.JSONFeed(revs, p.Slug, m.Cfg.Site.Feeds.PageJSON)
+		if err != nil {
+			return nil, err
+		}
+		put("feeds/pages/"+p.Slug+".json", typeJSONFeed, jf)
+		put("text/pages/"+p.Slug+"/history.txt", typeText, []byte(m.TextHistory(p.Slug, TextOptions{Width: 80})))
+	}
+	return t, nil
+}
+
+// marshal writes indented JSON without escaping <, > and &: the output is
+// data for sites and people, never inlined into HTML by Patchwork.
+func marshal(v any) ([]byte, error) {
+	var buf bytes.Buffer
+	enc := json.NewEncoder(&buf)
+	enc.SetEscapeHTML(false)
+	enc.SetIndent("", "  ")
+	if err := enc.Encode(v); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+func nonNil(r []Revision) []Revision {
+	if r == nil {
+		return []Revision{}
+	}
+	return r
+}
@@ -0,0 +1,234 @@+// Package server serves the published tree over HTTP.
+package server
+
+import (
+	"context"
+	"encoding/json"
+	"log/slog"
+	"net/http"
+	"strconv"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"git.pablomurad.com/pablo/patchwork/internal/render"
+)
+
+// Server serves the latest tree. Swap replaces it atomically, so a request
+// always sees one consistent build.
+type Server struct {
+	tree atomic.Pointer[render.Tree]
+	log  *slog.Logger
+	mux  *http.ServeMux
+}
+
+// New builds a server; call Swap before serving.
+func New(log *slog.Logger) *Server {
+	s := &Server{log: log, mux: http.NewServeMux()}
+	s.mux.HandleFunc("GET /healthz", s.health)
+	for _, p := range []string{"pages", "revisions"} {
+		s.mux.HandleFunc("GET /api/v1/"+p, s.file("api/v1/"+p+".json"))
+		s.mux.HandleFunc("GET /api/v1/"+p+".json", s.file("api/v1/"+p+".json"))
+	}
+	// Slugs may contain slashes (the "path" slug rule), so page routes take
+	// the rest of the path and split it themselves.
+	s.mux.HandleFunc("GET /api/v1/pages/{rest...}", s.pageAPI)
+	s.mux.HandleFunc("GET /feeds/{rest...}", s.feed)
+	s.mux.HandleFunc("GET /text/revisions", s.textRevisions)
+	s.mux.HandleFunc("GET /text/revisions.txt", s.textRevisions)
+	s.mux.HandleFunc("GET /text/pages/{rest...}", s.textPage)
+	s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodGet && r.Method != http.MethodHead {
+			writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET and HEAD")
+			return
+		}
+		writeError(w, http.StatusNotFound, "not_found", "no such path")
+	})
+	return s
+}
+
+// Swap publishes a new tree.
+func (s *Server) Swap(t *render.Tree) { s.tree.Store(t) }
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	start := time.Now()
+	sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
+	s.mux.ServeHTTP(sw, r)
+	s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dur", time.Since(start))
+}
+
+// ListenAndServe serves until ctx is done, then drains for a few seconds.
+func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
+	hs := &http.Server{Addr: addr, Handler: s, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 30 * time.Second}
+	errc := make(chan error, 1)
+	go func() { errc <- hs.ListenAndServe() }()
+	select {
+	case err := <-errc:
+		return err
+	case <-ctx.Done():
+		shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		return hs.Shutdown(shut)
+	}
+}
+
+func (s *Server) health(w http.ResponseWriter, r *http.Request) {
+	if s.tree.Load() == nil {
+		writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished")
+		return
+	}
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Write([]byte("ok\n"))
+}
+
+func (s *Server) file(path string) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) { s.serve(w, r, path) }
+}
+
+// splitVersion splits "<slug>/versions/<n>" into its parts.
+func splitVersion(rest string) (slug, n string, ok bool) {
+	i := strings.LastIndex(rest, "/versions/")
+	if i < 0 {
+		return rest, "", false
+	}
+	return rest[:i], rest[i+len("/versions/"):], true
+}
+
+func (s *Server) pageAPI(w http.ResponseWriter, r *http.Request) {
+	rest := strings.TrimSuffix(r.PathValue("rest"), ".json")
+	if slug, n, ok := splitVersion(rest); ok {
+		s.serve(w, r, "api/v1/pages/"+slug+"/versions/"+n+".json")
+		return
+	}
+	s.serve(w, r, "api/v1/pages/"+rest+".json")
+}
+
+func (s *Server) feed(w http.ResponseWriter, r *http.Request) {
+	s.serve(w, r, "feeds/"+r.PathValue("rest"))
+}
+
+// serve writes one file of the tree with ETag and conditional GET.
+func (s *Server) serve(w http.ResponseWriter, r *http.Request, path string) {
+	t := s.tree.Load()
+	if t == nil {
+		writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished")
+		return
+	}
+	f, ok := t.Files[path]
+	if !ok {
+		writeError(w, http.StatusNotFound, "not_found", "no such path")
+		return
+	}
+	h := w.Header()
+	h.Set("Content-Type", f.ContentType)
+	h.Set("ETag", f.ETag)
+	h.Set("Cache-Control", "public, max-age=30")
+	h.Set("X-Content-Type-Options", "nosniff")
+	if match := r.Header.Get("If-None-Match"); match != "" && etagMatch(match, f.ETag) {
+		w.WriteHeader(http.StatusNotModified)
+		return
+	}
+	h.Set("Content-Length", strconv.Itoa(len(f.Body)))
+	if r.Method == http.MethodHead {
+		return
+	}
+	w.Write(f.Body)
+}
+
+// Text routes render on request: width and colors come from the query
+// (?w=100, ?plain), like curl-friendly sites expect.
+
+func textOptions(r *http.Request) render.TextOptions {
+	o := render.TextOptions{Width: 80, Color: true}
+	if w, err := strconv.Atoi(r.URL.Query().Get("w")); err == nil {
+		o.Width = w
+	}
+	if r.URL.Query().Has("plain") {
+		o.Color = false
+	}
+	return o
+}
+
+func (s *Server) textRevisions(w http.ResponseWriter, r *http.Request) {
+	t := s.tree.Load()
+	if t == nil {
+		writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished")
+		return
+	}
+	revs := t.Model.Revisions
+	if limit := t.Model.Cfg.Feeds.Limit; len(revs) > limit {
+		revs = revs[:limit]
+	}
+	writeText(w, r, t.Model.TextRevisions(revs, textOptions(r)))
+}
+
+// textPage serves /text/pages/<slug>/history[.txt] and
+// /text/pages/<slug>/versions/<n>[.txt].
+func (s *Server) textPage(w http.ResponseWriter, r *http.Request) {
+	t := s.tree.Load()
+	if t == nil {
+		writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished")
+		return
+	}
+	rest := strings.TrimSuffix(r.PathValue("rest"), ".txt")
+	if slug, ok := strings.CutSuffix(rest, "/history"); ok {
+		if _, found := t.Model.Page(slug); !found {
+			writeError(w, http.StatusNotFound, "not_found", "no such page")
+			return
+		}
+		writeText(w, r, t.Model.TextHistory(slug, textOptions(r)))
+		return
+	}
+	slug, ns, ok := splitVersion(rest)
+	n, err := strconv.Atoi(ns)
+	if !ok || err != nil {
+		writeError(w, http.StatusNotFound, "not_found", "no such path")
+		return
+	}
+	body, found := t.Model.TextVersion(slug, n, textOptions(r))
+	if !found {
+		writeError(w, http.StatusNotFound, "not_found", "no such version")
+		return
+	}
+	writeText(w, r, body)
+}
+
+func writeText(w http.ResponseWriter, r *http.Request, body string) {
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Header().Set("Cache-Control", "no-cache")
+	if r.Method == http.MethodHead {
+		return
+	}
+	w.Write([]byte(body))
+}
+
+func writeError(w http.ResponseWriter, status int, code, msg string) {
+	var e render.Error
+	e.Error.Code, e.Error.Message = code, msg
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	w.Header().Set("Cache-Control", "no-store")
+	w.WriteHeader(status)
+	json.NewEncoder(w).Encode(e)
+}
+
+func etagMatch(header, etag string) bool {
+	if strings.TrimSpace(header) == "*" {
+		return true
+	}
+	for _, part := range strings.Split(header, ",") {
+		if strings.TrimPrefix(strings.TrimSpace(part), "W/") == etag {
+			return true
+		}
+	}
+	return false
+}
+
+type statusWriter struct {
+	http.ResponseWriter
+	status int
+}
+
+func (w *statusWriter) WriteHeader(code int) {
+	w.status = code
+	w.ResponseWriter.WriteHeader(code)
+}
@@ -0,0 +1,115 @@+package server
+
+import (
+	"encoding/json"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"git.pablomurad.com/pablo/patchwork/internal/config"
+	"git.pablomurad.com/pablo/patchwork/internal/render"
+)
+
+func testTree() *render.Tree {
+	cfg := config.Default()
+	m := &render.Model{Cfg: cfg, Versions: map[string][]*render.Version{}}
+	m.Pages = []render.PageDetail{{PageSummary: render.PageSummary{Slug: "notes/deep", Title: "Deep"}}}
+	m.Versions["notes/deep"] = []*render.Version{{Number: 1, LatestNumber: 1, Baseline: true, Title: "Deep", BodyMarkdown: "Hello."}}
+	t, err := m.Tree()
+	if err != nil {
+		panic(err)
+	}
+	return t
+}
+
+func newServer() *Server {
+	s := New(slog.New(slog.NewTextHandler(io.Discard, nil)))
+	s.Swap(testTree())
+	return s
+}
+
+func get(s *Server, path string, hdr ...string) *httptest.ResponseRecorder {
+	req := httptest.NewRequest(http.MethodGet, path, nil)
+	for i := 0; i+1 < len(hdr); i += 2 {
+		req.Header.Set(hdr[i], hdr[i+1])
+	}
+	rec := httptest.NewRecorder()
+	s.ServeHTTP(rec, req)
+	return rec
+}
+
+func TestRoutesAndTypes(t *testing.T) {
+	s := newServer()
+	cases := map[string]string{
+		"/api/v1/pages":                       "application/json",
+		"/api/v1/pages.json":                  "application/json",
+		"/api/v1/revisions":                   "application/json",
+		"/api/v1/pages/notes/deep":            "application/json",
+		"/api/v1/pages/notes/deep.json":       "application/json",
+		"/api/v1/pages/notes/deep/versions/1": "application/json",
+		"/feeds/revisions.atom":               "application/atom+xml",
+		"/feeds/revisions.json":               "application/feed+json",
+		"/feeds/pages/notes/deep.atom":        "application/atom+xml",
+		"/text/revisions":                     "text/plain",
+		"/text/pages/notes/deep/history":      "text/plain",
+		"/text/pages/notes/deep/versions/1":   "text/plain",
+		"/healthz":                            "text/plain",
+	}
+	for path, ctype := range cases {
+		rec := get(s, path)
+		if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) {
+			t.Errorf("%s: %d %s", path, rec.Code, rec.Header().Get("Content-Type"))
+		}
+	}
+}
+
+func TestNotFoundShape(t *testing.T) {
+	s := newServer()
+	for _, path := range []string{"/nope", "/api/v1/pages/missing", "/api/v1/pages/notes/deep/versions/9", "/text/pages/notes/deep/versions/x"} {
+		rec := get(s, path)
+		var e render.Error
+		if rec.Code != http.StatusNotFound || json.Unmarshal(rec.Body.Bytes(), &e) != nil || e.Error.Code != "not_found" {
+			t.Errorf("%s: %d %s", path, rec.Code, rec.Body)
+		}
+	}
+	req := httptest.NewRequest(http.MethodPost, "/api/v1/pages", nil)
+	rec := httptest.NewRecorder()
+	s.ServeHTTP(rec, req)
+	if rec.Code != http.StatusMethodNotAllowed {
+		t.Errorf("POST: %d", rec.Code)
+	}
+}
+
+func TestETag(t *testing.T) {
+	s := newServer()
+	first := get(s, "/feeds/revisions.atom")
+	etag := first.Header().Get("ETag")
+	if etag == "" {
+		t.Fatal("no ETag")
+	}
+	if rec := get(s, "/feeds/revisions.atom", "If-None-Match", etag); rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
+		t.Fatalf("conditional GET: %d", rec.Code)
+	}
+	if rec := get(s, "/feeds/revisions.atom", "If-None-Match", `"other", W/`+etag); rec.Code != http.StatusNotModified {
+		t.Fatalf("weak/list match: %d", rec.Code)
+	}
+}
+
+func TestNotReady(t *testing.T) {
+	s := New(slog.New(slog.NewTextHandler(io.Discard, nil)))
+	if rec := get(s, "/healthz"); rec.Code != http.StatusServiceUnavailable {
+		t.Fatalf("healthz before first build: %d", rec.Code)
+	}
+}
+
+func TestTextOptions(t *testing.T) {
+	s := newServer()
+	colored := get(s, "/text/revisions").Body.String()
+	plain := get(s, "/text/revisions?plain&w=60").Body.String()
+	if !strings.Contains(colored, "\x1b[") || strings.Contains(plain, "\x1b[") {
+		t.Fatal("?plain should drop ANSI")
+	}
+}
@@ -0,0 +1,164 @@+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://git.pablomurad.com/pablo/patchwork/raw/branch/main/schema/v1/defs.json",
+  "title": "Patchwork API v1: shared definitions",
+  "$defs": {
+    "time": {
+      "type": "string",
+      "format": "date-time",
+      "description": "RFC 3339, UTC."
+    },
+    "date": {
+      "type": "string",
+      "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
+      "description": "Calendar date in the site's time zone."
+    },
+    "url": {
+      "type": "string",
+      "description": "Absolute when site.base_url is set, otherwise a path."
+    },
+    "kind": {
+      "enum": ["correction", "expansion", "position", "reorganization"]
+    },
+    "maturity": {
+      "enum": ["seed", "sprout", "bloom"]
+    },
+    "pageRef": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["uid", "slug", "title", "url"],
+      "properties": {
+        "uid": { "type": "string" },
+        "slug": { "type": "string" },
+        "title": { "type": "string" },
+        "url": { "$ref": "#/$defs/url" }
+      }
+    },
+    "pageSummary": {
+      "$ref": "#/$defs/pageSummaryFields",
+      "unevaluatedProperties": false
+    },
+    "pageSummaryFields": {
+      "description": "pageSummary without the closing rule, so page.json can extend it.",
+      "type": "object",
+      "required": ["uid", "slug", "path", "title", "url", "history_url", "original_date", "first_seen_at", "revision_count", "latest_number"],
+      "properties": {
+        "uid": { "type": "string" },
+        "slug": { "type": "string" },
+        "path": { "type": "string" },
+        "title": { "type": "string" },
+        "url": { "$ref": "#/$defs/url" },
+        "history_url": { "$ref": "#/$defs/url" },
+        "maturity": { "$ref": "#/$defs/maturity" },
+        "original_date": { "$ref": "#/$defs/date" },
+        "first_seen_at": { "$ref": "#/$defs/time" },
+        "last_revised_at": { "$ref": "#/$defs/time", "description": "Absent until the first published revision." },
+        "revision_count": { "type": "integer", "minimum": 0, "description": "Published revisions, not counting the first version." },
+        "latest_number": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "versionSummary": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["number", "published_at", "updated_at", "url", "words_added", "words_removed"],
+      "properties": {
+        "number": { "type": "integer", "minimum": 1 },
+        "baseline": { "const": true, "description": "Present only on the first version." },
+        "published_at": { "$ref": "#/$defs/time" },
+        "updated_at": { "$ref": "#/$defs/time" },
+        "note": { "type": "string" },
+        "kind": { "$ref": "#/$defs/kind" },
+        "maturity": { "$ref": "#/$defs/maturity" },
+        "url": { "$ref": "#/$defs/url" },
+        "words_added": { "type": "integer", "minimum": 0 },
+        "words_removed": { "type": "integer", "minimum": 0 }
+      }
+    },
+    "segment": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["t", "s"],
+      "properties": {
+        "t": { "enum": ["eq", "del", "ins"] },
+        "s": { "type": "string" }
+      }
+    },
+    "op": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["op"],
+      "description": "One block of a diff. eq/insert/delete/move carry text; modify carries segments; gap carries count.",
+      "properties": {
+        "op": { "enum": ["eq", "insert", "delete", "modify", "move", "gap"] },
+        "kind": { "enum": ["paragraph", "heading", "code", "list_item", "quote", "html", "table", "break"] },
+        "text": { "type": "string" },
+        "segments": { "type": "array", "items": { "$ref": "#/$defs/segment" } },
+        "replaced": { "const": true, "description": "The block was rewritten: segments are the whole old text, then the whole new text." },
+        "from": { "type": "integer", "minimum": 0 },
+        "to": { "type": "integer", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 },
+        "removed": { "const": true, "description": "On a gap: the omitted blocks were deleted, not unchanged. Excerpts show the first two blocks of a long deletion and sum up the rest this way." }
+      }
+    },
+    "stats": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["words_added", "words_removed", "blocks_changed"],
+      "properties": {
+        "words_added": { "type": "integer", "minimum": 0 },
+        "words_removed": { "type": "integer", "minimum": 0 },
+        "blocks_changed": { "type": "integer", "minimum": 0 }
+      }
+    },
+    "metaChange": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["key", "old", "new"],
+      "properties": {
+        "key": { "type": "string" },
+        "old": {},
+        "new": {}
+      }
+    },
+    "diff": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["ops", "stats"],
+      "properties": {
+        "ops": { "type": "array", "items": { "$ref": "#/$defs/op" } },
+        "stats": { "$ref": "#/$defs/stats" },
+        "meta_changes": { "type": "array", "items": { "$ref": "#/$defs/metaChange" } }
+      }
+    },
+    "revision": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["id", "number", "base_number", "page", "note", "published_at", "updated_at", "original_date", "version_url", "history_url", "stats", "meta_changes", "excerpt"],
+      "properties": {
+        "id": { "type": "string", "description": "Permanent; the same id as the feed entry." },
+        "number": { "type": "integer", "minimum": 2 },
+        "base_number": { "type": "integer", "minimum": 1 },
+        "page": { "$ref": "#/$defs/pageRef" },
+        "kind": { "$ref": "#/$defs/kind" },
+        "maturity": { "$ref": "#/$defs/maturity" },
+        "note": { "type": "string" },
+        "published_at": { "$ref": "#/$defs/time" },
+        "updated_at": { "$ref": "#/$defs/time" },
+        "original_date": { "$ref": "#/$defs/date" },
+        "version_url": { "$ref": "#/$defs/url" },
+        "history_url": { "$ref": "#/$defs/url" },
+        "stats": { "$ref": "#/$defs/stats" },
+        "meta_changes": { "type": "array", "items": { "$ref": "#/$defs/metaChange" } },
+        "excerpt": {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["truncated", "blocks"],
+          "properties": {
+            "truncated": { "type": "boolean" },
+            "blocks": { "type": "array", "items": { "$ref": "#/$defs/op" } }
+          }
+        }
+      }
+    }
+  }
+}
@@ -0,0 +1,12 @@+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://git.pablomurad.com/pablo/patchwork/raw/branch/main/schema/v1/page.json",
+  "title": "GET /api/v1/pages/{slug}",
+  "description": "A page and its public history, newest version first.",
+  "$ref": "defs.json#/$defs/pageSummaryFields",
+  "unevaluatedProperties": false,
+  "required": ["versions"],
+  "properties": {
+    "versions": { "type": "array", "minItems": 1, "items": { "$ref": "defs.json#/$defs/versionSummary" } }
+  }
+}
@@ -0,0 +1,11 @@+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://git.pablomurad.com/pablo/patchwork/raw/branch/main/schema/v1/pages.json",
+  "title": "GET /api/v1/pages",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["pages"],
+  "properties": {
+    "pages": { "type": "array", "items": { "$ref": "defs.json#/$defs/pageSummary" } }
+  }
+}
@@ -0,0 +1,12 @@+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://git.pablomurad.com/pablo/patchwork/raw/branch/main/schema/v1/revisions.json",
+  "title": "GET /api/v1/revisions",
+  "description": "Recent published revisions across the site, newest first (at most feeds.limit).",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["revisions"],
+  "properties": {
+    "revisions": { "type": "array", "items": { "$ref": "defs.json#/$defs/revision" } }
+  }
+}
@@ -0,0 +1,26 @@+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://git.pablomurad.com/pablo/patchwork/raw/branch/main/schema/v1/version.json",
+  "title": "GET /api/v1/pages/{slug}/versions/{n}",
+  "description": "One public version: its text, and its full diff against the previous public version (null for the first).",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["page", "number", "latest_number", "published_at", "updated_at", "url", "title", "body_markdown", "frontmatter", "diff"],
+  "properties": {
+    "page": { "$ref": "defs.json#/$defs/pageRef" },
+    "number": { "type": "integer", "minimum": 1 },
+    "latest_number": { "type": "integer", "minimum": 1 },
+    "baseline": { "const": true },
+    "published_at": { "$ref": "defs.json#/$defs/time" },
+    "updated_at": { "$ref": "defs.json#/$defs/time" },
+    "note": { "type": "string" },
+    "kind": { "$ref": "defs.json#/$defs/kind" },
+    "maturity": { "$ref": "defs.json#/$defs/maturity" },
+    "url": { "$ref": "defs.json#/$defs/url" },
+    "title": { "type": "string" },
+    "body_markdown": { "type": "string", "description": "The Markdown body without frontmatter. Render it with your own Markdown pipeline." },
+    "frontmatter": { "type": "object", "description": "Only the keys listed in frontmatter.diff_keys." },
+    "base_number": { "type": "integer", "minimum": 1 },
+    "diff": { "oneOf": [{ "type": "null" }, { "$ref": "defs.json#/$defs/diff" }] }
+  }
+}