spec: rastrillo blog example — the north-star app, from stock components
examples/blog: ten file-routed actions, SQLite via the app's own Open (recording the real F4 friction: Serve's DBPath never reaches Ctx), all eight ui partials in real use, hand-rolled forms via the blog.css extension convention, and an eight-entry friction log for the next framework slices. Assumptions logged — designed while the user was away.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+887
−0
docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md+887 −0
diff --git a/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md b/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md| new file mode 100644 |
| index 000000000..2e8c49e95 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-03-rastrillo-blog-example-design.md |
| @@ -0,0 +1,887 @@ |
| +# `examples/blog`: the north-star app, built from stock parts |
| + |
| +**Status:** approved 2026-08-03 (Paul) — every decision in this document |
| +was made before writing; see §14 for the ones made while the user was |
| +away. |
| + |
| +**Builds on** `2026-08-02-rastrillo-ui-component-library-design.md` (the |
| +"library spec") — the eight partials, `tokens.css`, `Funcs()`, and its §5 |
| +data contracts are this app's entire component vocabulary — and on |
| +`2026-08-01-carlos-framework-design.md` §4 (filesystem routing) and §5 |
| +(the `Serve` bootstrap), as implemented in the rastrillo repo's |
| +`ui-component-library` branch. |
| + |
| +## 1. Overview and motivation |
| + |
| +The library spec proved its eight partials in a test file: `ui_test.go`'s |
| +`TestRenderEverythingSmoke` renders every one of them into a |
| +`strings.Builder` and checks the markers are present. That is the right |
| +bar for a library, and it is not the bar the project set itself. The |
| +north star is blunter: **a simple blog app that looks half-decent, built |
| +from stock components and simple actions.** A `strings.Builder` cannot |
| +tell you whether `list-row-action` has a slot for the one piece of |
| +information a blog list is actually about, or whether an app that needs |
| +its database inside `Ctx` can use `rastrillo.Serve`'s database options at |
| +all. Only building the app tells you that. |
| + |
| +So `examples/blog` is a real, single-user blog: a public reading side |
| +(index, one post per page) and an admin section for writing, editing, |
| +publishing and deleting posts. It ships in the rastrillo repo beside |
| +`examples/helloworld`, checked in the same way — `gen/` committed, |
| +`replace` directive to the parent module, buildable with one `go build`. |
| + |
| +It exists for three reasons, in this order: |
| + |
| +1. **Proof.** The eight partials compose into a coherent screen that |
| + looks finished, in light and dark, with JavaScript disabled. |
| +2. **Documentation.** Rastrillo's docs get a canonical example bigger |
| + than "Hello, World" — a whole app whose every route, template and test |
| + is readable in an afternoon. |
| +3. **Pressure.** Building it drives out what the library and the |
| + framework are still missing. Those findings get *recorded* — §12 and |
| + the example's README — not quietly patched around. An example that |
| + silently reaches past the framework's seams to look good is worse than |
| + no example: it hides exactly the information it was built to produce. |
| + |
| +`examples/helloworld` proved the framework's plumbing (scaffold, generate, |
| +serve, deploy). `examples/blog` proves its *shape*: that an app assembled |
| +from stock parts, with no JavaScript and no hand-written CSS beyond |
| +roughly fifty lines, is a thing a person would ship. |
| + |
| +## 2. Goals |
| + |
| +- A working blog — public index, public post page, and a full admin CRUD |
| + loop (create, edit, publish, unpublish, delete) — in ten actions on |
| + rastrillo's filesystem router. |
| +- **Every one of the eight partials used at least once**, in its intended |
| + role, with its data built the way the library spec documents (§4.1's |
| + `dict`, or a Go struct where the data is dynamic). |
| +- SQLite storage through `Ctx.DB`, one additive migration, no ORM, no |
| + query builder, no third-party dependency of any kind beyond the SQLite |
| + driver rastrillo itself already carries. |
| +- **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. |
| +- The look comes from `tokens.css` unedited, plus one small app-owned |
| + stylesheet that only styles what the library deliberately doesn't ship |
| + (§10). |
| +- Route-level tests over the real generated mux, in the rastrillo repo's |
| + established stdlib style — `httptest`, no assertion library. |
| +- A friction log: what building this revealed the library and framework |
| + still need. |
| + |
| +## 3. Non-goals |
| + |
| +- **Auth.** There is none. This is a single-user example run locally, and |
| + the framework design doc's auth layers (§8's actors, WebAuthn, the |
| + crypto core) are designed but unbuilt — see the repo README's "Not |
| + built yet". A real deployment would put the whole `/admin/…` subtree |
| + behind an authenticated session and set `Ctx.Actor` from it instead of |
| + the hardcoded human actor in §9; nothing else about the app would |
| + change shape. Recorded in §12 as a gap, not worked around here. |
| +- **Multi-user, comments, drafts-with-history, tags, feeds, uploads.** |
| + A blog is the smallest app that exercises a list screen honestly. Every |
| + feature beyond that is a feature this example would have to test and |
| + document without learning anything new about the library. |
| +- **Markdown, or any rendering dependency.** Post bodies are plain text; |
| + blank lines separate paragraphs (§8). A Markdown library would be the |
| + example's only non-framework dependency and would prove nothing. |
| +- **Form partials.** The library spec's §3 defers the `field`/ |
| + `field-textarea` family to a later slice, and this example does not |
| + invent its own version of it. The forms are hand-rolled HTML, and the |
| + gap is the single loudest entry in the friction log. |
| +- **A `dropdown`-based filter or sort control.** Also deferred by the |
| + library spec's §3. The admin list searches; it does not filter by |
| + status or re-sort. `list-bar` reserves the space, as designed. |
| +- **Changing anything outside `examples/blog/`.** The branch is |
| + `example-blog`, stacked on `ui-component-library`. Every friction |
| + finding in §12 is a note in this spec and the example's README, and |
| + zero lines in the framework. |
| + |
| +## 4. Shape on disk |
| + |
| +The example looks like a scaffolded app that grew — `rastrillo new`'s |
| +output (`cmd/rastrillo/new.go:28-53`: `go.mod`, `actions/`, |
| +`cmd/<name>/main.go`, `static/tokens.css`) with routes, templates, and one |
| +internal package added: |
| + |
| +``` |
| +examples/blog/ |
| + go.mod, go.sum module blog; replace => ../.. (helloworld's convention) |
| + README.md what it is, how to run, the friction log |
| + actions/ |
| + index.GET.go GET / |
| + posts/[id]/index.GET.go GET /posts/{id} |
| + admin/posts/index.GET.go GET /admin/posts |
| + admin/posts/index.POST.go POST /admin/posts |
| + admin/posts/new.GET.go GET /admin/posts/new |
| + admin/posts/[id]/edit.GET.go GET /admin/posts/{id}/edit |
| + admin/posts/[id]/index.POST.go POST /admin/posts/{id} |
| + admin/posts/[id]/publish.POST.go POST /admin/posts/{id}/publish |
| + admin/posts/[id]/unpublish.POST.go POST /admin/posts/{id}/unpublish |
| + admin/posts/[id]/delete.POST.go POST /admin/posts/{id}/delete |
| + cmd/blog/main.go flags, DB, Ctx factory, static serving, Serve |
| + gen/ committed generator output (router.go + gen/actions/…) |
| + internal/blog/ |
| + store.go Open, the migration, Post, every query |
| + view.go the template tree, Render, the view-model types |
| + templates/ |
| + layout.html {{define "layout"}} — html/head/body |
| + pages/*.html one file per screen, each {{define "content"}} |
| + internal/blogtest/ route + render tests (test files only) |
| + static/ |
| + tokens.css scaffolded by rastrillo new, unedited |
| + blog.css the app's own ~50 lines (§10) |
| +``` |
| + |
| +**Why `internal/blog/` exists, and why it has to.** The generator never |
| +compiles `actions/` in place: it walks the tree, matches |
| +`^(.+)\.(GET|POST|…)\.go$` only (`internal/generate/generate.go:33,70-73`), |
| +rewrites each matching file's package clause, and copies *that file alone* |
| +into its own directory under `gen/actions/` (`generate.go:174-197`). A |
| +sibling `helpers.go` in `actions/` is skipped by `Discover` and never |
| +copied, so an action referring to it would compile in `actions/` and fail |
| +in `gen/`. There is no escape hatch: `Discover` also skips any file whose |
| +base name starts with `_` (`generate.go:67-69` — the `_middleware.go` |
| +rule), so `_helpers.go` is dropped for a second, independent reason. |
| +Shared app code therefore lives in an ordinary importable |
| +package, and every action imports `blog/internal/blog` like any other Go |
| +package — imports travel unmodified through the rewrite |
| +(`generate.go:172-173`). This is a real convention the framework implies |
| +but nowhere states; §12 records it. |
| + |
| +## 5. Data model and storage |
| + |
| +One table, one additive migration string, applied at startup: |
| + |
| +```sql |
| +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 |
| +); |
| +``` |
| + |
| +Timestamps are RFC3339 strings in UTC (`time.Now().UTC().Format(time.RFC3339)`), |
| +the family convention SQLite makes cheapest to sort and read. `published` |
| +is 0/1, SQLite's boolean. The migration is idempotent by construction — |
| +`CREATE TABLE IF NOT EXISTS` — matching `Options.Migrations`'s |
| +additive-only contract (`serve.go:50-53`) even though, per §9, this app |
| +applies it itself. |
| + |
| +`internal/blog/store.go` exposes plain functions over `*sql.DB`, so |
| +`Ctx.DB` (`ctx.go:19`) flows straight from the action into the query with |
| +nothing in between: |
| + |
| +```go |
| +func Open(path string) (*sql.DB, error) // pragmas + migration |
| +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 |
| +``` |
| + |
| +`Post` is `{ID int64; Title, Body string; Published bool; CreatedAt, |
| +UpdatedAt time.Time}` — the RFC3339 strings are parsed on scan so |
| +formatting happens once, in Go, and templates never format a date. |
| + |
| +Ordering is `created_at DESC, id DESC` everywhere (the id tiebreak matters |
| +because two posts created in the same second are ordinary in a test). |
| +`ListPublished` adds `WHERE published = 1`. |
| + |
| +**Search** (`?q=`) matches the title only: |
| +`WHERE title LIKE ? ESCAPE '\'`, with the parameter built by an |
| +`likePattern` helper that escapes `\`, `%` and `_` in the user's string |
| +before wrapping it in `%…%`. Parameterised, so injection is not the |
| +concern; the escape is so a reader searching for `100%` gets a literal |
| +match rather than a wildcard. SQLite's `LIKE` is ASCII-case-insensitive |
| +by default, which is the behaviour a title search wants. |
| + |
| +## 6. Routes |
| + |
| +Ten actions. The `Route` column is exactly what |
| +`internal/generate/generate.go`'s `routeFor` emits for that path |
| +(`generate.go:114-138`): a `[param]` directory becomes `{param}`, and a |
| +file named `index` contributes no segment (`generate.go:119-121`). |
| + |
| +| Action file (under `actions/`) | Route | What it does | |
| +| --- | --- | --- | |
| +| `index.GET.go` | `GET /` | Published posts, newest first: title + date rows, paginated. Empty state when nothing is published. | |
| +| `posts/[id]/index.GET.go` | `GET /posts/{id}` | One published post. 404 for a draft, a missing id, or a non-numeric id. | |
| +| `admin/posts/index.GET.go` | `GET /admin/posts` | The List screen — the showcase. Search (`?q=`), pagination (`?page=`). | |
| +| `admin/posts/new.GET.go` | `GET /admin/posts/new` | The create form. | |
| +| `admin/posts/index.POST.go` | `POST /admin/posts` | Create. 303 → `/admin/posts/{id}/edit`. Empty title re-renders the form, 400. | |
| +| `admin/posts/[id]/edit.GET.go` | `GET /admin/posts/{id}/edit` | The edit form plus the status area. 404 for a missing id. | |
| +| `admin/posts/[id]/index.POST.go` | `POST /admin/posts/{id}` | Update title and body. 303 → the same edit page. Empty title re-renders, 400. | |
| +| `admin/posts/[id]/publish.POST.go` | `POST /admin/posts/{id}/publish` | `published = 1`. 303 → the edit page. | |
| +| `admin/posts/[id]/unpublish.POST.go` | `POST /admin/posts/{id}/unpublish` | `published = 0`. 303 → the edit page. | |
| +| `admin/posts/[id]/delete.POST.go` | `POST /admin/posts/{id}/delete` | Delete. 303 → `/admin/posts`. | |
| + |
| +Every POST is a plain `<form method="post">` and answers with |
| +`http.StatusSeeOther` (303) and a `Location` — redirect-after-POST, so a |
| +reload never re-submits. No POST renders a page on success; the two |
| +validation failures are the only POSTs that render, and they render the |
| +form they came from with the submitted values still in the fields. |
| + |
| +**Three route-level rules the reality of the mux forces:** |
| + |
| +- **`GET /` is a catch-all.** Go 1.22's `ServeMux` treats `/` as a prefix |
| + pattern, so the index action receives every unmatched GET — `/nonsense` |
| + included. `index.GET.go` therefore opens with |
| + `if r.URL.Path != "/" { http.NotFound(w, r); return }`. Every rastrillo |
| + app with an `actions/index.GET.go` needs that guard; §12 records it. |
| +- **Path params are strings.** `r.PathValue("id")` goes through |
| + `strconv.ParseInt`; a parse failure is a 404, not a 400 — a |
| + non-numeric id is a URL that was never ours. |
| +- **`/static/` outranks `/`.** The static handler registered in `main.go` |
| + (`GET /static/`, §9) is a longer pattern than `GET /`, so the stdlib |
| + mux prefers it. No ordering care is needed. |
| +- **Trailing-slash variants get no redirect.** None of these routes is |
| + registered with a trailing slash, so `/admin/posts/` and `/posts/1/` |
| + match nothing but the `GET /` prefix pattern and land in the index |
| + action, where the path guard above turns them into a 404. Go's mux only |
| + redirects to a trailing-slash form when a subtree pattern is |
| + registered, and this app registers none — so a stray slash is a |
| + not-found, not a bounce. |
| +- **A POST to an unmatched path is a 405, not a 404.** `GET /` matches |
| + the *path* of `POST /nonsense` and `POST /posts/1` but not the method, |
| + so the stdlib mux answers `405 Method Not Allowed` with an `Allow` |
| + header before any action runs. The index guard never sees those |
| + requests. Worth knowing before someone reads a 405 as a routing bug. |
| + |
| +## 7. Screens, and exactly which stock components build them |
| + |
| +Five screens. Between them they use all eight partials; the table after |
| +this section is the map. |
| + |
| +### 7.1 `GET /admin/posts` — the List screen |
| + |
| +The showcase, and the screen the library was designed for. |
| + |
| +- **`page-header`** — `dict "Title" "Posts" "ActionHref" "/admin/posts/new" |
| + "ActionLabel" "New post" "ActionIcon" "plus"`. The `plus` slug is |
| + already vendored (`icons.go:77`), so this needs no new icon. |
| +- **`list-bar`** — `dict "SearchAction" "/admin/posts" "Query" .Query |
| + "Placeholder" "Search posts" "Hidden" .Carry`. It wraps |
| + `list-bar-search`, which wraps `list-search-submit` |
| + (`ui/partials/list-bar.html:19-21`, `list-bar-search.html:37-44`), so |
| + one call exercises three partials — including the screen-reader-only |
| + submit button a keyboard user with JS off actually needs. `.Carry` is |
| + empty on this screen: there is no sort order or page size to preserve |
| + across a search, and a new search deliberately returns to page 1. |
| +- **Rows** — one `list-row-action` per post, inside the app's own |
| + `<div class="rst-list">` card (the container class the library |
| + documents but does not emit, `ui/ui.go:22-25`): |
| + |
| + | Key | Value | |
| + | --- | --- | |
| + | `Href` | `/admin/posts/{id}/edit` — the row goes where the work is | |
| + | `Main` | the post title | |
| + | `Sub` | `Published 2 August 2026`, or `Draft · edited 2 August 2026` | |
| + | `ActionHref` | `/posts/{id}` — published posts only | |
| + | `ActionLabel` | `View` | |
| + | `ActionAria` | `View <title>` | |
| + |
| + Two deliberate decisions here. **Status rides in `Sub` as text**, |
| + because the stock row contract has no slot for a pill |
| + (`list-row-action.html:13-29`: `Href`, `Main`, `Sub`, the action pill, |
| + and a decorative `Lead`). The one thing a blog list is most about — |
| + is this post live? — has nowhere structural to go. It is legible and it |
| + is accessible; it is also the loudest thing this example learned, and it |
| + is friction finding F1 rather than a library patch in this slice. |
| + **Drafts get no action pill**, because a draft has no public page to |
| + send anyone to; the partial renders nothing when `ActionHref` is empty |
| + (`list-row-action.html:35-37`), so this needs no conditional markup of |
| + our own. |
| + |
| + `Lead`/`LeadInitial` go unused, on purpose. Tinting a leading circle by |
| + publish state would encode status in colour, on an element the partial |
| + marks `aria-hidden` (`list-row-action.html:32`) — a WCAG 2.2 AA failure |
| + dressed as decoration. There is no other meaningful monogram for a post. |
| +- **`pagination`** — page size 10, `?page=N`, with `q` preserved in every |
| + page link. `Items` is a `[]blog.PageItem` (`{Label, Href string; |
| + Current, Disabled, Gap bool}`), built in Go: Previous (`Disabled` on |
| + page 1), the page numbers, Next (`Disabled` on the last page). Up to |
| + seven pages every number renders; beyond that it is first · gap · |
| + current±1 · gap · last. In day-to-day use — and in every test fixture |
| + here, the largest of which is eleven posts — the blog exercises the |
| + strip's common kinds: numbered links, the `Current` page, and |
| + `Disabled` Previous/Next at the ends. `Gap` needs 71 posts to appear, |
| + so it stays covered by the library's own fixtures |
| + (`ui_test.go:408-435`) rather than by this app; the builder emits it |
| + correctly when a blog gets that long. |
| + |
| + The strip renders only when there is more than one page — and that is |
| + the app's decision, not the partial's: `pagination` emits its `<nav>` |
| + even with no `Items` (`ui/partials/pagination.html:19-27`, |
| + `ui_test.go:459-467`), so an unguarded call would leave an empty |
| + landmark on every single-page list. The page template guards it with |
| + `{{if .Pagination.Show}}`, and the view model sets `Show` only when the |
| + total exceeds the page size. |
| +- **`empty-state`** — for the true blank state only, when the blog has no |
| + posts at all: `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"`. |
| + |
| + **A search that matches nothing is not an empty state.** It renders a |
| + plain `<p class="blog-note">No posts match “<q>”.</p>` inside the list |
| + card instead — following the library spec's own titogo observation |
| + (library spec §10: "titogo does not render its empty-state illustration |
| + for a filter that simply matches nothing, and neither does this |
| + check"). A dashed blank-state card telling a writer with forty posts |
| + that their blog is empty is a lie the component would happily tell. |
| + |
| +### 7.2 `GET /admin/posts/{id}/edit` — the edit screen |
| + |
| +- **`page-header`** — `Title` is the post's own title, `Sub` is |
| + `"Editing"`. No `ActionHref`: the actions on this screen act on the |
| + post and must be POSTs, which a header link cannot be. |
| +- **`status-pill`** — `dict "Tone" .StatusTone "Label" .StatusLabel`, |
| + resolved in Go to `neutral`/`Draft` or `positive`/`Published`. The |
| + partial takes the resolved pair by design; mapping a raw value to a |
| + tone is product logic that stays in the app |
| + (`ui/partials/status-pill.html:12-14`). |
| +- **The status area** — a `<div class="blog-status">` holding, in order: |
| + the pill, the publish-or-unpublish form, a `View` link when published, |
| + and the delete form. Both forms are `<form method="post">` with a single |
| + `<button class="rst-btn">`; they are **siblings of** the edit form, not |
| + inside it, because nested forms are invalid HTML and the browser drops |
| + the inner one. That constraint is why the publish toggle is its own |
| + route rather than a second submit button on the edit form. |
| +- **The edit form** — hand-rolled: a `<label for>`/`<input type="text" |
| + id name="title" required>` pair and a `<label for>`/`<textarea id |
| + name="body" rows="18">` pair, then `<button class="rst-btn |
| + rst-btn--primary" type="submit">Save</button>`. Real labels bound by |
| + `for`/`id`, so every field has an accessible name without an |
| + `aria-label` in sight. A validation failure renders |
| + `<p class="blog-error" role="alert">` above the fields. |
| + |
| +### 7.3 `GET /admin/posts/new` — the create screen |
| + |
| +`page-header` (`Title "New post"`, `Sub "It starts as a draft — nothing |
| +is public until you publish it."`), then the same hand-rolled form |
| +posting to `/admin/posts`, with a `Cancel` link back to the list. No |
| +status pill: an unsaved post has no status. |
| + |
| +### 7.4 `GET /` — the public index |
| + |
| +- **`page-header`** — `Title "The blog"`, `Sub "Notes, in the order they |
| + were written."` No action: readers have nothing to do here but read. |
| +- **Rows** — `list-row-action` with `Href` `/posts/{id}`, `Main` the |
| + title, `Sub` the published date, and **no action pill**: the row already |
| + goes to the only place it could go, and a "Read" pill beside a link to |
| + the same URL is noise with a tab stop. |
| +- **`empty-state`** — `Body "Nothing published yet. Check back soon."`, |
| + no `Title`, no CTA. A reader cannot act on an empty blog, and a |
| + blank-state card with a dead button is worse than one without. |
| +- **`pagination`** — the same builder as the admin list, over |
| + `/?page=N`, rendered only past ten published posts. |
| +- No `list-bar`: public search over one author's blog is a feature this |
| + example does not need, and adding it to fill a component slot would be |
| + the exact dishonesty §1 warns about. |
| + |
| +### 7.5 `GET /posts/{id}` — the public post |
| + |
| +`page-header` with the post's title and the published date as `Sub`, |
| +then `<article class="blog-article">` containing one `<p>` per paragraph. |
| +Paragraphs are split in Go on blank lines and rendered as ordinary |
| +`{{.}}` values — escaped by `html/template`, never `template.HTML`. Post |
| +bodies are author-supplied plain text and stay that way; there is no path |
| +in this app by which a body becomes markup. |
| + |
| +### 7.6 The partial-to-screen map |
| + |
| +| Partial | Where it appears | |
| +| --- | --- | |
| +| `page-header` | all five screens | |
| +| `list-bar` | `/admin/posts` | |
| +| `list-bar-search` | `/admin/posts`, via `list-bar` | |
| +| `list-search-submit` | `/admin/posts`, via `list-bar-search` | |
| +| `list-row-action` | `/admin/posts`, `/` | |
| +| `status-pill` | `/admin/posts/{id}/edit` | |
| +| `empty-state` | `/admin/posts` (no posts), `/` (nothing published) | |
| +| `pagination` | `/admin/posts`, `/` | |
| + |
| +## 8. Template architecture |
| + |
| +Exactly the pattern the library spec documents (§4, and `ui/ui.go:11-13`): |
| +the app owns rendering; `ui` contributes names to the app's tree. |
| + |
| +**One base tree, cloned once per page, at startup.** `internal/blog/view.go`: |
| + |
| +```go |
| +//go:embed templates/layout.html templates/pages/*.html |
| +var templateFS embed.FS |
| + |
| +var pages = buildPages() // panics at init on a broken template |
| + |
| +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") |
| + // … |
| + 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 |
| +} |
| +``` |
| + |
| +The clone is what makes the layout work. Every page file defines |
| +`{{define "content"}}`, which is 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. |
| +Cloning the base per page gives each screen its own `content`, so |
| +`layout.html` can call `{{template "content" .}}` with a constant name — |
| +the only thing `html/template` allows. Five clones happen once, at |
| +process start; nothing clones per request. This is the standard Go idiom |
| +for layout-plus-pages, and it is chosen here over the alternative |
| +(a distinct define name per page, invoked through a switch) because that |
| +alternative pushes a name-matching problem into every action. |
| + |
| +`layout.html` is one `{{define "layout"}}` containing `<html lang="en">`, |
| +a head with `<meta charset>`, `<meta name="viewport">`, |
| +`<title>{{.Head.Title}} · The blog</title>`, and two stylesheet links in |
| +this order: |
| + |
| +```html |
| +<link rel="stylesheet" href="/static/tokens.css"> |
| +<link rel="stylesheet" href="/static/blog.css"> |
| +``` |
| + |
| +— then `<body>`, `<div class="rst-page">` (the content column the library |
| +documents as the app's job, `ui/ui.go:24`), `{{template "content" .}}`, |
| +and a two-link `<footer class="blog-footer">` (`The blog` · |
| +`Posts admin`). The footer sits after the content, so it adds no |
| +repeated-block-before-content problem for WCAG 2.4.1 and needs no skip |
| +link. There is no `<script>` tag, here or anywhere. |
| + |
| +**Rendering.** One helper, used by every action: |
| + |
| +```go |
| +func Render(ctx *rastrillo.Ctx, w http.ResponseWriter, page string, status int, data any) |
| +``` |
| + |
| +It looks the page up, executes `"layout"` into a `bytes.Buffer` first, |
| +and only then writes the status code and the bytes — so a template error |
| +produces a clean 500 rather than half a page followed by a stack of |
| +garbage. On error it logs through `ctx.Logger`, which is the second real |
| +use of `Ctx` in this app after `Ctx.DB`. |
| + |
| +**Error paths, so the actions stay thin.** A sibling helper — |
| +`fail(ctx, w, "loading posts", err)` — logs through `ctx.Logger` and |
| +writes a plain 500, and every store error that is not `sql.ErrNoRows` |
| +goes through it in one line; `sql.ErrNoRows` is the only error an action |
| +interprets, and it always means 404. Each POST action opens by wrapping |
| +the body — `r.Body = http.MaxBytesReader(w, r.Body, 1<<20)` — before |
| +calling `r.ParseForm()`, and a `ParseForm` error is a 400: a megabyte is |
| +a generous post and an unbounded `ParseForm` on a public endpoint is a |
| +memory-exhaustion hole no example should teach. Those three lines plus |
| +the id parse are the whole preamble of every mutating action. |
| + |
| +**View models are Go structs, never `dict`-built maps.** Each screen has |
| +a type in `internal/blog` — `HomeView`, `PostView`, `AdminListView`, |
| +`AdminFormView` — each with a `Head Head` field carrying the `<title>`. |
| +Struct data is a deliberate choice against the library spec's §9 warning: |
| +with map data a mistyped field renders empty and `Execute` returns no |
| +error, while a struct field that doesn't exist fails loudly. The `dict` |
| +helper is still used, in the templates, for the *literal* argument lists |
| +that call a partial — that is what it is for. The dividing line: data |
| +that came from the database travels as a struct; data that is a constant |
| +string in a template stays in the template. |
| + |
| +**The app registers no template funcs of its own.** `ui.Funcs()` is the |
| +whole FuncMap. Every date format, every "Draft · edited …" string, every |
| +pagination item is computed in Go, in a view model. This is not |
| +asceticism: a template func is app logic in a place tests reach last, and |
| +an example that needed five of them would be arguing against its own |
| +component library. |
| + |
| +## 9. `main.go`, and the truth about `Serve`'s database options |
| + |
| +`cmd/blog/main.go` is the scaffolded `main.go` (`cmd/rastrillo/new.go:93-137`) |
| +plus a `-db` flag and a real Ctx factory: |
| + |
| +```go |
| +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)) |
| + |
| +db, err := blog.Open(*dbPath) // pragmas, then the migration |
| +if err != nil { logger.Error("open database", "err", err); os.Exit(1) } |
| +defer db.Close() |
| + |
| +mux := gen.Router(func(*http.Request) *rastrillo.Ctx { |
| + return &rastrillo.Ctx{DB: db, Logger: logger, Actor: rastrillo.Actor{Human: true}} |
| +}) |
| +mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) |
| + |
| +err = rastrillo.Serve(rastrillo.Options{Mux: mux, Socket: *socket, Addr: *addr, Logger: logger}) |
| +``` |
| + |
| +A fresh `Ctx` per request, exactly as the scaffold's comment tells you to |
| +do "once it does" have per-request state (`new.go:115-118`). `Actor.Human` |
| +is `true` and `Actor.Name` empty — honest for an app with no auth |
| +(`ctx.go:11-14`), and the one line a real deployment would replace. |
| + |
| +**`Options.DBPath` and `Options.Migrations` are not set. They cannot be.** |
| +This was verified against `serve.go`, and the finding is real: |
| + |
| +- `Serve` opens the database itself when `DBPath` is set, keeps the |
| + handle in a local variable, and defers `db.Close()` (`serve.go:80-88`). |
| +- It never returns the handle, never writes it into an `Options` field, |
| + never passes it to a callback. `Options` has no `Ctx` factory field and |
| + no `DB` field (`serve.go:38-65`). |
| +- `openDB` — the function carrying the pragma-ordering fix the design |
| + doc §5 exists to centralise — is unexported (`serve.go:152`). The root |
| + package exports exactly `BuildVersion`, `Options`, `Serve`, `Ctx`, |
| + `Actor` and `Icon`. There is no accessor. |
| +- `gen.Router` takes the Ctx factory *before* `Serve` runs |
| + (`internal/generate/generate.go:214-223`), so even a handle produced |
| + later inside `Serve` could not be threaded back into it without a |
| + mutable box the framework does not provide. |
| + |
| +So: **any app whose actions use `Ctx.DB` — which is every app with a |
| +database, since `Ctx.DB` is the field the framework put there for it — |
| +must open its own `*sql.DB` and leave `DBPath` empty.** `Options.DBPath` |
| +and `Options.Migrations` are usable only by an app that never reads its |
| +own database from an action, which is not an app. |
| + |
| +This example therefore opens its own handle in `blog.Open`, reproducing |
| +`serve.go:152-169` by hand: `sql.Open("sqlite", path + |
| +"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")` — busy_timeout |
| +before journal_mode, the ordering titogo paid for — then |
| +`SetMaxOpenConns(1)`, then the migration. Hand-copying that DSN into an |
| +example app is precisely the hand-propagation the framework was built to |
| +end, which is what makes this finding worth writing down rather than |
| +shrugging at. It is friction finding **F4**, and the fix belongs in the |
| +next framework slice, not in this branch: either `Serve` returns (or |
| +accepts) the opened `*sql.DB`, or `Options` grows a |
| +`Ctx func(*http.Request) *rastrillo.Ctx` field and `Serve` builds the |
| +router itself. Both are small; neither is this example's to make. |
| + |
| +One dependency consequence: `examples/blog/go.mod` requires |
| +`modernc.org/sqlite` directly, because `blog.Open` blank-imports the |
| +driver itself. (Rastrillo's own `serve.go:27` blank-imports it too — the |
| +`// indirect` marker beside it in the root `go.mod` is an untidied file, |
| +not a statement about how the package uses it.) The example naming the |
| +driver is a visible symptom of the same finding: an app that could use |
| +`DBPath` would never import a SQLite driver at all. |
| + |
| +## 10. `blog.css`: the extension convention, demonstrated |
| + |
| +Roughly fifty lines in `static/blog.css`, loaded *after* `tokens.css`, and |
| +scoped to exactly two things the library deliberately does not ship: |
| + |
| +1. **Form controls** — `.blog-form` (the field stack), `.blog-form label`, |
| + `.blog-form input[type="text"]`, `.blog-form textarea`, |
| + `.blog-form__actions` (the button row), `.blog-error` (the validation |
| + message), `.blog-status` (the edit screen's pill-and-buttons row), |
| + `.blog-note` (the search-found-nothing paragraph), `.blog-footer`. |
| +2. **Prose measure** — `.blog-article` on the public post page: a |
| + `max-inline-size` around 34rem and paragraph rhythm, because a column |
| + of body text at the list card's width is unreadable. |
| + |
| +Two rules govern it, and both are testable: |
| + |
| +- **Every value comes from a token.** Colours are `var(--rst-text)`, |
| + `var(--rst-surface)`, `var(--rst-line-strong)`, `var(--rst-accent)`, |
| + `var(--rst-tone-negative-fg)`; spacing is `var(--rst-sp-3)`; radii are |
| + `var(--rst-radius-sm)`. No literal hex, no literal px spacing. That is |
| + the whole mechanism by which the forms track the dark theme without |
| + `blog.css` knowing dark exists — `tokens.css` redeclares every colour |
| + token three times (light, `prefers-color-scheme: dark`, |
| + `[data-theme="dark"]`), and anything built on `var()` follows for free. |
| +- **It never styles an `rst-` class.** No selector in the file contains |
| + `.rst-`. Buttons on the forms *use* `.rst-btn`/`.rst-btn--primary` in |
| + their markup — using a library class is the point of a library — but |
| + no rule in `blog.css` redefines one. An example that shipped |
| + `.rst-row { padding: … }` would be teaching every reader to fork the |
| + design system on day one. |
| + |
| +The focus ring is the one place worth naming explicitly: `tokens.css` |
| +scopes its `:focus-visible` rule to library containers |
| +(`:where(.rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, |
| +.rst-pagination)`), so nothing on a form screen inherits it — not the |
| +inputs, and not the `.rst-btn` buttons beside them. `blog.css` therefore |
| +gives `.blog-form :focus-visible` and `.blog-status :focus-visible` their |
| +own `outline: 2px solid var(--rst-accent); outline-offset: 2px` — same |
| +token, same shape, restated because the library's scope ends at its own |
| +containers. |
| +Discovering that gap by building a form is exactly what this example is |
| +for; it is friction finding F2's smaller half. |
| + |
| +## 11. Testing strategy |
| + |
| +Plain stdlib — `testing`, `net/http/httptest`, `strings` — mirroring |
| +`ui/ui_test.go` and `icons_test.go`. All tests live in |
| +`examples/blog/internal/blogtest/`, a directory containing only `_test.go` |
| +files. That is a valid Go package: `go test ./...` runs it and |
| +`go build ./...`/`go vet ./...` pass over it silently (verified). |
| + |
| +The tests import `blog/gen`, which imports the generated actions, which |
| +import `blog/internal/blog` — so they cannot be an *internal* test of |
| +`internal/blog`, which would be a cycle. An external test package |
| +(`package blog_test` inside `internal/blog/`) would break the cycle |
| +equally well; the separate directory is **chosen** over it because it |
| +keeps `internal/blog` a directory of production code only, and because |
| +the tests then see the app exactly as `main.go` does — through `gen`, |
| +with no package-local shortcut available even by accident. |
| + |
| +**The harness.** One helper builds a whole app per test: |
| + |
| +```go |
| +func newApp(t *testing.T) (http.Handler, *sql.DB) |
| +``` |
| + |
| +— `blog.Open(filepath.Join(t.TempDir(), "blog.db"))` for a fresh |
| +file-backed SQLite database per test (a file, not `:memory:`, because |
| +`SetMaxOpenConns(1)` plus WAL is the configuration being exercised), then |
| +`gen.Router` with a Ctx factory closing over it, exactly as `main.go` |
| +does. Requests go through `httptest.NewRecorder`. Seeding goes through |
| +`blog.Create`/`blog.SetPublished`, not raw SQL, so the tests exercise the |
| +store too. |
| + |
| +**Route-level tests**, one or more per action: |
| + |
| +- `GET /` — published posts only, newest first; drafts absent; the empty |
| + state when nothing is published; `?page=2` shows posts 11+. |
| +- **The catch-all's edges**: `GET /nonsense` is 404 (the index guard, not |
| + the homepage); `GET /admin/posts/` and `GET /posts/1/` are 404 too, with |
| + no redirect; `POST /nonsense` is **405**, not 404, because `GET /` |
| + matches the path but not the method (§6). |
| +- `GET /posts/{id}` — 200 for published; **404 for a draft**; 404 for a |
| + missing id; 404 for `/posts/abc`. |
| +- `GET /admin/posts` — drafts and published both listed; `?q=` filters by |
| + title and is case-insensitive; `?q=` with no match renders the plain |
| + note and *not* `rst-empty`; no posts at all renders `rst-empty` and |
| + *not* the note; `?page=2` pages, and `q` survives into every page link; |
| + the pagination strip is absent with ten posts and present with eleven. |
| + |
| + One escaping trap worth stating, because it costs an afternoon |
| + otherwise: `html/template` escapes `&` inside an attribute value, so the |
| + preserved-query assertion must look for |
| + `href="/admin/posts?q=go&page=2"`, not `…q=go&page=2`. The rendered |
| + page is correct either way — a browser unescapes it — but a test |
| + asserting the raw ampersand fails against working output. |
| +- `POST /admin/posts` — 303 with `Location: /admin/posts/{id}/edit` for |
| + the id actually created; the row then appears on the list; an empty |
| + title returns 400, renders the form again, and creates nothing. |
| +- `GET /admin/posts/{id}/edit` — 200 with the current values in the |
| + fields; the pill reads `Draft` then `Published` after publishing; 404 |
| + for a missing id. |
| +- `POST /admin/posts/{id}` — fields updated, `updated_at` moved, 303 back |
| + to the edit page; empty title is 400 and changes nothing. |
| +- `POST …/publish` and `…/unpublish` — the flag flips, 303 back to the |
| + edit page, and the post appears on / disappears from `GET /`. |
| +- `POST …/delete` — the row is gone, 303 to `/admin/posts`, and a |
| + subsequent `GET /posts/{id}` is 404. |
| + |
| +**One rendered-HTML sanity test per screen**, asserting the stock |
| +component actually reached the page — `rst-page-header` on all five, |
| +`rst-lbar` + `rst-search` + `rst-sr-only` + `rst-row` + `rst-pagination` |
| +on the admin list, `rst-status` with `data-tone="positive"` on a published |
| +post's edit screen, `rst-empty` on both empty states — plus a non-empty |
| +`<title>` on every screen. One further test asserts, in one place, that |
| +**all eight partial markers appear across the app's own screens**, using |
| +the same marker strings as `ui_test.go:615-624`. That is the acceptance |
| +criterion in §13 made executable — and because the strip is guarded |
| +(§7.1), that test must seed **eleven** posts before rendering the admin |
| +list, or `rst-pagination` legitimately never appears and the assertion |
| +fails on a correct app. |
| + |
| +**A zero-JS and self-containment sweep** over every rendered page, in the |
| +style of `ui_test.go`'s `TestRenderedPartialsAreSelfContained`: render |
| +every screen in both its populated and its empty state, and assert the |
| +HTML contains none of `<script`, `<iframe`, `onload=`, `onclick=`, |
| +`http://`, `https://`, `//cdn`, `srcset`, `@import`; and that every |
| +`href="` and `action="` value starts with `/`, `?` or `#`. The layout's |
| +two `<link rel="stylesheet">` tags are the only asset references in the |
| +app and both are same-origin `/static/…` paths, so unlike the library's |
| +own sweep this one permits `<link ` and checks the target instead. The |
| +same self-containment check runs over `static/blog.css` — no `@import`, |
| +no `url(`, no `http` — and one further test asserts `blog.css` contains |
| +no `.rst-` selector, enforcing §10's second rule. |
| + |
| +**Generator freshness is not a Go test.** It would like to be, and it |
| +cannot be: `internal/generate` is internal to the rastrillo module and |
| +unimportable from `blog`, and the CLI always writes to `<dir>/gen` and |
| +reads the module path from `<dir>/go.mod`, so it cannot be pointed at a |
| +scratch output directory from inside a test. So it is a documented |
| +procedure in the example's README (development section) rather than a |
| +`_test.go` file: |
| + |
| +``` |
| +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 regeneration hygiene for CI and for whoever adds an action next — the |
| +everyday guard against a stale `gen/` is the route-level tests above, |
| +which run through the committed router and fail immediately if it no |
| +longer matches the actions. |
| + |
| +## 12. Friction log — what building this revealed |
| + |
| +Recorded, not fixed. Every one of these is a note in the example's |
| +`README.md` too, so the finding lives where the next person reads. |
| + |
| +**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 (`list-row-action.html:13-29`). Status goes into `Sub` as |
| +prose. It works and it is accessible; it is also why titogo's own rows |
| +carry a `status-pill` inline. The row partial wants a `Status` |
| +`Tone`/`Label` pair that renders `status-pill` in the row's right-hand |
| +group, beside the action pill. |
| + |
| +**F2 — no form partials, and the focus ring stops at the library's |
| +edge.** The `field`/`field-textarea` family is deferred by the library |
| +spec's §3, so every form here is hand-rolled and `blog.css` re-states the |
| +focus outline for controls outside `tokens.css`'s `:where(...)` scope. |
| +Roughly half of `blog.css` would disappear the day form partials land. |
| + |
| +**F3 — no `dropdown`, so "show drafts only" is missing.** Also deferred |
| +(library spec §3). The obvious next control on this screen — a status |
| +filter — has nowhere to attach, and 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`.** Verified in §9: `Serve` opens the handle, defers its |
| +`Close`, and never exposes it (`serve.go:80-88`); `openDB` is unexported |
| +(`serve.go:152`). The example hand-copies the pragma DSN — the exact |
| +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` |
| +(`generate.go:33,70-73,174-197`), and separately skips any file whose base |
| +name starts with `_` (`generate.go:67-69`) — 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. |
| +Correct behaviour, undocumented convention — it belongs in the repo |
| +README and in `rastrillo new`'s scaffold comments. |
| + |
| +**F6 — `actions/index.GET.go` is a catch-all.** `routeFor` maps `index` |
| +to `GET /` (`generate.go:119-121`) 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. The design doc's actor/session layers exist on paper only (repo |
| +README, "Not built yet"), so the example runs locally and says so |
| +loudly in its README rather than inventing a password field. |
| + |
| +**F8 — static serving is relative to the working directory.** The |
| +scaffolded line is `http.FileServer(http.Dir("static"))` |
| +(`new.go:125`), so the binary must be run from the app root or the |
| +stylesheets 404 and every screen renders unstyled. Parked during the |
| +library review; confirmed here as a thing a reader hits on their first |
| +run, which is why the README documents `cd examples/blog && ./blog` |
| +explicitly. |
| + |
| +## 13. Acceptance criteria |
| + |
| +- **All eight partials are used by the running app**, each in its |
| + intended role, proven by the cross-screen marker test in §11 — not by a |
| + fixture. |
| +- **The screens look coherent in light and dark.** `tokens.css` ships |
| + unedited; `blog.css` contains no literal colour and no `.rst-` |
| + selector, so both themes are inherited whole. Verified by eye in both |
| + `prefers-color-scheme` states and with `data-theme` forced each way, |
| + and by the `blog.css` tests in §11. |
| +- **Every flow works with JavaScript disabled, end to end**: 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. Links and |
| + form POSTs only, 303 after every mutation. The zero-JS sweep (§11) |
| + enforces that no page contains a script or an off-origin reference. |
| +- **The friction log is captured** in §12 and in the example's README, |
| + and **nothing outside `examples/blog/` changed** on the `example-blog` |
| + branch — verifiable with |
| + `git diff --name-only ui-component-library...example-blog`, whose every |
| + line starts `examples/blog/`. |
| +- **`go build ./cmd/blog` and `go test ./...` pass** in |
| + `examples/blog/`, and the committed `gen/` tree matches what the |
| + generator produces, checked once by the README's regeneration procedure |
| + (§11) rather than by a Go test. |
| +- **The README answers three questions**: what this is, how to run it |
| + (including the run-from-the-app-root requirement, F8, and that |
| + `go generate` is not needed because `gen/` is committed as |
| + `helloworld`'s is), and what building it revealed — plus a short |
| + development section carrying the regeneration procedure. |
| + |
| +## 14. Assumptions log |
| + |
| +Decisions made while the user was away. Each is a real choice with a |
| +plausible alternative; each is listed so it can be reversed in one |
| +reading. |
| + |
| +1. **Single-user blog, no auth** — over a multi-user example with a fake |
| + login. The auth layers do not exist yet; faking one would be the |
| + example lying about the framework (§3, F7). |
| +2. **Status as `Sub` text** — over adding a status slot to |
| + `list-row-action` in this branch. Recording friction beats patching |
| + the library from an example (§7.1, F1). |
| +3. **Filter-miss is a plain paragraph, not `empty-state`** — following |
| + the library spec's own §10 titogo observation. |
| +4. **Struct view models, `dict` only for template-literal argument |
| + lists** — over `dict` everywhere. Struct data fails loudly on a |
| + mistyped field; map data renders empty (library spec §9). |
| +5. **Clone-per-page at startup, every page file defining `content`** — |
| + over distinct define names per page. One layout, one constant |
| + `{{template "content" .}}`, no per-request cloning (§8). |
| +6. **No app-owned template funcs** — all formatting in Go view models. |
| +7. **Plain-text bodies split on blank lines** — over a Markdown |
| + dependency (§3). |
| +8. **The app opens its own `*sql.DB`; `Options.DBPath` unused** — forced |
| + by `serve.go`, not chosen (§9, F4). The alternative — actions opening |
| + their own connections — would be worse in every way. |
| +9. **Shared code in `internal/blog`** — forced by the generator's copy |
| + and `_`-prefix skip rules (§4, F5). **Tests in `internal/blogtest`** — |
| + *chosen*, not forced: an external `package blog_test` inside |
| + `internal/blog` would break the import cycle too, but a separate |
| + directory keeps `internal/blog` production-code-only and makes the |
| + tests see the app the way `main.go` does, through `gen` (§11). |
| +10. **`index.GET.go` guards `r.URL.Path != "/"`** — over letting the |
| + index answer every unmatched URL (§6, F6). |
| +11. **Page size 10, `?page=N`, `?q=` over titles only** — the smallest |
| + parameters that produce a real page strip and exercise the search |
| + bar's plumbing. (The `Gap` item kind needs 71 posts to appear, so the |
| + app builds it correctly but never renders it; the library's own |
| + fixtures cover it — §7.1.) |
| +12. **303 See Other after every mutation**, and validation failures |
| + re-rendering with 400 — over 302, and over redirecting a failed |
| + submission and losing what the writer typed. |
| +13. **`Lead`/`LeadInitial` deliberately unused** — tinting an |
| + `aria-hidden` circle by publish state is colour-only status (§7.1). |
| +14. **A two-link footer as the app's only chrome** — over a nav bar, |
| + which would need a skip link and would put structural CSS in |
| + `blog.css` that `tokens.css` explicitly does not cover (§8). |
| +15. **POST bodies bounded at 1 MiB via `http.MaxBytesReader`** before |
| + `ParseForm`, with a `ParseForm` error answered 400 — over trusting |
| + `ParseForm`'s defaults on a route anyone can reach (§8). |
| +16. **Generator freshness is a README procedure, not a Go test** — |
| + `internal/generate` is unimportable from the example and the CLI |
| + cannot be pointed at a scratch output directory, so the check moves |
| + to CI/regeneration hygiene and the route tests carry the everyday |
| + guard (§11). |