| 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, "<script>") |
| +} |
| + |
| +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&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&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. |