carlos / platform Public

plan: rastrillo blog example — 7 tasks, prototype-verified, review-fixed

Every code block was built and run green (55 tests) in a prototype
before transcription. Opus review materialized the plan verbatim in a
scratch repo: green on first pass, generator output byte-identical.
Fix round applied 11 findings (2 Important: MaxBytesReader 400-path
now tested on all five POST handlers; draft-pill assertion made a
count). Spec §11 amended per F9: go build/vet/test ./... all fail on
multi-action apps; scoped commands are the supported form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0ac63fe3381f0c27f2f60d080e7e6cc71cf78e40 parent 8fb39a3
2 files changed, +3350 −2
  • docs/superpowers/plans/2026-08-03-rastrillo-blog-example.md +3328 −0
  • docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md +22 −2
diff --git a/docs/superpowers/plans/2026-08-03-rastrillo-blog-example.md b/docs/superpowers/plans/2026-08-03-rastrillo-blog-example.md
new file mode 100644
index 000000000..30c05225b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-rastrillo-blog-example.md
@@ -0,0 +1,3328 @@
+# `examples/blog` Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build `examples/blog` in the rastrillo repo — a real single-user blog (public index, public post page, admin CRUD) assembled from the `ui` package's eight stock partials, with zero JavaScript and route-level tests over the real generated mux.
+
+**Architecture:** Ten actions on rastrillo's filesystem router. Shared app code lives in `internal/blog` (the generator cannot compile shared code inside `actions/`): `store.go` holds plain functions over `*sql.DB`, `view.go` holds one template tree cloned per page, a `Render` helper, and every view-model struct. Screens are built by calling `ui`'s partials by name with `dict`-built argument lists; all formatting happens in Go. Tests live in `internal/blogtest`, a directory of `_test.go` files that drives `gen.Router` through `httptest`.
+
+**Tech Stack:** Go 1.25, `html/template`, `net/http` (Go 1.22 `ServeMux` patterns), `modernc.org/sqlite`, `github.com/carlosframework/rastrillo` (via a `replace` to the parent module), stdlib `testing` + `net/http/httptest`.
+
+## Global Constraints
+
+Copied from the spec. Every task's requirements implicitly include this section.
+
+- **Branch:** `example-blog`, created from `ui-component-library` (stacked — the blog consumes the `ui` package). All work happens in the rastrillo repo.
+- **Nothing outside `examples/blog/` changes.** Every friction finding is a note in the example's README, and zero lines in the framework. Verifiable with `git diff --name-only ui-component-library...example-blog`, whose every line must start `examples/blog/`.
+- **Zero JavaScript.** No `<script>` tag anywhere in the app, no off-origin reference on any page. Every flow — searching, paging, creating, publishing, deleting — is a plain link or a plain form.
+- **No new dependencies.** `modernc.org/sqlite` is the only non-rastrillo requirement, and only because `blog.Open` blank-imports the driver. No ORM, no query builder, no Markdown library, no assertion library.
+- **`tokens.css` ships unedited.** The app's own stylesheet is `static/blog.css`, roughly fifty lines, loaded *after* `tokens.css`. Every value in it comes from a `var(--rst-*)` token — no literal hex, no literal px spacing. No selector in it contains `.rst-`.
+- **Tests are stdlib only:** `testing`, `net/http/httptest`, `strings`. No assertion library. Route tests run against `gen.Router`'s mux with a temp-file SQLite database per test (a file, not `:memory:`, because `SetMaxOpenConns(1)` plus WAL is the configuration being exercised). Seeding goes through `blog.Create`/`blog.SetPublished`, not raw SQL.
+- **Error paths, uniform across every action:**
+ - Each POST action opens with `r.Body = http.MaxBytesReader(w, r.Body, 1<<20)` before `r.ParseForm()`; a `ParseForm` error is a **400**.
+ - `sql.ErrNoRows` is the only error an action interprets, and it always means **404**.
+ - Every other store error goes through `blog.Fail(ctx, w, "…", err)` in one line, which logs via `ctx.Logger` and writes a plain **500**.
+ - A non-numeric `{id}` is a **404**, not a 400 — a URL that was never ours.
+- **Redirect-after-POST:** every mutating action answers `http.StatusSeeOther` (**303**) with a `Location`. No POST renders a page on success. The two validation failures are the only POSTs that render, and they re-render the form they came from, **400**, with the submitted values still in the fields.
+- **`Options.DBPath` and `Options.Migrations` are not set.** `Serve` opens a handle it never hands back and `openDB` is unexported, so any app whose actions read `Ctx.DB` must open its own `*sql.DB`. The example reproduces the pragma DSN by hand — `busy_timeout` before `journal_mode(WAL)`, then `SetMaxOpenConns(1)`.
+- **Run the binary from the app root.** `http.FileServer(http.Dir("static"))` is relative to the working directory, so `cd examples/blog && ./blog` or both stylesheets 404 and every screen renders unstyled.
+- **`gen/` is committed**, exactly as `examples/helloworld/gen/` is. It is produced by running the real generator; it is never hand-written and never hand-edited.
+- **The module's working commands are `go build ./cmd/blog`, `go test ./internal/...` and `go vet ./cmd/... ./gen/... ./internal/...`.** `go build ./...`, `go vet ./...` and `go test ./...` **all fail** in a rastrillo app with more than one action, and that is not a defect in this app: `actions/` is generator input, not a compilable package — two actions in one directory both declare `Handle` (`actions/admin/posts/` has three), and a `[id]` directory is a malformed Go import path, which is what `./...` trips over first. This is friction finding **F9**, new to this build; it amends the spec's §11 and §13 wording to the scoped commands above.
+
+---
+
+## File Structure
+
+Everything below is under `examples/blog/` in the rastrillo repo.
+
+| File | Responsibility |
+| --- | --- |
+| `go.mod`, `go.sum` | module `blog`; `replace github.com/carlosframework/rastrillo => ../..`, mirroring `examples/helloworld` |
+| `.gitignore` | the `blog` binary and the SQLite files a local run leaves behind |
+| `README.md` | what it is, how to run it, the friction log, the regeneration procedure |
+| `actions/index.GET.go` | `GET /` — published posts, paginated, plus the catch-all path guard |
+| `actions/posts/[id]/index.GET.go` | `GET /posts/{id}` — one published post |
+| `actions/admin/posts/index.GET.go` | `GET /admin/posts` — the List screen (search, pagination) |
+| `actions/admin/posts/index.POST.go` | `POST /admin/posts` — create |
+| `actions/admin/posts/new.GET.go` | `GET /admin/posts/new` — the create form |
+| `actions/admin/posts/[id]/edit.GET.go` | `GET /admin/posts/{id}/edit` — the edit form and status area |
+| `actions/admin/posts/[id]/index.POST.go` | `POST /admin/posts/{id}` — update |
+| `actions/admin/posts/[id]/publish.POST.go` | `POST /admin/posts/{id}/publish` |
+| `actions/admin/posts/[id]/unpublish.POST.go` | `POST /admin/posts/{id}/unpublish` |
+| `actions/admin/posts/[id]/delete.POST.go` | `POST /admin/posts/{id}/delete` |
+| `cmd/blog/main.go` | flags, the database handle, the Ctx factory, static serving, `Serve` |
+| `gen/router.go`, `gen/actions/**` | committed generator output — never hand-edited |
+| `internal/blog/store.go` | `Open`, the migration, `Post`, every query |
+| `internal/blog/view.go` | the template tree, `Render`, `Fail`, every view-model type, all formatting |
+| `internal/blog/templates/layout.html` | `{{define "layout"}}` — html/head/body/footer |
+| `internal/blog/templates/pages/*.html` | one file per screen, each `{{define "content"}}` |
+| `internal/blogtest/*_test.go` | the whole test suite — store tests, view tests, route tests, sweeps |
+| `static/tokens.css` | scaffolded from `ui/tokens.css`, unedited |
+| `static/blog.css` | the app's own ~50 lines |
+
+---
+
+### Task 1: Branch, module skeleton, and the store
+
+**Files:**
+- Create: `examples/blog/go.mod`
+- Create: `examples/blog/go.sum` (copied from `examples/helloworld/go.sum`)
+- Create: `examples/blog/.gitignore`
+- Create: `examples/blog/static/tokens.css` (copied from `ui/tokens.css`)
+- Create: `examples/blog/internal/blog/store.go`
+- Test: `examples/blog/internal/blogtest/store_test.go`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks. `github.com/carlosframework/rastrillo` is reached through a `replace` directive to `../..`.
+- Produces, all in package `blog` (import path `blog/internal/blog`):
+ - `type Post struct { ID int64; Title, Body string; Published bool; CreatedAt, UpdatedAt time.Time }`
+ - `func Open(path string) (*sql.DB, error)`
+ - `func Get(db *sql.DB, id int64) (Post, error)` — `sql.ErrNoRows` when absent
+ - `func ListPublished(db *sql.DB, offset, limit int) ([]Post, error)`
+ - `func CountPublished(db *sql.DB) (int, error)`
+ - `func List(db *sql.DB, q string, offset, limit int) ([]Post, error)`
+ - `func Count(db *sql.DB, q string) (int, error)`
+ - `func Create(db *sql.DB, title, body string) (int64, error)`
+ - `func Update(db *sql.DB, id int64, title, body string) error`
+ - `func SetPublished(db *sql.DB, id int64, v bool) error`
+ - `func Delete(db *sql.DB, id int64) error`
+ - The test package `blogtest` and its `newDB(t)`, `ids(posts)`, `titles(posts)` helpers.
+
+- [ ] **Step 1: Create the branch**
+
+```bash
+cd <rastrillo repo>
+git checkout ui-component-library
+git checkout -b example-blog
+```
+
+- [ ] **Step 2: Create the directory tree**
+
+From the repo root:
+
+```bash
+mkdir -p examples/blog && cd examples/blog
+mkdir -p actions/posts/'[id]' actions/admin/posts/'[id]' cmd/blog \
+ internal/blog/templates/pages internal/blogtest static
+```
+
+All later commands in this plan run from `examples/blog/`.
+
+- [ ] **Step 3: Write `go.mod`**
+
+`examples/helloworld` requires rastrillo by its published path and points it at the parent module with a `replace` directive; this module does the same, and additionally requires `modernc.org/sqlite` directly because `store.go` blank-imports the driver.
+
+```
+module blog
+
+go 1.25.0
+
+require (
+ github.com/carlosframework/rastrillo v0.1.0
+ modernc.org/sqlite v1.55.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
+
+replace github.com/carlosframework/rastrillo => ../..
+```
+
+- [ ] **Step 4: Copy `go.sum` and `tokens.css`, and write `.gitignore`**
+
+`helloworld`'s `go.sum` already covers exactly this dependency set, and `rastrillo new` writes `ui/tokens.css` into a new app's `static/` verbatim.
+
+```bash
+cp ../helloworld/go.sum go.sum
+cp ../../ui/tokens.css static/tokens.css
+```
+
+`go build ./cmd/blog` drops a `blog` binary in the app root, and a local run creates `blog.db` beside it. Ignore all four once, here, rather than remembering to delete them before every commit. Create `examples/blog/.gitignore`:
+
+```
+blog
+blog.db
+blog.db-shm
+blog.db-wal
+```
+
+- [ ] **Step 5: Write the failing store test**
+
+Create `internal/blogtest/store_test.go`:
+
+```go
+// Package blogtest holds the example's tests: the store's own, and
+// route-level tests over the real generated mux. Plain stdlib testing and
+// httptest — no assertion library, mirroring the rastrillo repo's own
+// style.
+//
+// It is a directory of _test.go files and nothing else — a valid Go
+// package that go test runs and go vet passes over silently. It lives
+// outside internal/blog because it imports blog/gen, which imports the
+// generated actions, which import blog/internal/blog: an internal test of
+// that package would be an import cycle. Keeping the tests here also
+// means they see the app exactly as main.go does, through gen.
+package blogtest
+
+import (
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "testing"
+
+ "blog/internal/blog"
+)
+
+// newDB opens a fresh file-backed database per test. A file, not
+// :memory:, because SetMaxOpenConns(1) plus WAL is the configuration
+// being exercised.
+func newDB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := blog.Open(filepath.Join(t.TempDir(), "blog.db"))
+ if err != nil {
+ t.Fatalf("open database: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ return db
+}
+
+func TestOpenIsIdempotent(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "blog.db")
+ first, err := blog.Open(path)
+ if err != nil {
+ t.Fatalf("first open: %v", err)
+ }
+ if _, err := blog.Create(first, "Kept", "Body."); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ first.Close()
+
+ second, err := blog.Open(path)
+ if err != nil {
+ t.Fatalf("second open: %v", err)
+ }
+ defer second.Close()
+ n, err := blog.Count(second, "")
+ if err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("count = %d, want 1: re-running the migration lost data", n)
+ }
+}
+
+func TestCreateStartsADraftWithTimestamps(t *testing.T) {
+ db := newDB(t)
+ id, err := blog.Create(db, "First post", "Body.")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ post, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if post.Title != "First post" || post.Body != "Body." {
+ t.Errorf("post = %q/%q", post.Title, post.Body)
+ }
+ if post.Published {
+ t.Errorf("a new post is published; it should start as a draft")
+ }
+ if post.CreatedAt.IsZero() || post.UpdatedAt.IsZero() {
+ t.Errorf("timestamps did not round-trip: %v / %v", post.CreatedAt, post.UpdatedAt)
+ }
+}
+
+func TestGetAMissingPostIsErrNoRows(t *testing.T) {
+ db := newDB(t)
+ if _, err := blog.Get(db, 999); !errors.Is(err, sql.ErrNoRows) {
+ t.Errorf("err = %v, want sql.ErrNoRows", err)
+ }
+}
+
+func TestListOrdersNewestFirstWithAnIDTiebreak(t *testing.T) {
+ db := newDB(t)
+ // Two posts created in the same second are ordinary in a test, which
+ // is exactly why the ordering carries an id tiebreak.
+ older, err := blog.Create(db, "Older", "Body.")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ newer, err := blog.Create(db, "Newer", "Body.")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ posts, err := blog.List(db, "", 0, blog.PageSize)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(posts) != 2 || posts[0].ID != newer || posts[1].ID != older {
+ t.Fatalf("order = %v, want [%d %d]", ids(posts), newer, older)
+ }
+}
+
+func TestListPublishedExcludesDrafts(t *testing.T) {
+ db := newDB(t)
+ live, err := blog.Create(db, "Live", "Body.")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := blog.Create(db, "Draft", "Body."); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := blog.SetPublished(db, live, true); err != nil {
+ t.Fatalf("publish: %v", err)
+ }
+
+ posts, err := blog.ListPublished(db, 0, blog.PageSize)
+ if err != nil {
+ t.Fatalf("list published: %v", err)
+ }
+ if len(posts) != 1 || posts[0].ID != live {
+ t.Fatalf("published = %v, want [%d]", ids(posts), live)
+ }
+ n, err := blog.CountPublished(db)
+ if err != nil {
+ t.Fatalf("count published: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("count published = %d, want 1", n)
+ }
+}
+
+func TestListPagesWithOffsetAndLimit(t *testing.T) {
+ db := newDB(t)
+ for i := 0; i < 3; i++ {
+ if _, err := blog.Create(db, "Post", "Body."); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ }
+ posts, err := blog.List(db, "", 2, 2)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(posts) != 1 {
+ t.Errorf("got %d posts at offset 2, want 1", len(posts))
+ }
+}
+
+func TestSearchMatchesTitlesCaseInsensitively(t *testing.T) {
+ db := newDB(t)
+ if _, err := blog.Create(db, "Going to production", "Body."); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := blog.Create(db, "Unrelated", "mentions going in the body"); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ posts, err := blog.List(db, "GOING", 0, blog.PageSize)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(posts) != 1 || posts[0].Title != "Going to production" {
+ t.Fatalf("search matched %v, want just the title match", titles(posts))
+ }
+ n, err := blog.Count(db, "GOING")
+ if err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("count = %d, want 1", n)
+ }
+}
+
+// Searching for "100%" finds a literal per cent sign, not everything:
+// the wildcards in a user's string are escaped before the LIKE.
+func TestSearchEscapesLikeWildcards(t *testing.T) {
+ db := newDB(t)
+ if _, err := blog.Create(db, "Shipping 100% of the time", "Body."); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := blog.Create(db, "Shipping sometimes", "Body."); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ posts, err := blog.List(db, "100%", 0, blog.PageSize)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(posts) != 1 {
+ t.Fatalf("search matched %v, want just the literal match", titles(posts))
+ }
+ if underscores, err := blog.Count(db, "_"); err != nil {
+ t.Fatalf("count: %v", err)
+ } else if underscores != 0 {
+ t.Errorf("_ matched %d titles as a wildcard, want 0", underscores)
+ }
+}
+
+func TestUpdateAndSetPublishedAndDelete(t *testing.T) {
+ db := newDB(t)
+ id, err := blog.Create(db, "Before", "Old body.")
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ if err := blog.Update(db, id, "After", "New body."); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ post, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if post.Title != "After" || post.Body != "New body." {
+ t.Errorf("post = %q/%q, want After/New body.", post.Title, post.Body)
+ }
+
+ if err := blog.SetPublished(db, id, true); err != nil {
+ t.Fatalf("publish: %v", err)
+ }
+ if post, err = blog.Get(db, id); err != nil {
+ t.Fatalf("get: %v", err)
+ } else if !post.Published {
+ t.Errorf("post is still a draft")
+ }
+ if err := blog.SetPublished(db, id, false); err != nil {
+ t.Fatalf("unpublish: %v", err)
+ }
+ if post, err = blog.Get(db, id); err != nil {
+ t.Fatalf("get: %v", err)
+ } else if post.Published {
+ t.Errorf("post is still published")
+ }
+
+ if err := blog.Delete(db, id); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, err := blog.Get(db, id); !errors.Is(err, sql.ErrNoRows) {
+ t.Errorf("err = %v after delete, want sql.ErrNoRows", err)
+ }
+}
+
+func ids(posts []blog.Post) []int64 {
+ out := make([]int64, len(posts))
+ for i, p := range posts {
+ out[i] = p.ID
+ }
+ return out
+}
+
+func titles(posts []blog.Post) []string {
+ out := make([]string, len(posts))
+ for i, p := range posts {
+ out[i] = p.Title
+ }
+ return out
+}
+```
+
+Note: the test refers to `blog.PageSize`, which lands in Task 2's `view.go`. Add it now, as a one-line file, so this task compiles on its own — Task 2 moves it into `view.go`'s doc'd position. Create `internal/blog/pagesize.go`:
+
+```go
+package blog
+
+// PageSize is how many posts a list screen shows. Ten is small enough
+// that eleven posts produce a real page strip in a test.
+const PageSize = 10
+```
+
+- [ ] **Step 6: Run the test to verify it fails**
+
+Run: `go test ./internal/...`
+Expected: FAIL — `undefined: blog.Open` (and every other store symbol).
+
+- [ ] **Step 7: Write `internal/blog/store.go`**
+
+```go
+// Package blog is the example app's shared code: SQLite storage, the
+// template tree, and the view models every action renders.
+//
+// It exists because actions/ cannot hold shared code. The generator
+// copies only <name>.<VERB>.go files into gen/actions/ and skips any file
+// whose base name starts with "_", so a sibling helpers.go never reaches
+// the generated tree. Ordinary packages travel fine: imports pass through
+// the generator's package-clause rewrite unmodified.
+package blog
+
+import (
+ "database/sql"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite" // the app opens its own handle; see Open
+)
+
+// migration is the app's whole schema: one additive, idempotent
+// statement, applied at startup by Open.
+const migration = `
+CREATE TABLE IF NOT EXISTS posts (
+ id INTEGER PRIMARY KEY,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ published INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);`
+
+// Post is one row of posts. Timestamps are stored as RFC3339 strings in
+// UTC and parsed on scan, so formatting happens once, in Go, and no
+// template ever formats a date.
+type Post struct {
+ ID int64
+ Title string
+ Body string
+ Published bool
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// Open opens the app's SQLite database and applies the migration.
+//
+// The DSN reproduces rastrillo's own openDB by hand — busy_timeout before
+// journal_mode=WAL, then SetMaxOpenConns(1) — because rastrillo.Serve
+// never hands its opened *sql.DB back to the app, and every action here
+// reads the database through Ctx.DB. See the README's friction log, F4.
+func Open(path string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
+ if err != nil {
+ return nil, err
+ }
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec(migration); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return db, nil
+}
+
+const selectColumns = `id, title, body, published, created_at, updated_at`
+
+// scanPost reads one row in selectColumns order, parsing both timestamps.
+func scanPost(row interface{ Scan(...any) error }) (Post, error) {
+ var (
+ p Post
+ published int
+ created string
+ updated string
+ )
+ if err := row.Scan(&p.ID, &p.Title, &p.Body, &published, &created, &updated); err != nil {
+ return Post{}, err
+ }
+ p.Published = published != 0
+ var err error
+ if p.CreatedAt, err = time.Parse(time.RFC3339, created); err != nil {
+ return Post{}, err
+ }
+ if p.UpdatedAt, err = time.Parse(time.RFC3339, updated); err != nil {
+ return Post{}, err
+ }
+ return p, nil
+}
+
+// Get returns one post by id. A missing id is sql.ErrNoRows, which every
+// action turns into a 404.
+func Get(db *sql.DB, id int64) (Post, error) {
+ return scanPost(db.QueryRow(`SELECT `+selectColumns+` FROM posts WHERE id = ?`, id))
+}
+
+// ListPublished returns published posts, newest first.
+func ListPublished(db *sql.DB, offset, limit int) ([]Post, error) {
+ rows, err := db.Query(`SELECT `+selectColumns+` FROM posts WHERE published = 1
+ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ return collect(rows)
+}
+
+// CountPublished counts published posts.
+func CountPublished(db *sql.DB) (int, error) {
+ var n int
+ err := db.QueryRow(`SELECT COUNT(*) FROM posts WHERE published = 1`).Scan(&n)
+ return n, err
+}
+
+// List returns posts of any status, newest first, filtered by a title
+// search when q is non-empty.
+func List(db *sql.DB, q string, offset, limit int) ([]Post, error) {
+ if q == "" {
+ rows, err := db.Query(`SELECT `+selectColumns+` FROM posts
+ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ return collect(rows)
+ }
+ rows, err := db.Query(`SELECT `+selectColumns+` FROM posts WHERE title LIKE ? ESCAPE '\'
+ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, likePattern(q), limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ return collect(rows)
+}
+
+// Count counts the posts List would page through.
+func Count(db *sql.DB, q string) (int, error) {
+ var n int
+ var err error
+ if q == "" {
+ err = db.QueryRow(`SELECT COUNT(*) FROM posts`).Scan(&n)
+ } else {
+ err = db.QueryRow(`SELECT COUNT(*) FROM posts WHERE title LIKE ? ESCAPE '\'`, likePattern(q)).Scan(&n)
+ }
+ return n, err
+}
+
+// Create inserts a draft and returns its id.
+func Create(db *sql.DB, title, body string) (int64, error) {
+ now := time.Now().UTC().Format(time.RFC3339)
+ res, err := db.Exec(`INSERT INTO posts (title, body, published, created_at, updated_at)
+ VALUES (?, ?, 0, ?, ?)`, title, body, now, now)
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+// Update rewrites a post's title and body and moves updated_at.
+func Update(db *sql.DB, id int64, title, body string) error {
+ now := time.Now().UTC().Format(time.RFC3339)
+ _, err := db.Exec(`UPDATE posts SET title = ?, body = ?, updated_at = ? WHERE id = ?`,
+ title, body, now, id)
+ return err
+}
+
+// SetPublished flips a post's published flag and moves updated_at.
+func SetPublished(db *sql.DB, id int64, v bool) error {
+ n := 0
+ if v {
+ n = 1
+ }
+ now := time.Now().UTC().Format(time.RFC3339)
+ _, err := db.Exec(`UPDATE posts SET published = ?, updated_at = ? WHERE id = ?`, n, now, id)
+ return err
+}
+
+// Delete removes a post.
+func Delete(db *sql.DB, id int64) error {
+ _, err := db.Exec(`DELETE FROM posts WHERE id = ?`, id)
+ return err
+}
+
+// collect drains a *sql.Rows into posts, always closing it.
+func collect(rows *sql.Rows) ([]Post, error) {
+ defer rows.Close()
+ var out []Post
+ for rows.Next() {
+ p, err := scanPost(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// likePattern wraps a user's search string for a LIKE with ESCAPE '\'.
+// The escaping is not about injection — the value is a bound parameter —
+// but about meaning: someone searching for "100%" wants a literal match,
+// not a wildcard.
+func likePattern(q string) string {
+ r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
+ return "%" + r.Replace(q) + "%"
+}
+```
+
+- [ ] **Step 8: Run the tests to verify they pass**
+
+Run: `gofmt -l . && go vet ./internal/... && go test ./internal/...`
+Expected: no gofmt output, vet silent, `ok blog/internal/blogtest`.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add examples/blog
+git commit -m "feat(blog): module skeleton and the posts store"
+```
+
+---
+
+### Task 2: The template tree, the view models, and Render
+
+**Files:**
+- Create: `examples/blog/internal/blog/view.go`
+- Delete: `examples/blog/internal/blog/pagesize.go` (its one constant moves into `view.go`)
+- Create: `examples/blog/internal/blog/templates/layout.html`
+- Create: `examples/blog/internal/blog/templates/pages/index.html`
+- Create: `examples/blog/internal/blog/templates/pages/post.html`
+- Create: `examples/blog/internal/blog/templates/pages/admin_list.html`
+- Create: `examples/blog/internal/blog/templates/pages/admin_new.html`
+- Create: `examples/blog/internal/blog/templates/pages/admin_edit.html`
+- Test: `examples/blog/internal/blogtest/assert_test.go`, `examples/blog/internal/blogtest/view_test.go`
+
+**Interfaces:**
+- Consumes: `blog.Post` from Task 1. `ui.Funcs()` and `ui.Templates()` from `github.com/carlosframework/rastrillo/ui`; `rastrillo.Ctx` (fields `DB *sql.DB`, `Logger *slog.Logger`, `Actor`).
+- Produces, all in package `blog`:
+ - `const PageSize = 10`
+ - `func Render(ctx *rastrillo.Ctx, w http.ResponseWriter, page string, status int, data any)` — page keys are `"index"`, `"post"`, `"admin_list"`, `"admin_new"`, `"admin_edit"`
+ - `func Fail(ctx *rastrillo.Ctx, w http.ResponseWriter, what string, err error)` — **exported on purpose.** The spec writes it lowercase, but every caller is an action, and an action compiles into its own generated package (`act_…`), so an unexported helper would be unreachable. Same reason `Render`, `ParseID` and `PageParam` are exported.
+ - `func ParseID(r *http.Request) (int64, bool)`, `func PageParam(r *http.Request) int`, `func Offset(page int) int`
+ - `type Head struct{ Title string }`
+ - `type Row struct { Href, Main, Sub, ActionHref, ActionLabel, ActionAria string }`
+ - `type PageItem struct { Label, Href string; Current, Disabled, Gap bool }`
+ - `type Pagination struct { Show bool; Items []PageItem }`
+ - `type HomeView struct { Head Head; Rows []Row; Pagination Pagination }`
+ - `type PostView struct { Head Head; Title, Date string; Paragraphs []string }`
+ - `type AdminListView struct { Head Head; Query string; Carry [][2]string; Rows []Row; Pagination Pagination; Empty, NoMatch bool }`
+ - `type AdminFormView struct { Head Head; Title, Sub, Action, FormTitle, Body, Error string; ID int64; Published bool; StatusTone, StatusLabel string }`
+ - `func EditForm(p Post) AdminFormView`
+ - `func FormatDate(t time.Time) string`, `func PublishedLine(t time.Time) string`, `func DraftLine(t time.Time) string`
+ - `func PublicRows(posts []Post) []Row`, `func AdminRows(posts []Post) []Row`
+ - `func Paragraphs(body string) []string`
+ - `func BuildPagination(base, q string, page, total int) Pagination`
+ - Test helpers `wantStatus`, `wantContains`, `wantNotContains`.
+
+- [ ] **Step 1: Write the shared test assertions**
+
+Create `internal/blogtest/assert_test.go`:
+
+```go
+package blogtest
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// The assertions every test in this package shares. Plain stdlib, no
+// assertion library — the same style as the rastrillo repo's own tests.
+
+func wantStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
+ t.Helper()
+ if rec.Code != want {
+ t.Fatalf("status = %d, want %d", rec.Code, want)
+ }
+}
+
+func wantContains(t *testing.T, body, want string) {
+ t.Helper()
+ if !strings.Contains(body, want) {
+ t.Errorf("missing %q in:\n%s", want, body)
+ }
+}
+
+func wantNotContains(t *testing.T, body, unwanted string) {
+ t.Helper()
+ if strings.Contains(body, unwanted) {
+ t.Errorf("unexpected %q in:\n%s", unwanted, body)
+ }
+}
+```
+
+- [ ] **Step 2: Write the failing view test**
+
+Create `internal/blogtest/view_test.go`:
+
+```go
+package blogtest
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "blog/internal/blog"
+)
+
+func renderPage(t *testing.T, page string, data any) string {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ blog.Render(nil, rec, page, 200, data)
+ if rec.Code != 200 {
+ t.Fatalf("Render(%q) = %d, want 200:\n%s", page, rec.Code, rec.Body.String())
+ }
+ return rec.Body.String()
+}
+
+// Every page file defines "content" — the same name in every file.
+// Parsed into one shared tree they would overwrite each other silently,
+// last file wins, and the app would render the wrong screen with no
+// error anywhere. This is the test that catches a lost clone.
+func TestEachPageRendersItsOwnContent(t *testing.T) {
+ index := renderPage(t, "index", blog.HomeView{Head: blog.Head{Title: "The blog"}})
+ post := renderPage(t, "post", blog.PostView{
+ Head: blog.Head{Title: "Release notes"}, Title: "Release notes",
+ Date: "Published 2 August 2026", Paragraphs: []string{"One."},
+ })
+ adminNew := renderPage(t, "admin_new", blog.AdminFormView{
+ Head: blog.Head{Title: "New post"}, Action: "/admin/posts",
+ })
+
+ wantContains(t, index, "Notes, in the order they were written.")
+ wantContains(t, post, `<article class="blog-article">`)
+ wantNotContains(t, post, "Notes, in the order they were written.")
+ wantContains(t, adminNew, `<form class="blog-form" method="post" action="/admin/posts">`)
+ wantNotContains(t, adminNew, `<article class="blog-article">`)
+}
+
+func TestLayoutWrapsEveryPage(t *testing.T) {
+ html := renderPage(t, "index", blog.HomeView{Head: blog.Head{Title: "The blog"}})
+
+ wantContains(t, html, `<html lang="en">`)
+ wantContains(t, html, `<title>The blog · The blog</title>`)
+ wantContains(t, html, `<link rel="stylesheet" href="/static/tokens.css">`)
+ wantContains(t, html, `<link rel="stylesheet" href="/static/blog.css">`)
+ wantContains(t, html, `<div class="rst-page">`)
+ wantContains(t, html, `<footer class="blog-footer">`)
+ wantNotContains(t, html, "<script")
+}
+
+// A template error is a clean 500, not half a page followed by garbage:
+// Render executes into a buffer before it writes anything.
+func TestRenderOfAnUnknownPageIs500WithNoPartialOutput(t *testing.T) {
+ rec := httptest.NewRecorder()
+ blog.Render(nil, rec, "no-such-page", 200, nil)
+
+ if rec.Code != 500 {
+ t.Errorf("status = %d, want 500", rec.Code)
+ }
+ wantNotContains(t, rec.Body.String(), "<html")
+}
+
+func TestParagraphsSplitsOnBlankLines(t *testing.T) {
+ got := blog.Paragraphs("One.\n\nTwo.\r\n\r\nThree.\n\n\n")
+ want := []string{"One.", "Two.", "Three."}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Errorf("Paragraphs = %q, want %q", got, want)
+ }
+ if len(blog.Paragraphs(" ")) != 0 {
+ t.Errorf("a blank body should produce no paragraphs")
+ }
+}
+
+func TestMetaLinesFormatDatesInGo(t *testing.T) {
+ when := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
+ if got, want := blog.FormatDate(when), "2 August 2026"; got != want {
+ t.Errorf("FormatDate = %q, want %q", got, want)
+ }
+ if got, want := blog.PublishedLine(when), "Published 2 August 2026"; got != want {
+ t.Errorf("PublishedLine = %q, want %q", got, want)
+ }
+ if got, want := blog.DraftLine(when), "Draft · edited 2 August 2026"; got != want {
+ t.Errorf("DraftLine = %q, want %q", got, want)
+ }
+}
+
+func TestAdminRowsGiveDraftsNoActionPill(t *testing.T) {
+ when := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
+ rows := blog.AdminRows([]blog.Post{
+ {ID: 1, Title: "Live", Published: true, CreatedAt: when, UpdatedAt: when},
+ {ID: 2, Title: "Draft", Published: false, CreatedAt: when, UpdatedAt: when},
+ })
+
+ if rows[0].ActionHref != "/posts/1" || rows[0].ActionAria != "View Live" {
+ t.Errorf("published row = %+v", rows[0])
+ }
+ if rows[1].ActionHref != "" {
+ t.Errorf("a draft has no public page, so it gets no action pill: %+v", rows[1])
+ }
+ if rows[1].Href != "/admin/posts/2/edit" {
+ t.Errorf("the row goes where the work is: %+v", rows[1])
+ }
+ // The lead marker stays unused: tinting an aria-hidden circle by
+ // publish state would be colour-only status.
+ for _, row := range rows {
+ if strings.Contains(row.Sub, "aria") {
+ t.Errorf("unexpected markup in a Sub line: %q", row.Sub)
+ }
+ }
+}
+
+func TestEditFormResolvesTheStatusPillPair(t *testing.T) {
+ draft := blog.EditForm(blog.Post{ID: 7, Title: "Draft"})
+ if draft.StatusTone != "neutral" || draft.StatusLabel != "Draft" {
+ t.Errorf("draft pill = %q/%q", draft.StatusTone, draft.StatusLabel)
+ }
+ if draft.Action != "/admin/posts/7" || draft.Sub != "Editing" {
+ t.Errorf("edit view = %+v", draft)
+ }
+
+ live := blog.EditForm(blog.Post{ID: 7, Title: "Live", Published: true})
+ if live.StatusTone != "positive" || live.StatusLabel != "Published" {
+ t.Errorf("published pill = %q/%q", live.StatusTone, live.StatusLabel)
+ }
+}
+
+func TestPaginationIsHiddenAtOnePageAndShownBeyondIt(t *testing.T) {
+ if got := blog.BuildPagination("/admin/posts", "", 1, 10); got.Show {
+ t.Errorf("ten posts is one page; the strip must stay away")
+ }
+ got := blog.BuildPagination("/admin/posts", "", 1, 11)
+ if !got.Show {
+ t.Fatalf("eleven posts is two pages; the strip must appear")
+ }
+ want := []blog.PageItem{
+ {Label: "Previous", Disabled: true},
+ {Label: "1", Current: true},
+ {Label: "2", Href: "/admin/posts?page=2"},
+ {Label: "Next", Href: "/admin/posts?page=2"},
+ }
+ if len(got.Items) != len(want) {
+ t.Fatalf("items = %+v, want %+v", got.Items, want)
+ }
+ for i := range want {
+ if got.Items[i] != want[i] {
+ t.Errorf("item %d = %+v, want %+v", i, got.Items[i], want[i])
+ }
+ }
+}
+
+func TestPaginationCarriesTheQueryAndDisablesNextOnTheLastPage(t *testing.T) {
+ got := blog.BuildPagination("/admin/posts", "go", 2, 11)
+ if got.Items[0] != (blog.PageItem{Label: "Previous", Href: "/admin/posts?q=go&page=1"}) {
+ t.Errorf("Previous = %+v", got.Items[0])
+ }
+ last := got.Items[len(got.Items)-1]
+ if last != (blog.PageItem{Label: "Next", Disabled: true}) {
+ t.Errorf("Next on the last page = %+v", last)
+ }
+}
+
+// A gap needs 71 posts to appear, so the app builds it correctly and
+// never renders it — the library's own fixtures cover that item kind.
+func TestPaginationWindowsWithGapsPastSevenPages(t *testing.T) {
+ got := blog.BuildPagination("/", "", 5, 100) // ten pages
+ var labels []string
+ gaps := 0
+ for _, item := range got.Items {
+ if item.Gap {
+ gaps++
+ labels = append(labels, "…")
+ continue
+ }
+ labels = append(labels, item.Label)
+ }
+ if gaps != 2 {
+ t.Errorf("items = %v, want two gaps", labels)
+ }
+ if strings.Join(labels, " ") != "Previous 1 … 4 5 6 … 10 Next" {
+ t.Errorf("items = %q", strings.Join(labels, " "))
+ }
+}
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+Run: `go test ./internal/...`
+Expected: FAIL — `undefined: blog.Render`, `undefined: blog.HomeView`, and so on.
+
+- [ ] **Step 4: Write `layout.html`**
+
+Create `internal/blog/templates/layout.html`. The two stylesheet links are the app's only asset references and both are same-origin. The footer sits after the content, so it adds no repeated block before the content and needs no skip link. There is no `<script>` tag, here or anywhere.
+
+```html
+{{define "layout"}}<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Head.Title}} · The blog</title>
+<link rel="stylesheet" href="/static/tokens.css">
+<link rel="stylesheet" href="/static/blog.css">
+</head>
+<body>
+<div class="rst-page">
+{{template "content" .}}
+<footer class="blog-footer">
+<a href="/">The blog</a>
+<a href="/admin/posts">Posts admin</a>
+</footer>
+</div>
+</body>
+</html>
+{{end}}
+```
+
+- [ ] **Step 5: Write the five page templates**
+
+Each file defines `content` — the same name in every file, which is exactly why `view.go` clones the base tree per page.
+
+`internal/blog/templates/pages/index.html`:
+
+```html
+{{define "content"}}
+{{template "page-header" dict "Title" "The blog" "Sub" "Notes, in the order they were written."}}
+{{if .Rows}}
+<div class="rst-list">
+{{range .Rows}}{{template "list-row-action" dict "Href" .Href "Main" .Main "Sub" .Sub}}
+{{end}}
+</div>
+{{if .Pagination.Show}}{{template "pagination" dict "Items" .Pagination.Items}}{{end}}
+{{else}}
+{{template "empty-state" dict "Body" "Nothing published yet. Check back soon."}}
+{{end}}
+{{end}}
+```
+
+`internal/blog/templates/pages/post.html`:
+
+```html
+{{define "content"}}
+{{template "page-header" dict "Title" .Title "Sub" .Date}}
+<article class="blog-article">
+{{range .Paragraphs}}<p>{{.}}</p>
+{{end}}</article>
+{{end}}
+```
+
+`internal/blog/templates/pages/admin_list.html`:
+
+```html
+{{define "content"}}
+{{template "page-header" dict "Title" "Posts" "ActionHref" "/admin/posts/new" "ActionLabel" "New post" "ActionIcon" "plus"}}
+{{if .Empty}}
+{{template "empty-state" dict "Title" "No posts yet" "Body" "Every blog starts empty. Write the first one and it will show up here." "ActionHref" "/admin/posts/new" "ActionLabel" "Write your first post"}}
+{{else}}
+<div class="rst-list">
+{{template "list-bar" dict "SearchAction" "/admin/posts" "Query" .Query "Placeholder" "Search posts" "Hidden" .Carry}}
+{{if .NoMatch}}<p class="blog-note">No posts match “{{.Query}}”.</p>
+{{else}}{{range .Rows}}{{template "list-row-action" dict "Href" .Href "Main" .Main "Sub" .Sub "ActionHref" .ActionHref "ActionLabel" .ActionLabel "ActionAria" .ActionAria}}
+{{end}}{{end}}
+</div>
+{{if .Pagination.Show}}{{template "pagination" dict "Items" .Pagination.Items}}{{end}}
+{{end}}
+{{end}}
+```
+
+`internal/blog/templates/pages/admin_new.html`:
+
+```html
+{{define "content"}}
+{{template "page-header" dict "Title" "New post" "Sub" "It starts as a draft — nothing is public until you publish it."}}
+{{if .Error}}<p class="blog-error" role="alert">{{.Error}}</p>{{end}}
+<form class="blog-form" method="post" action="{{.Action}}">
+<label for="title">Title</label>
+<input type="text" id="title" name="title" value="{{.FormTitle}}" required>
+<label for="body">Body</label>
+<textarea id="body" name="body" rows="18">{{.Body}}</textarea>
+<div class="blog-form__actions">
+<button class="rst-btn rst-btn--primary" type="submit">Create post</button>
+<a class="rst-btn" href="/admin/posts">Cancel</a>
+</div>
+</form>
+{{end}}
+```
+
+`internal/blog/templates/pages/admin_edit.html`. The publish/unpublish and delete forms are **siblings of** the edit form, never inside it: nested forms are invalid HTML and the browser drops the inner one. That constraint is why the publish toggle is its own route.
+
+```html
+{{define "content"}}
+{{template "page-header" dict "Title" .Title "Sub" .Sub}}
+<div class="blog-status">
+{{template "status-pill" dict "Tone" .StatusTone "Label" .StatusLabel}}
+{{if .Published}}
+<form method="post" action="/admin/posts/{{.ID}}/unpublish"><button class="rst-btn" type="submit">Unpublish</button></form>
+<a class="rst-btn" href="/posts/{{.ID}}">View</a>
+{{else}}
+<form method="post" action="/admin/posts/{{.ID}}/publish"><button class="rst-btn" type="submit">Publish</button></form>
+{{end}}
+<form method="post" action="/admin/posts/{{.ID}}/delete"><button class="rst-btn" type="submit">Delete</button></form>
+</div>
+{{if .Error}}<p class="blog-error" role="alert">{{.Error}}</p>{{end}}
+<form class="blog-form" method="post" action="{{.Action}}">
+<label for="title">Title</label>
+<input type="text" id="title" name="title" value="{{.FormTitle}}" required>
+<label for="body">Body</label>
+<textarea id="body" name="body" rows="18">{{.Body}}</textarea>
+<div class="blog-form__actions">
+<button class="rst-btn rst-btn--primary" type="submit">Save</button>
+<a class="rst-btn" href="/admin/posts">Back to posts</a>
+</div>
+</form>
+{{end}}
+```
+
+- [ ] **Step 6: Write `internal/blog/view.go` and delete `pagesize.go`**
+
+```bash
+rm internal/blog/pagesize.go
+```
+
+Create `internal/blog/view.go`:
+
+```go
+package blog
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/rastrillo"
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+//go:embed templates/layout.html templates/pages/*.html
+var templateFS embed.FS
+
+// PageSize is how many posts a list screen shows. Ten is small enough
+// that eleven posts produce a real page strip in a test.
+const PageSize = 10
+
+// pages is one template tree per screen, built once at process start.
+var pages = buildPages()
+
+// buildPages parses ui's partials and the layout into a base tree, then
+// clones that base once per page file.
+//
+// The clone is what makes the layout work: every page file defines
+// "content", the same name in every file. Parsed into one shared tree
+// they would overwrite each other silently — last file wins, wrong screen
+// rendered, no error anywhere. A clone per page gives each screen its own
+// "content", so layout.html can call {{template "content" .}} with the
+// constant name html/template requires.
+func buildPages() map[string]*template.Template {
+ base := template.Must(template.New("").Funcs(ui.Funcs()).ParseFS(ui.Templates(), "*.html"))
+ base = template.Must(base.ParseFS(templateFS, "templates/layout.html"))
+
+ names, err := fs.Glob(templateFS, "templates/pages/*.html")
+ if err != nil {
+ panic(err) // the pattern is a constant and the FS is embedded
+ }
+ if len(names) == 0 {
+ panic("blog: no page templates embedded")
+ }
+ out := make(map[string]*template.Template, len(names))
+ for _, n := range names {
+ t := template.Must(base.Clone())
+ t = template.Must(t.ParseFS(templateFS, n))
+ out[strings.TrimSuffix(filepath.Base(n), ".html")] = t
+ }
+ return out
+}
+
+// Render executes one page's "layout" into a buffer and only then writes
+// the status and the bytes, so a template error is a clean 500 rather
+// than half a page followed by a stack trace.
+func Render(ctx *rastrillo.Ctx, w http.ResponseWriter, page string, status int, data any) {
+ t, ok := pages[page]
+ if !ok {
+ Fail(ctx, w, "rendering "+page, fmt.Errorf("no such page template"))
+ return
+ }
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
+ Fail(ctx, w, "rendering "+page, err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ w.Write(buf.Bytes())
+}
+
+// Fail logs through Ctx.Logger and answers a plain 500. Every store error
+// that is not sql.ErrNoRows goes through it in one line.
+func Fail(ctx *rastrillo.Ctx, w http.ResponseWriter, what string, err error) {
+ if ctx != nil && ctx.Logger != nil {
+ ctx.Logger.Error("blog: "+what, "err", err)
+ }
+ http.Error(w, "Something went wrong.", http.StatusInternalServerError)
+}
+
+// ParseID reads the {id} path value. A non-numeric id is a URL that was
+// never ours, so the caller answers 404 rather than 400.
+func ParseID(r *http.Request) (int64, bool) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil || id < 1 {
+ return 0, false
+ }
+ return id, true
+}
+
+// PageParam reads ?page=, defaulting to 1 for anything missing or silly.
+func PageParam(r *http.Request) int {
+ n, err := strconv.Atoi(r.URL.Query().Get("page"))
+ if err != nil || n < 1 {
+ return 1
+ }
+ return n
+}
+
+// Offset is the LIMIT/OFFSET offset for a 1-based page number.
+func Offset(page int) int { return (page - 1) * PageSize }
+
+// ── View models ────────────────────────────────────────────────────────
+//
+// Every screen's data is a Go struct, never a dict-built map: a mistyped
+// field on a struct fails loudly at Execute, while a missing map key
+// renders empty and returns no error. dict stays in the templates, where
+// it belongs — for the literal argument lists that call a partial.
+
+// Head carries what the layout's <head> needs.
+type Head struct{ Title string }
+
+// Row is one list-row-action's data, formatted in Go.
+type Row struct {
+ Href string
+ Main string
+ Sub string
+ ActionHref string
+ ActionLabel string
+ ActionAria string
+}
+
+// PageItem is one control in the pagination strip. Exactly one of Href,
+// Current, Disabled and Gap is meaningful per item.
+type PageItem struct {
+ Label string
+ Href string
+ Current bool
+ Disabled bool
+ Gap bool
+}
+
+// Pagination is the strip plus the app's own decision to show it at all:
+// the partial happily renders an empty <nav>, which would leave a
+// landmark on every single-page list.
+type Pagination struct {
+ Show bool
+ Items []PageItem
+}
+
+// HomeView is GET /.
+type HomeView struct {
+ Head Head
+ Rows []Row
+ Pagination Pagination
+}
+
+// PostView is GET /posts/{id}.
+type PostView struct {
+ Head Head
+ Title string
+ Date string
+ Paragraphs []string
+}
+
+// AdminListView is GET /admin/posts.
+type AdminListView struct {
+ Head Head
+ Query string
+ // Carry is list-bar's Hidden: name/value pairs a search must
+ // preserve. Empty here — this screen has no sort order or page size
+ // to keep, and a new search deliberately returns to page 1.
+ Carry [][2]string
+ Rows []Row
+ Pagination Pagination
+ Empty bool // no posts at all: the real blank state
+ NoMatch bool // a search that matched nothing: a plain note, not a card
+}
+
+// AdminFormView is GET /admin/posts/new and GET /admin/posts/{id}/edit,
+// and the 400 re-render of either POST.
+type AdminFormView struct {
+ Head Head
+ Title string // page-header title (the post's own, when editing)
+ Sub string // page-header subhead
+ Action string // the form's POST target
+ FormTitle string // the title field's current value
+ Body string // the textarea's current value
+ Error string // a validation message, rendered role="alert"
+ ID int64
+ Published bool
+ StatusTone string // status-pill Tone: neutral or positive
+ StatusLabel string // status-pill Label: Draft or Published
+}
+
+// EditForm builds the edit screen's view model from a post. Resolving a
+// raw published flag to a Tone/Label pair is product logic and stays
+// here, in the app: status-pill takes the resolved pair by design.
+func EditForm(p Post) AdminFormView {
+ tone, label := "neutral", "Draft"
+ if p.Published {
+ tone, label = "positive", "Published"
+ }
+ return AdminFormView{
+ Head: Head{Title: p.Title},
+ Title: p.Title,
+ Sub: "Editing",
+ Action: fmt.Sprintf("/admin/posts/%d", p.ID),
+ FormTitle: p.Title,
+ Body: p.Body,
+ ID: p.ID,
+ Published: p.Published,
+ StatusTone: tone,
+ StatusLabel: label,
+ }
+}
+
+// ── Formatting, all of it in Go ────────────────────────────────────────
+//
+// The app registers no template funcs of its own: ui.Funcs() is the whole
+// FuncMap. Every date, every meta line, every page item is computed here,
+// where a test reaches it directly.
+
+// FormatDate renders a timestamp the way the screens read it.
+func FormatDate(t time.Time) string { return t.Format("2 January 2006") }
+
+// PublishedLine is a published post's meta line.
+func PublishedLine(t time.Time) string { return "Published " + FormatDate(t) }
+
+// DraftLine is a draft's meta line. Status rides in the row's Sub as
+// prose because the stock row has no status slot — friction finding F1.
+func DraftLine(t time.Time) string { return "Draft · edited " + FormatDate(t) }
+
+// PublicRows builds the public index's rows. No action pill: the row
+// already goes to the only place it could go, and a second link to the
+// same URL is noise with a tab stop.
+func PublicRows(posts []Post) []Row {
+ rows := make([]Row, 0, len(posts))
+ for _, p := range posts {
+ rows = append(rows, Row{
+ Href: fmt.Sprintf("/posts/%d", p.ID),
+ Main: p.Title,
+ Sub: FormatDate(p.CreatedAt),
+ })
+ }
+ return rows
+}
+
+// AdminRows builds the admin list's rows. A draft gets no action pill:
+// it has no public page to send anyone to, and list-row-action renders
+// nothing when ActionHref is empty.
+func AdminRows(posts []Post) []Row {
+ rows := make([]Row, 0, len(posts))
+ for _, p := range posts {
+ row := Row{
+ Href: fmt.Sprintf("/admin/posts/%d/edit", p.ID),
+ Main: p.Title,
+ Sub: DraftLine(p.UpdatedAt),
+ }
+ if p.Published {
+ row.Sub = PublishedLine(p.CreatedAt)
+ row.ActionHref = fmt.Sprintf("/posts/%d", p.ID)
+ row.ActionLabel = "View"
+ row.ActionAria = "View " + p.Title
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
+// Paragraphs splits a plain-text body on blank lines. Bodies are
+// author-supplied plain text and stay that way: each paragraph is
+// rendered as an ordinary {{.}} value, escaped by html/template, never
+// template.HTML.
+func Paragraphs(body string) []string {
+ body = strings.ReplaceAll(body, "\r\n", "\n")
+ var out []string
+ for _, chunk := range strings.Split(body, "\n\n") {
+ if s := strings.TrimSpace(chunk); s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+// BuildPagination builds the page strip for a list of total items at a
+// 1-based page number. Show is false at or below one page, and the page
+// template guards the partial with it.
+func BuildPagination(base, q string, page, total int) Pagination {
+ p := Pagination{Show: total > PageSize}
+ if !p.Show {
+ return p
+ }
+ pages := (total + PageSize - 1) / PageSize
+ if page < 1 {
+ page = 1
+ }
+ if page > pages {
+ page = pages
+ }
+
+ // Built by hand rather than with url.Values.Encode, which sorts its
+ // keys and would emit page before q.
+ href := func(n int) string {
+ if q != "" {
+ return base + "?q=" + url.QueryEscape(q) + "&page=" + strconv.Itoa(n)
+ }
+ return base + "?page=" + strconv.Itoa(n)
+ }
+
+ items := []PageItem{{Label: "Previous", Disabled: true}}
+ if page > 1 {
+ items[0] = PageItem{Label: "Previous", Href: href(page - 1)}
+ }
+ for _, n := range pageNumbers(page, pages) {
+ switch {
+ case n == 0:
+ items = append(items, PageItem{Gap: true})
+ case n == page:
+ items = append(items, PageItem{Label: strconv.Itoa(n), Current: true})
+ default:
+ items = append(items, PageItem{Label: strconv.Itoa(n), Href: href(n)})
+ }
+ }
+ next := PageItem{Label: "Next", Disabled: true}
+ if page < pages {
+ next = PageItem{Label: "Next", Href: href(page + 1)}
+ }
+ p.Items = append(items, next)
+ return p
+}
+
+// pageNumbers returns the page numbers to render, with 0 standing for a
+// gap. Up to seven pages every number renders; beyond that it is
+// first · gap · current±1 · gap · last. A gap needs 71 posts to appear,
+// so this app builds it correctly and never renders it — the library's
+// own fixtures cover that item kind.
+func pageNumbers(page, pages int) []int {
+ if pages <= 7 {
+ out := make([]int, 0, pages)
+ for n := 1; n <= pages; n++ {
+ out = append(out, n)
+ }
+ return out
+ }
+ want := map[int]bool{1: true, pages: true, page: true}
+ if page-1 >= 1 {
+ want[page-1] = true
+ }
+ if page+1 <= pages {
+ want[page+1] = true
+ }
+ var out []int
+ prev := 0
+ for n := 1; n <= pages; n++ {
+ if !want[n] {
+ continue
+ }
+ if prev != 0 && n != prev+1 {
+ out = append(out, 0)
+ }
+ out = append(out, n)
+ prev = n
+ }
+ return out
+}
+```
+
+- [ ] **Step 7: Run the tests to verify they pass**
+
+Run: `gofmt -l . && go vet ./internal/... && go test ./internal/...`
+Expected: no gofmt output, vet silent, `ok blog/internal/blogtest`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add examples/blog
+git commit -m "feat(blog): the template tree, view models, and Render"
+```
+
+---
+
+### Task 3: The public side — two actions, `gen/`, `main.go`, route tests
+
+**Files:**
+- Create: `examples/blog/actions/index.GET.go`
+- Create: `examples/blog/actions/posts/[id]/index.GET.go`
+- Create: `examples/blog/cmd/blog/main.go`
+- Create (generated, committed): `examples/blog/gen/router.go`, `examples/blog/gen/actions/index_get/index.GET.go`, `examples/blog/gen/actions/posts/id/index_get/index.GET.go`
+- Test: `examples/blog/internal/blogtest/harness_test.go`, `examples/blog/internal/blogtest/public_test.go`
+
+**Interfaces:**
+- Consumes: everything Task 1 and Task 2 produced — `blog.CountPublished`, `blog.ListPublished`, `blog.Get`, `blog.PageParam`, `blog.Offset`, `blog.PageSize`, `blog.ParseID`, `blog.Render`, `blog.Fail`, `blog.PublicRows`, `blog.BuildPagination`, `blog.PublishedLine`, `blog.Paragraphs`, `blog.HomeView`, `blog.PostView`, `blog.Head`.
+- Produces:
+ - `gen.Router(ctxFactory func(*http.Request) *rastrillo.Ctx) *http.ServeMux` (generated).
+ - Test helpers `newApp(t) (http.Handler, *sql.DB)`, `get(t, h, target)`, `post(t, h, target, form)`, `seed(t, db, title, body, published) int64`.
+
+- [ ] **Step 1: Write the failing public route tests**
+
+Create `internal/blogtest/harness_test.go`:
+
+```go
+package blogtest
+
+import (
+ "database/sql"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/gen"
+ "blog/internal/blog"
+)
+
+// newApp builds a whole app per test: a fresh file-backed SQLite database
+// (a file, not :memory:, because SetMaxOpenConns(1) plus WAL is the
+// configuration being exercised) and the real generated router, wired
+// exactly as cmd/blog/main.go wires it.
+func newApp(t *testing.T) (http.Handler, *sql.DB) {
+ t.Helper()
+ db, err := blog.Open(filepath.Join(t.TempDir(), "blog.db"))
+ if err != nil {
+ t.Fatalf("open database: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ mux := gen.Router(func(*http.Request) *rastrillo.Ctx {
+ return &rastrillo.Ctx{DB: db, Logger: logger, Actor: rastrillo.Actor{Human: true}}
+ })
+ return mux, db
+}
+
+func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
+ return rec
+}
+
+func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// seed creates a post through the store, so the tests exercise it too.
+func seed(t *testing.T, db *sql.DB, title, body string, published bool) int64 {
+ t.Helper()
+ id, err := blog.Create(db, title, body)
+ if err != nil {
+ t.Fatalf("seed %q: %v", title, err)
+ }
+ if published {
+ if err := blog.SetPublished(db, id, true); err != nil {
+ t.Fatalf("publish %q: %v", title, err)
+ }
+ }
+ return id
+}
+```
+
+Create `internal/blogtest/public_test.go`:
+
+```go
+package blogtest
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestIndexListsPublishedPostsNewestFirst(t *testing.T) {
+ app, db := newApp(t)
+ seed(t, db, "Older published", "One.", true)
+ seed(t, db, "Newer published", "Two.", true)
+ seed(t, db, "A draft", "Three.", false)
+
+ rec := get(t, app, "/")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, "Newer published")
+ wantContains(t, body, "Older published")
+ wantNotContains(t, body, "A draft")
+ if strings.Index(body, "Newer published") > strings.Index(body, "Older published") {
+ t.Errorf("newest post is not first:\n%s", body)
+ }
+}
+
+func TestIndexEmptyState(t *testing.T) {
+ app, _ := newApp(t)
+
+ rec := get(t, app, "/")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, `<div class="rst-empty">`)
+ wantContains(t, body, "Nothing published yet. Check back soon.")
+ // A reader cannot act on an empty blog, so no call to action.
+ wantNotContains(t, body, "rst-empty__cta")
+}
+
+func TestIndexSecondPageShowsTheEleventhPost(t *testing.T) {
+ app, db := newApp(t)
+ for i := 1; i <= 11; i++ {
+ seed(t, db, fmt.Sprintf("Post %02d", i), "Body.", true)
+ }
+
+ first := get(t, app, "/")
+ wantStatus(t, first, http.StatusOK)
+ wantNotContains(t, first.Body.String(), "Post 01")
+ wantContains(t, first.Body.String(), `<nav class="rst-pagination"`)
+
+ second := get(t, app, "/?page=2")
+ wantStatus(t, second, http.StatusOK)
+ wantContains(t, second.Body.String(), "Post 01")
+ wantNotContains(t, second.Body.String(), "Post 11")
+}
+
+// GET / is a prefix pattern, so the index action answers every unmatched
+// GET and has to guard the path itself (friction finding F6). The
+// trailing-slash cases land there too: none of this app's routes is
+// registered with a trailing slash, so Go's mux never redirects to one.
+func TestUnmatchedGETsAre404NotTheHomepage(t *testing.T) {
+ app, db := newApp(t)
+ seed(t, db, "Published", "Body.", true)
+
+ for _, target := range []string{"/nonsense", "/admin/posts/", "/posts/1/"} {
+ rec := get(t, app, target)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404\n%s", target, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+// GET / matches the path of POST /nonsense but not its method, so the
+// stdlib mux answers 405 before any action runs. Worth knowing before
+// someone reads a 405 as a routing bug.
+func TestPostToAnUnmatchedPathIs405(t *testing.T) {
+ app, _ := newApp(t)
+
+ rec := post(t, app, "/nonsense", nil)
+ wantStatus(t, rec, http.StatusMethodNotAllowed)
+ if got, want := rec.Header().Get("Allow"), "GET, HEAD"; got != want {
+ t.Errorf("Allow = %q, want %q", got, want)
+ }
+}
+
+func TestPostPageRendersParagraphs(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Release notes", "First paragraph.\n\nSecond paragraph.", true)
+
+ rec := get(t, app, fmt.Sprintf("/posts/%d", id))
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, "<p>First paragraph.</p>")
+ wantContains(t, body, "<p>Second paragraph.</p>")
+ wantContains(t, body, `<article class="blog-article">`)
+}
+
+func TestPostPageEscapesBodyText(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Escaping", "<script>alert(1)</script>", true)
+
+ rec := get(t, app, fmt.Sprintf("/posts/%d", id))
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantNotContains(t, body, "<script>")
+ wantContains(t, body, "&lt;script&gt;")
+}
+
+func TestPostPage404s(t *testing.T) {
+ app, db := newApp(t)
+ draft := seed(t, db, "A draft", "Body.", false)
+
+ cases := map[string]string{
+ "a draft has no public page": fmt.Sprintf("/posts/%d", draft),
+ "a missing id": "/posts/9999",
+ "a non-numeric id": "/posts/abc",
+ }
+ for name, target := range cases {
+ rec := get(t, app, target)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s: GET %s = %d, want 404", name, target, rec.Code)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/...`
+Expected: FAIL — `internal/blogtest/harness_test.go:16:2: package blog/gen is not in std (<GOROOT>/src/blog/gen)`, then `FAIL blog/internal/blogtest [setup failed]`. The generator has not run yet, so `gen/` does not exist; because `blog/gen` is inside this module, the Go tool reports it as "not in std" rather than as a missing dependency.
+
+- [ ] **Step 3: Write the two public actions**
+
+`actions/index.GET.go`:
+
+```go
+package actions
+
+import (
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is GET /.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ // Go's mux treats "/" as a prefix pattern, so this action receives
+ // every unmatched GET. Without this guard the homepage answers
+ // /nonsense with a 200. Every rastrillo app with an index action
+ // needs it — friction finding F6.
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+
+ page := blog.PageParam(r)
+ total, err := blog.CountPublished(ctx.DB)
+ if err != nil {
+ blog.Fail(ctx, w, "counting published posts", err)
+ return
+ }
+ posts, err := blog.ListPublished(ctx.DB, blog.Offset(page), blog.PageSize)
+ if err != nil {
+ blog.Fail(ctx, w, "loading published posts", err)
+ return
+ }
+
+ blog.Render(ctx, w, "index", http.StatusOK, blog.HomeView{
+ Head: blog.Head{Title: "The blog"},
+ Rows: blog.PublicRows(posts),
+ Pagination: blog.BuildPagination("/", "", page, total),
+ })
+}
+```
+
+`actions/posts/[id]/index.GET.go`:
+
+```go
+package actions
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is GET /posts/{id}.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ id, ok := blog.ParseID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ post, err := blog.Get(ctx.DB, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ blog.Fail(ctx, w, "loading post", err)
+ return
+ }
+ // A draft has no public page. Not 403: the reader is not being told
+ // a post exists.
+ if !post.Published {
+ http.NotFound(w, r)
+ return
+ }
+
+ blog.Render(ctx, w, "post", http.StatusOK, blog.PostView{
+ Head: blog.Head{Title: post.Title},
+ Title: post.Title,
+ Date: blog.PublishedLine(post.CreatedAt),
+ Paragraphs: blog.Paragraphs(post.Body),
+ })
+}
+```
+
+- [ ] **Step 4: Run the real generator and commit its output**
+
+Never hand-write `gen/`. Run the CLI from the app root:
+
+```bash
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate .
+```
+
+Expected output:
+
+```
+rastrillo generate: 2 route(s) wired
+ GET / actions/index.GET.go
+ GET /posts/{id} actions/posts/[id]/index.GET.go
+```
+
+Expected files, all committed as `examples/helloworld/gen/` is:
+
+```
+gen/router.go
+gen/actions/index_get/index.GET.go
+gen/actions/posts/id/index_get/index.GET.go
+```
+
+- [ ] **Step 5: Write `cmd/blog/main.go`**
+
+```go
+// Command blog runs the example blog.
+//
+// Run it from the app root — static assets are served from ./static, so
+// starting the binary from anywhere else 404s both stylesheets and every
+// screen renders unstyled:
+//
+// cd examples/blog && go build ./cmd/blog && ./blog -addr :8080
+package main
+
+import (
+ "flag"
+ "log/slog"
+ "net/http"
+ "os"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/gen"
+ "blog/internal/blog"
+)
+
+func main() {
+ // -socket/-addr mirror the platform's activation contract (a
+ // systemd-activated listener wins over either — see rastrillo.Serve).
+ socket := flag.String("socket", "", "unix socket to listen on")
+ addr := flag.String("addr", "", "TCP host:port to listen on")
+ dbPath := flag.String("db", "blog.db", "SQLite database file")
+ flag.Parse()
+
+ logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
+ // The app opens its own handle: rastrillo.Serve's DBPath opens a
+ // database it never hands back, and every action here reads Ctx.DB.
+ // See the README's friction log, F4.
+ db, err := blog.Open(*dbPath)
+ if err != nil {
+ logger.Error("open database", "err", err)
+ os.Exit(1)
+ }
+ defer db.Close()
+
+ // A fresh Ctx per request. Actor.Human is true and Actor.Name empty:
+ // honest for an app with no auth, and the one line a real deployment
+ // would replace with a session lookup.
+ mux := gen.Router(func(*http.Request) *rastrillo.Ctx {
+ return &rastrillo.Ctx{DB: db, Logger: logger, Actor: rastrillo.Actor{Human: true}}
+ })
+
+ // The app serves its own static files — rastrillo.Serve never does.
+ // "GET /static/" is a longer pattern than "GET /", so the stdlib mux
+ // prefers it and no ordering care is needed.
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
+
+ if err := rastrillo.Serve(rastrillo.Options{
+ Mux: mux,
+ Socket: *socket,
+ Addr: *addr,
+ Logger: logger,
+ }); err != nil {
+ logger.Error("serve failed", "err", err)
+ os.Exit(1)
+ }
+}
+```
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `gofmt -l . && go build ./cmd/blog && go vet ./cmd/... ./gen/... ./internal/... && go test ./internal/...`
+Expected: no gofmt output, a `blog` binary, vet silent, `ok blog/internal/blogtest`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add examples/blog
+git commit -m "feat(blog): the public index and post page, with gen/ committed"
+```
+
+---
+
+### Task 4: The admin List screen
+
+**Files:**
+- Create: `examples/blog/actions/admin/posts/index.GET.go`
+- Modify (regenerate): `examples/blog/gen/router.go`, plus a new `examples/blog/gen/actions/admin/posts/index_get/index.GET.go`
+- Test: `examples/blog/internal/blogtest/admin_list_test.go`
+
+**Interfaces:**
+- Consumes: `blog.Count`, `blog.List`, `blog.PageParam`, `blog.Offset`, `blog.PageSize`, `blog.AdminRows`, `blog.BuildPagination`, `blog.Render`, `blog.Fail`, `blog.AdminListView`, `blog.Head`; the `newApp`/`get`/`seed` helpers from Task 3.
+- Produces: `GET /admin/posts` on the generated mux.
+
+- [ ] **Step 1: Write the failing admin-list tests**
+
+Create `internal/blogtest/admin_list_test.go`:
+
+```go
+package blogtest
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestAdminListShowsDraftsAndPublished(t *testing.T) {
+ app, db := newApp(t)
+ seed(t, db, "A draft", "Body.", false)
+ seed(t, db, "Live post", "Body.", true)
+
+ rec := get(t, app, "/admin/posts")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, "A draft")
+ wantContains(t, body, "Live post")
+ // Status rides in the row's Sub as prose: the stock row has no status
+ // slot (friction finding F1).
+ wantContains(t, body, "Draft · edited")
+ wantContains(t, body, "Published ")
+ // The published row gets a pill; the draft, having no public page, does not.
+ if n := strings.Count(body, `class="rst-row__action"`); n != 1 {
+ t.Errorf("%d action pills, want exactly 1 (the published row)", n)
+ }
+}
+
+func TestAdminSearchFiltersByTitleCaseInsensitively(t *testing.T) {
+ app, db := newApp(t)
+ seed(t, db, "Going to production", "Body.", true)
+ seed(t, db, "Unrelated", "Body.", true)
+
+ rec := get(t, app, "/admin/posts?q=GOING")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, "Going to production")
+ wantNotContains(t, body, "Unrelated")
+ // The search field echoes the query back, so a search survives paging.
+ wantContains(t, body, `value="GOING"`)
+}
+
+// A search that matches nothing is not an empty state: a dashed
+// blank-state card telling a writer with forty posts that their blog is
+// empty is a lie the component would happily tell.
+func TestAdminSearchWithNoMatchRendersANoteNotTheEmptyState(t *testing.T) {
+ app, db := newApp(t)
+ seed(t, db, "Going to production", "Body.", true)
+
+ rec := get(t, app, "/admin/posts?q=zzz")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, `<p class="blog-note">`)
+ wantContains(t, body, "No posts match")
+ wantNotContains(t, body, `<div class="rst-empty">`)
+}
+
+func TestAdminWithNoPostsRendersTheEmptyStateNotTheNote(t *testing.T) {
+ app, _ := newApp(t)
+
+ rec := get(t, app, "/admin/posts")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, `<div class="rst-empty">`)
+ wantContains(t, body, "No posts yet")
+ wantContains(t, body, "Write your first post")
+ wantNotContains(t, body, `<p class="blog-note">`)
+}
+
+func TestAdminPaginationAppearsOnlyPastOnePage(t *testing.T) {
+ app, db := newApp(t)
+ for i := 1; i <= 10; i++ {
+ seed(t, db, fmt.Sprintf("Post %02d", i), "Body.", false)
+ }
+
+ ten := get(t, app, "/admin/posts")
+ wantStatus(t, ten, http.StatusOK)
+ // The partial emits its <nav> even with no items, so an unguarded call
+ // would leave an empty landmark on every single-page list.
+ wantNotContains(t, ten.Body.String(), `<nav class="rst-pagination"`)
+
+ seed(t, db, "Post 11", "Body.", false)
+
+ eleven := get(t, app, "/admin/posts")
+ wantStatus(t, eleven, http.StatusOK)
+ body := eleven.Body.String()
+ wantContains(t, body, `<nav class="rst-pagination"`)
+ wantContains(t, body, `<span aria-current="page">1</span>`)
+ wantContains(t, body, `<a href="/admin/posts?page=2">2</a>`)
+ // Previous is present but not actionable on page 1.
+ wantContains(t, body, `<span>Previous</span>`)
+}
+
+// html/template escapes & inside an attribute value, so the preserved
+// query looks like q=go&amp;page=2 in the source. The rendered page is
+// correct either way — a browser unescapes it — but a test asserting the
+// raw ampersand fails against working output.
+func TestAdminPaginationCarriesTheQueryIntoEveryPageLink(t *testing.T) {
+ app, db := newApp(t)
+ for i := 1; i <= 11; i++ {
+ seed(t, db, fmt.Sprintf("go %02d", i), "Body.", false)
+ }
+
+ rec := get(t, app, "/admin/posts?q=go")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, `href="/admin/posts?q=go&amp;page=2"`)
+ wantNotContains(t, body, `href="/admin/posts?page=2"`)
+}
+
+func TestAdminSecondPageShowsTheEleventhPost(t *testing.T) {
+ app, db := newApp(t)
+ for i := 1; i <= 11; i++ {
+ seed(t, db, fmt.Sprintf("Post %02d", i), "Body.", false)
+ }
+
+ rec := get(t, app, "/admin/posts?page=2")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, "Post 01")
+ wantNotContains(t, body, "Post 11")
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/...`
+Expected: FAIL — `GET /admin/posts` is not registered, so the index action's path guard answers 404 and `wantStatus` reports `status = 404, want 200`.
+
+- [ ] **Step 3: Write the admin list action**
+
+Create `actions/admin/posts/index.GET.go`:
+
+```go
+package actions
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is GET /admin/posts.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+ page := blog.PageParam(r)
+
+ all, err := blog.Count(ctx.DB, "")
+ if err != nil {
+ blog.Fail(ctx, w, "counting posts", err)
+ return
+ }
+ total, err := blog.Count(ctx.DB, q)
+ if err != nil {
+ blog.Fail(ctx, w, "counting matching posts", err)
+ return
+ }
+ posts, err := blog.List(ctx.DB, q, blog.Offset(page), blog.PageSize)
+ if err != nil {
+ blog.Fail(ctx, w, "loading posts", err)
+ return
+ }
+
+ blog.Render(ctx, w, "admin_list", http.StatusOK, blog.AdminListView{
+ Head: blog.Head{Title: "Posts"},
+ Query: q,
+ Rows: blog.AdminRows(posts),
+ Pagination: blog.BuildPagination("/admin/posts", q, page, total),
+ // The true blank state gets the empty-state card; a search that
+ // matched nothing gets a plain note instead. Telling a writer
+ // with forty posts that their blog is empty is a lie.
+ Empty: all == 0,
+ NoMatch: all > 0 && total == 0,
+ })
+}
+```
+
+- [ ] **Step 4: Regenerate**
+
+```bash
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate .
+```
+
+Expected: `3 route(s) wired`, and a new `gen/actions/admin/posts/index_get/index.GET.go` alongside an updated `gen/router.go`.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `gofmt -l . && go build ./cmd/blog && go vet ./cmd/... ./gen/... ./internal/... && go test ./internal/...`
+Expected: no gofmt output, vet silent, `ok blog/internal/blogtest`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add examples/blog
+git commit -m "feat(blog): the admin List screen — search, paging, empty states"
+```
+
+---
+
+### Task 5: Create and edit — four actions, the forms, and `blog.css`
+
+**Files:**
+- Create: `examples/blog/actions/admin/posts/new.GET.go`
+- Create: `examples/blog/actions/admin/posts/index.POST.go`
+- Create: `examples/blog/actions/admin/posts/[id]/edit.GET.go`
+- Create: `examples/blog/actions/admin/posts/[id]/index.POST.go`
+- Create: `examples/blog/static/blog.css`
+- Modify (regenerate): `examples/blog/gen/**`
+- Test: `examples/blog/internal/blogtest/admin_form_test.go`
+
+**Interfaces:**
+- Consumes: `blog.Create`, `blog.Update`, `blog.Get`, `blog.EditForm`, `blog.ParseID`, `blog.Render`, `blog.Fail`, `blog.AdminFormView`, `blog.Head`, `blog.PageSize`, `blog.List`, `blog.Count`; the Task 3 harness.
+- Produces: `GET /admin/posts/new`, `POST /admin/posts`, `GET /admin/posts/{id}/edit`, `POST /admin/posts/{id}` on the generated mux, and `static/blog.css`.
+
+- [ ] **Step 1: Write the failing form tests**
+
+Create `internal/blogtest/admin_form_test.go`:
+
+```go
+package blogtest
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "blog/internal/blog"
+)
+
+func TestNewPostFormRenders(t *testing.T) {
+ app, _ := newApp(t)
+
+ rec := get(t, app, "/admin/posts/new")
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, `<form class="blog-form" method="post" action="/admin/posts">`)
+ wantContains(t, body, `<label for="title">Title</label>`)
+ wantContains(t, body, `<input type="text" id="title" name="title" value="" required>`)
+ wantContains(t, body, `<textarea id="body" name="body" rows="18">`)
+ // An unsaved post has no status.
+ wantNotContains(t, body, `class="rst-status"`)
+}
+
+func TestCreateRedirectsToTheNewPostsEditPage(t *testing.T) {
+ app, db := newApp(t)
+
+ rec := post(t, app, "/admin/posts", url.Values{
+ "title": {"First post"},
+ "body": {"Hello."},
+ })
+ wantStatus(t, rec, http.StatusSeeOther)
+
+ posts, err := blog.List(db, "", 0, blog.PageSize)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(posts) != 1 {
+ t.Fatalf("created %d posts, want 1", len(posts))
+ }
+ want := fmt.Sprintf("/admin/posts/%d/edit", posts[0].ID)
+ if got := rec.Header().Get("Location"); got != want {
+ t.Errorf("Location = %q, want %q", got, want)
+ }
+
+ list := get(t, app, "/admin/posts")
+ wantContains(t, list.Body.String(), "First post")
+}
+
+func TestCreateWithAnEmptyTitleIs400AndCreatesNothing(t *testing.T) {
+ app, db := newApp(t)
+
+ rec := post(t, app, "/admin/posts", url.Values{
+ "title": {" "},
+ "body": {"Body the writer typed."},
+ })
+ wantStatus(t, rec, http.StatusBadRequest)
+ body := rec.Body.String()
+
+ wantContains(t, body, `<p class="blog-error" role="alert">A post needs a title.</p>`)
+ // The submitted body is still in the field: a failed submission never
+ // costs the writer what they typed.
+ wantContains(t, body, "Body the writer typed.")
+
+ n, err := blog.Count(db, "")
+ if err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("created %d posts, want 0", n)
+ }
+}
+
+func TestEditShowsCurrentValuesAndTheDraftPill(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Release notes", "The body.", false)
+
+ rec := get(t, app, fmt.Sprintf("/admin/posts/%d/edit", id))
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, `value="Release notes"`)
+ wantContains(t, body, "The body.")
+ wantContains(t, body, `<span class="rst-status" data-tone="neutral">Draft</span>`)
+ wantContains(t, body, fmt.Sprintf(`action="/admin/posts/%d/publish"`, id))
+ wantContains(t, body, fmt.Sprintf(`action="/admin/posts/%d/delete"`, id))
+ // A draft has no public page to link to.
+ wantNotContains(t, body, fmt.Sprintf(`href="/posts/%d"`, id))
+}
+
+func TestEditShowsThePublishedPillAfterPublishing(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Release notes", "The body.", true)
+
+ rec := get(t, app, fmt.Sprintf("/admin/posts/%d/edit", id))
+ wantStatus(t, rec, http.StatusOK)
+ body := rec.Body.String()
+
+ wantContains(t, body, `<span class="rst-status" data-tone="positive">Published</span>`)
+ wantContains(t, body, fmt.Sprintf(`action="/admin/posts/%d/unpublish"`, id))
+ wantContains(t, body, fmt.Sprintf(`href="/posts/%d"`, id))
+}
+
+func TestEditWithAMissingIdIs404(t *testing.T) {
+ app, _ := newApp(t)
+
+ rec := get(t, app, "/admin/posts/9999/edit")
+ wantStatus(t, rec, http.StatusNotFound)
+}
+
+// A non-numeric id is a URL that was never ours, on the admin side as
+// much as the public one: ParseID fails and the action answers 404, not
+// 400.
+func TestANonNumericIdIs404OnTheAdminSideToo(t *testing.T) {
+ app, _ := newApp(t)
+
+ if rec := get(t, app, "/admin/posts/abc/edit"); rec.Code != http.StatusNotFound {
+ t.Errorf("GET /admin/posts/abc/edit = %d, want 404", rec.Code)
+ }
+ if rec := post(t, app, "/admin/posts/abc", nil); rec.Code != http.StatusNotFound {
+ t.Errorf("POST /admin/posts/abc = %d, want 404", rec.Code)
+ }
+}
+
+// The 1 MiB MaxBytesReader guard opens every mutating action; this is the
+// test that proves it is wired. A body over the cap makes ParseForm fail,
+// and a ParseForm failure is a 400. The three state-change routes get the
+// same check in admin_state_test.go.
+func TestAnOversizedPostBodyIs400(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "T", "B", false)
+ for _, target := range []string{"/admin/posts", fmt.Sprintf("/admin/posts/%d", id)} {
+ req := httptest.NewRequest(http.MethodPost, target,
+ strings.NewReader("title="+strings.Repeat("x", 2<<20)))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ wantStatus(t, rec, http.StatusBadRequest)
+ }
+}
+
+func TestUpdateChangesTheFieldsAndRedirectsBack(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Before", "Old body.", false)
+ before, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+
+ // Timestamps are RFC3339 seconds, so wait for the clock to cross a
+ // second boundary — otherwise "updated_at moved" is unobservable.
+ time.Sleep(time.Until(time.Now().Truncate(time.Second).Add(time.Second)) + 20*time.Millisecond)
+
+ rec := post(t, app, fmt.Sprintf("/admin/posts/%d", id), url.Values{
+ "title": {"After"},
+ "body": {"New body."},
+ })
+ wantStatus(t, rec, http.StatusSeeOther)
+ if got, want := rec.Header().Get("Location"), fmt.Sprintf("/admin/posts/%d/edit", id); got != want {
+ t.Errorf("Location = %q, want %q", got, want)
+ }
+
+ after, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if after.Title != "After" || after.Body != "New body." {
+ t.Errorf("post = %q/%q, want After/New body.", after.Title, after.Body)
+ }
+ if !after.UpdatedAt.After(before.UpdatedAt) {
+ t.Errorf("updated_at did not move: %v then %v", before.UpdatedAt, after.UpdatedAt)
+ }
+ if !after.CreatedAt.Equal(before.CreatedAt) {
+ t.Errorf("created_at moved: %v then %v", before.CreatedAt, after.CreatedAt)
+ }
+}
+
+func TestUpdateWithAnEmptyTitleIs400AndChangesNothing(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Before", "Old body.", false)
+
+ rec := post(t, app, fmt.Sprintf("/admin/posts/%d", id), url.Values{
+ "title": {""},
+ "body": {"New body."},
+ })
+ wantStatus(t, rec, http.StatusBadRequest)
+ wantContains(t, rec.Body.String(), "A post needs a title.")
+ // The re-render is the edit screen, status area and all.
+ wantContains(t, rec.Body.String(), `<span class="rst-status" data-tone="neutral">Draft</span>`)
+
+ after, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if after.Title != "Before" || after.Body != "Old body." {
+ t.Errorf("post changed: %q/%q", after.Title, after.Body)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/...`
+Expected: FAIL — `/admin/posts/new` and `/admin/posts/{id}/edit` are unmatched GETs, so the index guard answers 404; the POSTs answer 405.
+
+- [ ] **Step 3: Write the create-form action**
+
+Create `actions/admin/posts/new.GET.go`:
+
+```go
+package actions
+
+import (
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is GET /admin/posts/new.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ blog.Render(ctx, w, "admin_new", http.StatusOK, blog.AdminFormView{
+ Head: blog.Head{Title: "New post"},
+ Action: "/admin/posts",
+ })
+}
+```
+
+- [ ] **Step 4: Write the create action**
+
+Create `actions/admin/posts/index.POST.go`:
+
+```go
+package actions
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is POST /admin/posts.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ // A megabyte is a generous post, and an unbounded ParseForm on a
+ // public endpoint is a memory-exhaustion hole.
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Bad request.", http.StatusBadRequest)
+ return
+ }
+
+ title := strings.TrimSpace(r.PostFormValue("title"))
+ body := r.PostFormValue("body")
+ if title == "" {
+ // Re-render with what the writer typed, rather than redirecting
+ // and losing it.
+ blog.Render(ctx, w, "admin_new", http.StatusBadRequest, blog.AdminFormView{
+ Head: blog.Head{Title: "New post"},
+ Action: "/admin/posts",
+ FormTitle: title,
+ Body: body,
+ Error: "A post needs a title.",
+ })
+ return
+ }
+
+ id, err := blog.Create(ctx.DB, title, body)
+ if err != nil {
+ blog.Fail(ctx, w, "creating post", err)
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/admin/posts/%d/edit", id), http.StatusSeeOther)
+}
+```
+
+- [ ] **Step 5: Write the edit-form action**
+
+Create `actions/admin/posts/[id]/edit.GET.go`:
+
+```go
+package actions
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is GET /admin/posts/{id}/edit.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ id, ok := blog.ParseID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ post, err := blog.Get(ctx.DB, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ blog.Fail(ctx, w, "loading post", err)
+ return
+ }
+ blog.Render(ctx, w, "admin_edit", http.StatusOK, blog.EditForm(post))
+}
+```
+
+- [ ] **Step 6: Write the update action**
+
+Create `actions/admin/posts/[id]/index.POST.go`:
+
+```go
+package actions
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is POST /admin/posts/{id}.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Bad request.", http.StatusBadRequest)
+ return
+ }
+ id, ok := blog.ParseID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ post, err := blog.Get(ctx.DB, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ blog.Fail(ctx, w, "loading post", err)
+ return
+ }
+
+ title := strings.TrimSpace(r.PostFormValue("title"))
+ body := r.PostFormValue("body")
+ if title == "" {
+ view := blog.EditForm(post)
+ view.FormTitle = title
+ view.Body = body
+ view.Error = "A post needs a title."
+ blog.Render(ctx, w, "admin_edit", http.StatusBadRequest, view)
+ return
+ }
+
+ if err := blog.Update(ctx.DB, id, title, body); err != nil {
+ blog.Fail(ctx, w, "updating post", err)
+ return
+ }
+ http.Redirect(w, r, fmt.Sprintf("/admin/posts/%d/edit", id), http.StatusSeeOther)
+}
+```
+
+- [ ] **Step 7: Write `static/blog.css`**
+
+```css
+/* blog.css — the app's own styles, loaded after tokens.css.
+ *
+ * Two rules govern this file, and both are tested:
+ * 1. Every value comes from a token. No literal colour, no literal px
+ * spacing — that is the whole mechanism by which these controls
+ * follow the dark theme without this file knowing dark exists.
+ * 2. It never styles an .rst- class. The forms use .rst-btn in their
+ * markup; no rule here redefines one.
+ *
+ * It covers exactly what the library deliberately does not ship: form
+ * controls (the field partials are deferred — friction finding F2) and a
+ * prose measure for the public post page.
+ */
+
+.blog-form { display: flex; flex-direction: column; gap: var(--rst-sp-2); max-inline-size: 44rem; }
+.blog-form label { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); font-weight: 600; }
+.blog-form input[type="text"],
+.blog-form textarea {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text);
+ font: inherit;
+ padding: var(--rst-sp-2) var(--rst-sp-3);
+}
+.blog-form textarea { line-height: 1.6; resize: vertical; }
+.blog-form__actions { display: flex; gap: var(--rst-sp-3); margin-block-start: var(--rst-sp-3); }
+
+/* tokens.css scopes its :focus-visible rule to the library's own
+ * containers, so nothing on a form screen inherits it. Same token, same
+ * shape, restated where the library's scope ends — friction finding F2. */
+.blog-form :focus-visible,
+.blog-status :focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
+
+.blog-error { color: var(--rst-tone-negative-fg); font-weight: 600; margin: 0 0 var(--rst-sp-3); }
+.blog-note { color: var(--rst-text-muted); margin: 0; padding: var(--rst-sp-3); }
+.blog-status { align-items: center; display: flex; flex-wrap: wrap; gap: var(--rst-sp-3); margin-block-end: var(--rst-sp-5); }
+.blog-status form { margin: 0; }
+
+.blog-article { color: var(--rst-text); line-height: 1.7; max-inline-size: 34rem; }
+.blog-article p { margin: 0 0 var(--rst-sp-4); }
+
+.blog-footer {
+ border-block-start: 1px solid var(--rst-line);
+ color: var(--rst-text-muted);
+ display: flex;
+ gap: var(--rst-sp-4);
+ margin-block-start: var(--rst-sp-6);
+ padding-block-start: var(--rst-sp-3);
+}
+```
+
+- [ ] **Step 8: Regenerate**
+
+```bash
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate .
+```
+
+Expected: `7 route(s) wired`, with new `gen/actions/admin/posts/new_get/`, `gen/actions/admin/posts/index_post/`, `gen/actions/admin/posts/id/edit_get/` and `gen/actions/admin/posts/id/index_post/` directories.
+
+- [ ] **Step 9: Run the tests to verify they pass**
+
+Run: `gofmt -l . && go build ./cmd/blog && go vet ./cmd/... ./gen/... ./internal/... && go test ./internal/...`
+Expected: no gofmt output, vet silent, `ok blog/internal/blogtest`.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add examples/blog
+git commit -m "feat(blog): the create and edit screens, and blog.css"
+```
+
+---
+
+### Task 6: Publish, unpublish, delete
+
+**Files:**
+- Create: `examples/blog/actions/admin/posts/[id]/publish.POST.go`
+- Create: `examples/blog/actions/admin/posts/[id]/unpublish.POST.go`
+- Create: `examples/blog/actions/admin/posts/[id]/delete.POST.go`
+- Modify (regenerate): `examples/blog/gen/**`
+- Test: `examples/blog/internal/blogtest/admin_state_test.go`
+
+**Interfaces:**
+- Consumes: `blog.SetPublished`, `blog.Delete`, `blog.ParseID`, `blog.Fail`, `blog.Get`, `blog.Count`; the Task 3 harness.
+- Produces: `POST /admin/posts/{id}/publish`, `POST /admin/posts/{id}/unpublish`, `POST /admin/posts/{id}/delete` — the last three of the ten routes.
+
+- [ ] **Step 1: Write the failing state-change tests**
+
+Create `internal/blogtest/admin_state_test.go`:
+
+```go
+package blogtest
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "blog/internal/blog"
+)
+
+func TestPublishMakesThePostPublic(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Release notes", "Body.", false)
+
+ rec := post(t, app, fmt.Sprintf("/admin/posts/%d/publish", id), nil)
+ wantStatus(t, rec, http.StatusSeeOther)
+ if got, want := rec.Header().Get("Location"), fmt.Sprintf("/admin/posts/%d/edit", id); got != want {
+ t.Errorf("Location = %q, want %q", got, want)
+ }
+
+ p, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if !p.Published {
+ t.Errorf("post is still a draft")
+ }
+ wantContains(t, get(t, app, "/").Body.String(), "Release notes")
+ wantStatus(t, get(t, app, fmt.Sprintf("/posts/%d", id)), http.StatusOK)
+}
+
+func TestUnpublishTakesThePostBackOffTheBlog(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Release notes", "Body.", true)
+
+ rec := post(t, app, fmt.Sprintf("/admin/posts/%d/unpublish", id), nil)
+ wantStatus(t, rec, http.StatusSeeOther)
+ if got, want := rec.Header().Get("Location"), fmt.Sprintf("/admin/posts/%d/edit", id); got != want {
+ t.Errorf("Location = %q, want %q", got, want)
+ }
+
+ p, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if p.Published {
+ t.Errorf("post is still published")
+ }
+ wantNotContains(t, get(t, app, "/").Body.String(), "Release notes")
+ wantStatus(t, get(t, app, fmt.Sprintf("/posts/%d", id)), http.StatusNotFound)
+}
+
+func TestDeleteRemovesThePost(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Release notes", "Body.", true)
+
+ rec := post(t, app, fmt.Sprintf("/admin/posts/%d/delete", id), nil)
+ wantStatus(t, rec, http.StatusSeeOther)
+ if got, want := rec.Header().Get("Location"), "/admin/posts"; got != want {
+ t.Errorf("Location = %q, want %q", got, want)
+ }
+
+ n, err := blog.Count(db, "")
+ if err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 0 {
+ t.Errorf("%d posts left, want 0", n)
+ }
+ wantStatus(t, get(t, app, fmt.Sprintf("/posts/%d", id)), http.StatusNotFound)
+ wantNotContains(t, get(t, app, "/admin/posts").Body.String(), "Release notes")
+}
+
+// The other three mutating actions carry the same 1 MiB body guard, and
+// a non-numeric id 404s on all three. admin_form_test.go covers create
+// and update; this covers publish, unpublish and delete, so all five
+// POST handlers are proven.
+func TestStateChangesGuardTheirBodiesAndTheirIds(t *testing.T) {
+ app, db := newApp(t)
+ id := seed(t, db, "Release notes", "Body.", false)
+
+ for _, verb := range []string{"publish", "unpublish", "delete"} {
+ req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/admin/posts/%d/%s", id, verb),
+ strings.NewReader("title="+strings.Repeat("x", 2<<20)))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ wantStatus(t, rec, http.StatusBadRequest)
+
+ if rec := post(t, app, "/admin/posts/abc/"+verb, nil); rec.Code != http.StatusNotFound {
+ t.Errorf("POST /admin/posts/abc/%s = %d, want 404", verb, rec.Code)
+ }
+ }
+
+ // Nothing got through: every one of those requests failed before its
+ // handler touched the database.
+ p, err := blog.Get(db, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if p.Published {
+ t.Errorf("an oversized publish still flipped the flag")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/...`
+Expected: FAIL — the three POST paths are unmatched, so `wantStatus` reports `status = 405, want 303`.
+
+- [ ] **Step 3: Write the publish action**
+
+Create `actions/admin/posts/[id]/publish.POST.go`:
+
+```go
+package actions
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is POST /admin/posts/{id}/publish.
+//
+// A separate route rather than a second submit button on the edit form:
+// the publish control is its own <form>, and a form nested inside another
+// form is invalid HTML that the browser silently drops.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Bad request.", http.StatusBadRequest)
+ return
+ }
+ id, ok := blog.ParseID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if err := blog.SetPublished(ctx.DB, id, true); err != nil {
+ blog.Fail(ctx, w, "publishing post", err)
+ return
+ }
+ // A missing id affects zero rows here and 404s on the redirect target
+ // instead. Deliberate asymmetry with POST /admin/posts/{id}, which
+ // 404s directly because it must load the post anyway to re-render.
+ http.Redirect(w, r, fmt.Sprintf("/admin/posts/%d/edit", id), http.StatusSeeOther)
+}
+```
+
+- [ ] **Step 4: Write the unpublish action**
+
+Create `actions/admin/posts/[id]/unpublish.POST.go`:
+
+```go
+package actions
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is POST /admin/posts/{id}/unpublish. The mirror of publish, and
+// a separate route for the same reason: its control is its own <form>,
+// and a form nested inside the edit form would be invalid HTML.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Bad request.", http.StatusBadRequest)
+ return
+ }
+ id, ok := blog.ParseID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if err := blog.SetPublished(ctx.DB, id, false); err != nil {
+ blog.Fail(ctx, w, "unpublishing post", err)
+ return
+ }
+ // A missing id affects zero rows here and 404s on the redirect target
+ // instead. Deliberate asymmetry with POST /admin/posts/{id}, which
+ // 404s directly because it must load the post anyway to re-render.
+ http.Redirect(w, r, fmt.Sprintf("/admin/posts/%d/edit", id), http.StatusSeeOther)
+}
+```
+
+- [ ] **Step 5: Write the delete action**
+
+Create `actions/admin/posts/[id]/delete.POST.go`:
+
+```go
+package actions
+
+import (
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+
+ "blog/internal/blog"
+)
+
+// Handle is POST /admin/posts/{id}/delete.
+func Handle(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Bad request.", http.StatusBadRequest)
+ return
+ }
+ id, ok := blog.ParseID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if err := blog.Delete(ctx.DB, id); err != nil {
+ blog.Fail(ctx, w, "deleting post", err)
+ return
+ }
+ // A missing id affects zero rows here and the list renders either way.
+ // Deliberate asymmetry with POST /admin/posts/{id}, which 404s
+ // directly because it must load the post anyway to re-render.
+ http.Redirect(w, r, "/admin/posts", http.StatusSeeOther)
+}
+```
+
+- [ ] **Step 6: Regenerate and verify all ten routes**
+
+```bash
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate .
+```
+
+Expected output — the ten routes of the spec's §6 table:
+
+```
+rastrillo generate: 10 route(s) wired
+ POST /admin/posts/{id}/delete actions/admin/posts/[id]/delete.POST.go
+ GET /admin/posts/{id}/edit actions/admin/posts/[id]/edit.GET.go
+ POST /admin/posts/{id} actions/admin/posts/[id]/index.POST.go
+ POST /admin/posts/{id}/publish actions/admin/posts/[id]/publish.POST.go
+ POST /admin/posts/{id}/unpublish actions/admin/posts/[id]/unpublish.POST.go
+ GET /admin/posts actions/admin/posts/index.GET.go
+ POST /admin/posts actions/admin/posts/index.POST.go
+ GET /admin/posts/new actions/admin/posts/new.GET.go
+ GET / actions/index.GET.go
+ GET /posts/{id} actions/posts/[id]/index.GET.go
+```
+
+- [ ] **Step 7: Run the tests to verify they pass**
+
+Run: `gofmt -l . && go build ./cmd/blog && go vet ./cmd/... ./gen/... ./internal/... && go test ./internal/...`
+Expected: no gofmt output, vet silent, `ok blog/internal/blogtest`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add examples/blog
+git commit -m "feat(blog): publish, unpublish and delete"
+```
+
+---
+
+### Task 7: Cross-screen sweeps, the README, and final verification
+
+**Files:**
+- Create: `examples/blog/internal/blogtest/screens_test.go`
+- Create: `examples/blog/README.md`
+
+**Interfaces:**
+- Consumes: the whole app — every route from Tasks 3–6, `static/blog.css` from Task 5, and the Task 3 harness plus the Task 2 assertions.
+- Produces: nothing further; this task closes the branch.
+
+- [ ] **Step 1: Write the failing cross-screen sweep**
+
+Create `internal/blogtest/screens_test.go`:
+
+```go
+package blogtest
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// populatedScreens renders every screen with content on it: eleven
+// published posts (so the guarded pagination strip appears on both list
+// screens, and /?page=2 is a real second page rather than an empty
+// state), plus one draft so the edit screen has a neutral pill to show.
+func populatedScreens(t *testing.T) map[string]string {
+ t.Helper()
+ app, db := newApp(t)
+ var published int64
+ for i := 1; i <= 11; i++ {
+ published = seed(t, db, fmt.Sprintf("Go post %02d", i), "First paragraph.\n\nSecond paragraph.", true)
+ }
+ draft := seed(t, db, "A draft to edit", "Body.", false)
+
+ out := map[string]string{}
+ for _, target := range []string{
+ "/",
+ "/?page=2",
+ fmt.Sprintf("/posts/%d", published),
+ "/admin/posts",
+ "/admin/posts?q=go",
+ "/admin/posts?q=zzz",
+ "/admin/posts/new",
+ fmt.Sprintf("/admin/posts/%d/edit", published),
+ fmt.Sprintf("/admin/posts/%d/edit", draft),
+ } {
+ rec := get(t, app, target)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", target, rec.Code)
+ }
+ out[target] = rec.Body.String()
+ }
+ return out
+}
+
+// emptyScreens renders the two blank states.
+func emptyScreens(t *testing.T) map[string]string {
+ t.Helper()
+ app, _ := newApp(t)
+ out := map[string]string{}
+ for _, target := range []string{"/", "/admin/posts"} {
+ rec := get(t, app, target)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", target, rec.Code)
+ }
+ out["empty "+target] = rec.Body.String()
+ }
+ return out
+}
+
+func allScreens(t *testing.T) map[string]string {
+ t.Helper()
+ out := populatedScreens(t)
+ for name, html := range emptyScreens(t) {
+ out[name] = html
+ }
+ return out
+}
+
+// The acceptance criterion, made executable: all eight partials are used
+// by the running app, not by a fixture. The marker strings are the ones
+// ui_test.go's own smoke test uses.
+func TestAllEightPartialsAppearAcrossTheApp(t *testing.T) {
+ var combined strings.Builder
+ for _, html := range allScreens(t) {
+ combined.WriteString(html)
+ }
+ out := combined.String()
+
+ markers := map[string]string{
+ "page-header": `<header class="rst-page-header">`,
+ "list-bar": `<div class="rst-lbar">`,
+ "list-bar-search": `<form class="rst-search"`,
+ "list-search-submit": `<button class="rst-sr-only" type="submit">`,
+ "list-row-action": `<div class="rst-row">`,
+ "status-pill": `<span class="rst-status"`,
+ "empty-state": `<div class="rst-empty">`,
+ "pagination": `<nav class="rst-pagination"`,
+ }
+ for name, marker := range markers {
+ if !strings.Contains(out, marker) {
+ t.Errorf("no screen rendered %s (%q)", name, marker)
+ }
+ }
+}
+
+func TestEveryScreenHasAPageHeaderAndATitle(t *testing.T) {
+ titleRe := regexp.MustCompile(`<title>([^<]+)</title>`)
+ for name, html := range allScreens(t) {
+ if !strings.Contains(html, `<header class="rst-page-header">`) {
+ t.Errorf("%s has no page header", name)
+ }
+ m := titleRe.FindStringSubmatch(html)
+ if m == nil || strings.TrimSpace(m[1]) == "" {
+ t.Errorf("%s has no non-empty <title>", name)
+ }
+ }
+}
+
+func TestAdminListScreenCarriesItsStockComponents(t *testing.T) {
+ screens := populatedScreens(t)
+ html := screens["/admin/posts"]
+ for _, want := range []string{
+ `<header class="rst-page-header">`,
+ `<div class="rst-lbar">`,
+ `<form class="rst-search"`,
+ `<button class="rst-sr-only" type="submit">`,
+ `<div class="rst-row">`,
+ `<nav class="rst-pagination"`,
+ } {
+ wantContains(t, html, want)
+ }
+}
+
+// Zero JavaScript, and nothing the browser fetches from another origin.
+// The layout's two stylesheet links are the app's only asset references,
+// so unlike the library's own sweep this one permits <link and checks
+// the target instead.
+func TestNoScreenContainsScriptOrAnOffOriginReference(t *testing.T) {
+ for name, html := range allScreens(t) {
+ for _, bad := range []string{"<script", "<iframe", "onload=", "onclick=", "http://", "https://", "//cdn", "srcset", "@import"} {
+ if strings.Contains(html, bad) {
+ t.Errorf("%s reaches outside the page (%q)", name, bad)
+ }
+ }
+ for _, attr := range []string{`href="`, `action="`} {
+ for _, value := range attributeValues(html, attr) {
+ if value == "" || !strings.ContainsAny(value[:1], "/?#") {
+ t.Errorf("%s: %s%s…\" is not a same-origin reference", name, attr, value)
+ }
+ }
+ }
+ }
+}
+
+// attributeValues returns every value of one attribute in the HTML.
+func attributeValues(html, attr string) []string {
+ var out []string
+ rest := html
+ for {
+ i := strings.Index(rest, attr)
+ if i < 0 {
+ return out
+ }
+ rest = rest[i+len(attr):]
+ j := strings.Index(rest, `"`)
+ if j < 0 {
+ return out
+ }
+ out = append(out, rest[:j])
+ rest = rest[j+1:]
+ }
+}
+
+func TestBlogCSSIsSelfContained(t *testing.T) {
+ css := readBlogCSS(t)
+ for _, bad := range []string{"@import", "url(", "http://", "https://", "src:"} {
+ if strings.Contains(css, bad) {
+ t.Errorf("blog.css reaches outside the page (%q)", bad)
+ }
+ }
+}
+
+// blog.css never styles a library class: an example that shipped
+// .rst-row { padding: … } would be teaching every reader to fork the
+// design system on day one. Comments are stripped first, because the
+// file's own header explains the rule by naming the prefix.
+func TestBlogCSSStylesNoLibraryClass(t *testing.T) {
+ css := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(readBlogCSS(t), "")
+ if strings.Contains(css, ".rst-") {
+ t.Errorf("blog.css contains a .rst- selector:\n%s", css)
+ }
+}
+
+// Colours and spacing come from tokens, never literals — that is the
+// whole mechanism by which these controls track the dark theme.
+func TestBlogCSSUsesTokensNotLiterals(t *testing.T) {
+ css := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(readBlogCSS(t), "")
+ if m := regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b`).FindString(css); m != "" {
+ t.Errorf("blog.css contains a literal colour %q", m)
+ }
+ if !strings.Contains(css, "var(--rst-accent)") {
+ t.Errorf("blog.css does not restate the focus ring with the accent token")
+ }
+}
+
+func readBlogCSS(t *testing.T) string {
+ t.Helper()
+ b, err := os.ReadFile("../../static/blog.css")
+ if err != nil {
+ t.Fatalf("read blog.css: %v", err)
+ }
+ return string(b)
+}
+```
+
+- [ ] **Step 2: Run the sweep to verify it passes**
+
+Run: `go test ./internal/... -run 'Screen|Partials|CSS' -v`
+Expected: PASS for every test above. If `TestAllEightPartialsAppearAcrossTheApp` fails on `rst-pagination`, the fixture is seeding fewer than eleven posts — the strip is guarded, so a correct app renders no strip below that.
+
+- [ ] **Step 3: Write `README.md`**
+
+Create `examples/blog/README.md`:
+
+````markdown
+# examples/blog — a whole app, built from stock parts
+
+A single-user blog: a public reading side (index, one post per page) and
+an admin section for writing, editing, publishing and deleting. Ten
+actions on rastrillo's filesystem router, one SQLite table, every one of
+the `ui` package's eight partials in its intended role, and **no
+JavaScript** — no `<script>` tag anywhere, no off-origin reference on any
+page. The look is `static/tokens.css` unedited plus about fifty app-owned
+lines in `static/blog.css`.
+
+`examples/helloworld` proves the framework's plumbing (scaffold,
+generate, serve, deploy). This proves its *shape*: that an app assembled
+from stock parts, with no JavaScript and almost no hand-written CSS, is a
+thing a person would ship.
+
+## Running it
+
+```
+cd examples/blog
+go build ./cmd/blog
+./blog -addr :8080
+```
+
+Then read at <http://localhost:8080/> and write at
+<http://localhost:8080/admin/posts>.
+
+**Run it from the app root.** Static files are served with
+`http.FileServer(http.Dir("static"))`, which is relative to the working
+directory, so starting the binary from anywhere else 404s both
+stylesheets and every screen renders unstyled (F8).
+
+**No `go generate` step.** `gen/` is committed, exactly as helloworld's
+is, so `go build ./cmd/blog` works on a fresh clone.
+
+**There is no auth.** The whole `/admin/…` subtree is open. Rastrillo's
+actor and session layers are designed but unbuilt (see the repo README's
+"Not built yet"), so this example runs locally and says so rather than
+inventing a password field (F7).
+
+## The commands that work
+
+```
+go build ./cmd/blog
+go test ./internal/...
+go vet ./cmd/... ./gen/... ./internal/...
+```
+
+`go build ./...`, `go vet ./...` and `go test ./...` all **fail** — see
+F9. That is a property of every rastrillo app with more than one action,
+not of this one.
+
+## How it is put together
+
+- `actions/` — one file per route, named `<name>.<VERB>.go`, in
+ directories that spell the URL. It is generator *input*, not a
+ compilable package.
+- `gen/` — committed generator output. Never hand-edited.
+- `internal/blog/store.go` — `Open`, the migration, `Post`, every query.
+ Plain functions over `*sql.DB`, so `Ctx.DB` flows straight from an
+ action into a query with nothing in between.
+- `internal/blog/view.go` — the template tree, `Render`, `Fail`, and
+ every view model. One base tree (ui's partials + the layout) cloned
+ once per page at startup, because every page file defines `content`.
+- `internal/blog/templates/` — `layout.html` and one file per screen.
+- `internal/blogtest/` — the tests, a directory of `_test.go` files that
+ drives the real generated mux through `httptest`.
+- `static/` — `tokens.css` (shipped by `rastrillo new`, unedited) and
+ `blog.css` (the app's own).
+
+## Development
+
+Regenerate after adding, renaming or removing an action:
+
+```
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate .
+```
+
+Check a committed `gen/` is fresh (for CI, or before review):
+
+```
+tmp=$(mktemp -d) && cp -R actions go.mod "$tmp"/
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate "$tmp"
+diff -r gen "$tmp/gen"
+```
+
+Copying `go.mod` is what makes the generated import paths match; the
+`actions/` tree and that one file are the generator's whole input. This
+is a procedure rather than a Go test because `internal/generate` is
+internal to the rastrillo module and the CLI always writes to
+`<dir>/gen`, so it cannot be pointed at a scratch output directory from
+inside a test. The everyday guard against a stale `gen/` is the route
+tests, which run through the committed router and fail immediately if it
+no longer matches the actions.
+
+## What building this revealed
+
+Recorded, not fixed. Nothing outside `examples/blog/` changed on this
+branch; each of these belongs to a later framework slice.
+
+**F1 — `list-row-action` has no status slot.** A blog list's most
+load-bearing fact per row is "draft or published", and the row contract
+offers `Main`, `Sub`, one action pill and a decorative `aria-hidden`
+lead marker. Status goes into `Sub` as prose. It works and it is
+accessible; the row partial wants a `Status` `Tone`/`Label` pair that
+renders `status-pill` in the row's right-hand group.
+
+**F2 — no form partials, and the focus ring stops at the library's
+edge.** The `field`/`field-textarea` family is deferred, so every form
+here is hand-rolled, and `tokens.css` scopes its `:focus-visible` rule to
+library containers, so `blog.css` has to restate the outline for controls
+outside them. Roughly half of `blog.css` would disappear the day form
+partials land.
+
+**F3 — no `dropdown`, so "show drafts only" is missing.** Also deferred.
+The obvious next control on the admin list — a status filter — has
+nowhere to attach, so the example ships without it rather than
+hand-rolling a control the library has already scheduled.
+
+**F4 — `Serve`'s `DBPath`/`Migrations` are unusable by any app that puts
+the DB in `Ctx`.** `Serve` opens the handle, defers its `Close`, and
+never exposes it; `openDB` is unexported and `Options` carries no `DB`
+field and no Ctx factory. So this app opens its own handle and
+hand-copies the pragma DSN — `busy_timeout` before `journal_mode(WAL)`,
+then `SetMaxOpenConns(1)` — which is precisely the hand-propagation the
+framework exists to end. Next slice: return the handle from `Serve`, or
+let `Options` carry the Ctx factory.
+
+**F5 — `actions/` cannot hold shared code, by two rules.** The generator
+copies only files matching `<name>.<VERB>.go`, and separately skips any
+file whose base name starts with `_`. So neither `helpers.go` nor
+`_helpers.go` ever reaches `gen/`, and an action calling into one
+compiles in `actions/` and fails in `gen/`. Shared code lives in a normal
+package (`internal/blog`); that is the only path, not a preference.
+
+**F6 — `actions/index.GET.go` is a catch-all.** `index` maps to `GET /`
+and Go's mux treats `/` as a prefix, so every unmatched GET lands in the
+index action. Every app needs the `r.URL.Path != "/"` guard, or the
+generator should emit a 404 fallback for it.
+
+**F7 — no auth, because there is none to use.** The `/admin/…` subtree is
+open. A real deployment would put it behind an authenticated session and
+set `Ctx.Actor` from it instead of this app's hardcoded human actor;
+nothing else about the app would change shape.
+
+**F8 — static serving is relative to the working directory.** The
+scaffolded line is `http.FileServer(http.Dir("static"))`, so the binary
+must be run from the app root or every screen renders unstyled.
+
+**F9 — `go build ./...`, `go vet ./...` and `go test ./...` all fail in a
+rastrillo app.** `actions/` is generator input, not a package Go can
+compile: two actions in one directory both declare `Handle`
+(`actions/admin/posts/` has three), and a `[id]` directory is a malformed
+Go import path — which is what every `./...` invocation trips over first,
+`vet` included. helloworld never hit this because it has one action in
+one directory. The working commands are listed above; the framework
+should say so in `rastrillo new`'s output, and could remove the problem
+entirely by reading actions from a directory the Go tool ignores.
+
+**F10 — `tokens.css` still styles a pagination state the partial no
+longer emits.** `ui/partials/pagination.html` renders a disabled item as
+a bare `<span>{{.Label}}</span>` — `aria-disabled` was deliberately
+dropped in `c00653c` — but `tokens.css` still carries
+`.rst-pagination [aria-disabled="true"] { border-style: dashed; color: var(--rst-text-faint) }`,
+which now matches nothing. The visible result on this blog's list screens
+is that a disabled `Previous` on page 1 looks identical to a live page
+link: same border, same colour, differing only in not being clickable.
+The fix belongs in the library — either restore the attribute on the
+span, or restyle the rule to target the disabled item as the partial
+actually emits it — and not in an example, so this branch changes
+nothing.
+````
+
+- [ ] **Step 4: Run the full suite and the whole verification set**
+
+```bash
+gofmt -l .
+go build ./cmd/blog
+go vet ./cmd/... ./gen/... ./internal/...
+go test ./internal/...
+```
+
+Expected: no gofmt output, a `blog` binary, vet silent, `ok blog/internal/blogtest`.
+
+- [ ] **Step 5: Verify the committed `gen/` is fresh**
+
+```bash
+tmp=$(mktemp -d) && cp -R actions go.mod "$tmp"/
+go run github.com/carlosframework/rastrillo/cmd/rastrillo generate "$tmp"
+diff -r gen "$tmp/gen" && echo "gen/ is fresh"
+rm -rf "$tmp"
+```
+
+Expected: no diff output, then `gen/ is fresh`.
+
+- [ ] **Step 6: Run the app and click through it once, with JavaScript disabled**
+
+```bash
+./blog -addr :8080
+```
+
+Walk the acceptance flow by hand: create a post → edit it → publish it →
+read it on the public side → search for it in the admin list → page past
+it → unpublish → delete. Then check the screens in both
+`prefers-color-scheme` states and with `data-theme` forced each way (the
+browser devtools' rendering panel, or add `data-theme="dark"` to `<html>`
+temporarily — do not commit that).
+
+- [ ] **Step 7: Verify nothing outside `examples/blog/` changed**
+
+```bash
+cd <rastrillo repo root>
+git diff --name-only ui-component-library...example-blog
+```
+
+Expected: every line starts `examples/blog/`. If any does not, revert that
+file — friction findings are notes in the README, never framework edits.
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd examples/blog
+git status --short # expect only internal/blogtest/screens_test.go and README.md
+git add examples/blog
+git commit -m "test(blog): cross-screen sweeps, and the README with the friction log"
+```
+
+---
+
+## Notes for the implementer
+
+- **Every command in Tasks 1–7 runs from `examples/blog/`** unless it says otherwise.
+- **Never hand-write or hand-edit anything under `gen/`.** If a route looks wrong, fix the file under `actions/` and re-run the generator.
+- **The `[id]` directories need quoting in the shell** (`mkdir -p 'actions/posts/[id]'`), because brackets are glob characters.
+- **`gofmt -l .` is part of every verification step.** The rastrillo repo is gofmt-clean and this example must stay that way.
+- **If a test fails on a rendered string, print the body.** Every assertion helper in `assert_test.go` already does; read the HTML before changing the assertion.
diff --git a/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md b/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md
index 2e8c49e95..20eda013a 100644
--- a/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md
+++ b/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md
@@ -4,6 +4,10 @@
was made before writing; see §14 for the ones made while the user was
away.
+**Amended 2026-08-03:** §11 verification commands corrected per F9 —
+`go build/vet/test ./...` all fail on multi-action apps; scoped commands
+are the supported form (found during plan review).
+
**Builds on** `2026-08-02-rastrillo-ui-component-library-design.md` (the
"library spec") — the eight partials, `tokens.css`, `Funcs()`, and its §5
data contracts are this app's entire component vocabulary — and on
@@ -631,8 +635,24 @@ for; it is friction finding F2's smaller half.
Plain stdlib — `testing`, `net/http/httptest`, `strings` — mirroring
`ui/ui_test.go` and `icons_test.go`. All tests live in
`examples/blog/internal/blogtest/`, a directory containing only `_test.go`
-files. That is a valid Go package: `go test ./...` runs it and
-`go build ./...`/`go vet ./...` pass over it silently (verified).
+files. That is a valid Go package: `go test ./internal/...` runs it, and
+`go build ./cmd/blog` and `go vet ./cmd/... ./gen/... ./internal/...`
+pass over it silently.
+
+**The package patterns must be scoped, not `./...`.** The original
+wording here claimed `go test ./...` runs these tests and
+`go build ./...`/`go vet ./...` pass silently. That was verified against
+`internal/blogtest/` alone and is false for the module as a whole:
+`actions/` is generator input, not a compilable package. Two actions in
+one directory both declare `Handle` (`actions/admin/posts/` holds three),
+and a `[id]` directory is a malformed Go import path — which is what
+every `./...` invocation trips over first, `vet` included. So all three
+of `go build ./...`, `go vet ./...` and `go test ./...` fail in this app,
+and in any rastrillo app with more than one action; `helloworld` never
+hit it because it has exactly one. The supported commands are the scoped
+ones above, and §13's acceptance criterion (which repeats the old
+`go test ./...` wording) reads with them. This is friction finding
+**F9**, recorded in the example's README beside F1–F8.
The tests import `blog/gen`, which imports the generated actions, which
import `blog/internal/blog` — so they cannot be an *internal* test of