app: create and read a document
POST redirects 303 so a refresh does not repost; both work with no JavaScript. The read view takes its text from the CRDT through wazero rather than a column, and its canvas opts out of the theme's scheme so paper stays paper in the dark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
5 files changed,
+222
−5
internal/docs/app.go+9 −0internal/docs/handlers.go+78 −3internal/docs/list_test.go+38 −0internal/docs/render.go+13 −2internal/docs/views/show.html+84 −0
diff --git a/internal/docs/app.go b/internal/docs/app.go| index acf1921..5619382 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -107,6 +107,15 @@ func New(cfg Config) (*App, error) { |
| r := chi.NewRouter() |
| r.Get("/", h.list) |
| + // csrf.Protect(origin) is not mounted here — rastrillo SKILL.md §5 |
| + // asks for it app-wide, but Protect needs a fixed origin and this |
| + // app has none yet (no sessions, no Resolve-derived origin: both |
| + // land with Task 6's dev-run wiring). POST /documents is the |
| + // app's only mutating route so far; it gets the same origin-check |
| + // protection the moment Task 6 gives this app an origin to check |
| + // against. |
| + r.Post("/documents", h.create) |
| + r.Get("/d/{sqid}", h.show) |
| r.Handle("/static/*", render.assets.Handler()) |
| mux := http.NewServeMux() |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index fec8348..b29f90c 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -1,10 +1,14 @@ |
| package docs |
| import ( |
| + "errors" |
| "log/slog" |
| "net/http" |
| + "strings" |
| "time" |
| + "gorm.io/gorm" |
| + |
| "github.com/carlosframework/rastrillo" |
| ) |
| @@ -37,9 +41,8 @@ type docGroup struct { |
| } |
| // docRow is one document's row. Href points at the read-only view |
| -// Task 4 wires at GET /d/{sqid}: this task does not add that route, |
| -// only the link the later task's route answers, so a click 404s until |
| -// then. |
| +// (Task 4's GET /d/{sqid}, show below) — an ordinary link, so a click |
| +// works with no JavaScript. |
| type docRow struct { |
| Href string |
| Title string |
| @@ -79,6 +82,78 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| +// create serves POST /documents: a plain form post, no JavaScript |
| +// required (spec §6.2). It reads the permitted field by name — |
| +// rastrillo SKILL.md §4's "never bind a request body onto a GORM |
| +// model" — and Store.Create builds the row itself, so there is no |
| +// mass-assignment surface here at all. On success it redirects with |
| +// 303 See Other rather than the 302 a naive handler reaches for: a |
| +// visitor reloading the response, or a browser retrying the |
| +// navigation, replays the GET the redirect points at, never the POST |
| +// that created the document. |
| +func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| + title := strings.TrimSpace(r.PostFormValue("title")) |
| + if title == "" { |
| + title = rastrillo.T(r, "docs.document.untitled") |
| + } |
| + |
| + doc, err := h.store.Create(r.Context(), title) |
| + if err != nil { |
| + h.logger.Error("docs: create document", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther) |
| +} |
| + |
| +// showPage is GET /d/{sqid}'s view data (spec §6.1/§6.2). Title is the |
| +// document's own title — data, not authored copy, printed as-is the |
| +// same way docRow.Title is on the list. Text is Store.Text's |
| +// plain-text replay of the CRDT through wazero, never a database |
| +// column; a document with no updates yet (a document just created) |
| +// replays to "", which views/show.html must render as an ordinary |
| +// empty canvas rather than erroring. |
| +type showPage struct { |
| + Title string |
| + Text string |
| +} |
| + |
| +// show serves GET /d/{sqid}: the document read-only, its title and |
| +// its CRDT text inside a page-shaped canvas (views/show.html). An |
| +// unknown or malformed sqid 404s — spec §4.2's contract that a |
| +// document you cannot see is indistinguishable from one that does not |
| +// exist. There is no signed-in viewer yet (identity is Task 5), so |
| +// today "cannot see" only ever means "no such row"; the 404 is the |
| +// same either way, and nothing here will need to change once scoping |
| +// lands — it only gains another way to reach this same branch. |
| +func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| + sqid := r.PathValue("sqid") |
| + |
| + doc, err := h.store.Get(r.Context(), sqid) |
| + if err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound) |
| + return |
| + } |
| + h.logger.Error("docs: get document", "sqid", sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + text, err := h.store.Text(r.Context(), doc) |
| + if err != nil { |
| + h.logger.Error("docs: document text", "sqid", sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + data := showPage{Title: doc.Title, Text: text} |
| + if err := h.render.page(w, r, "show", data); err != nil { |
| + h.logger.Error("docs: render show", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + } |
| +} |
| + |
| // groupByRecency buckets docs — already newest-first, per Store.List |
| // — into recency groups without re-sorting: consecutive documents |
| // that share a bucket join the same group, and a new bucket starts a |
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go| index 21def7a..dfc5d1a 100644 |
| --- a/internal/docs/list_test.go |
| +++ b/internal/docs/list_test.go |
| @@ -68,3 +68,41 @@ func TestEmptyListSaysSo(t *testing.T) { |
| t.Error("empty list rendered almost nothing — there should be an empty state") |
| } |
| } |
| + |
| +func TestCreateThenRead(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + |
| + form := strings.NewReader("title=Minutes") |
| + req := httptest.NewRequest(http.MethodPost, "/documents", form) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /documents: status %d, want 303", rec.Code) |
| + } |
| + loc := rec.Header().Get("Location") |
| + if !strings.HasPrefix(loc, "/d/") { |
| + t.Fatalf("Location = %q, want /d/<sqid>", loc) |
| + } |
| + |
| + req2 := httptest.NewRequest(http.MethodGet, loc, nil) |
| + rec2 := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec2, req2) |
| + if rec2.Code != http.StatusOK { |
| + t.Fatalf("GET %s: status %d", loc, rec2.Code) |
| + } |
| + if !strings.Contains(rec2.Body.String(), "Minutes") { |
| + t.Error("the document's title is not on its own page") |
| + } |
| +} |
| + |
| +func TestUnknownDocument404s(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code) |
| + } |
| +} |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index 8883a29..45c0c51 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -51,6 +51,17 @@ var Catalog = rastrillo.Catalog{ |
| // §7's view.Fail/ErrorPage wiring is later work). Still a catalog |
| // key, never a literal, even though it is plain text today. |
| "docs.error.generic": "Something went wrong.", |
| + |
| + // docs.error.notfound answers GET /d/{sqid} for an unknown sqid — |
| + // spec §4.2's 404-not-403 contract. Same "nowhere styled to land |
| + // yet" caveat as docs.error.generic above. |
| + "docs.error.notfound": "Not found.", |
| + |
| + // docs.document.untitled is POST /documents' fallback title when |
| + // the submitted one is empty or all whitespace — boilerplate copy |
| + // this app writes, not the caller's own input, so it is a catalog |
| + // key rather than a hardcoded default. |
| + "docs.document.untitled": "Untitled document", |
| } |
| // Locales and DefaultLocale declare this app's locale set for |
| @@ -73,10 +84,10 @@ const DefaultLocale = "en" |
| //go:embed views/*.html |
| var viewsFS embed.FS |
| -// pageTemplates maps a page name to its file under views/. Task 4 |
| -// adds "show" here. |
| +// pageTemplates maps a page name to its file under views/. |
| var pageTemplates = map[string]string{ |
| "list": "views/list.html", |
| + "show": "views/show.html", |
| } |
| // renderer owns the app's parsed templates and its fingerprinted |
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html| new file mode 100644 |
| index 0000000..d7584e5 |
| --- /dev/null |
| +++ b/internal/docs/views/show.html |
| @@ -0,0 +1,84 @@ |
| +{{/* show — GET /d/{sqid}, the document read view (spec §6.1/§6.2). |
| + handlers.go's show builds the view data: Title is the document's |
| + own title, printed as-is the same way list.html's row titles are |
| + (data, not authored copy — not resolved through rastrillo.T); |
| + Text is Store.Text's plain-text replay of the CRDT through wazero, |
| + never a database column, and is empty for a document with no |
| + updates yet — an ordinary empty canvas below, not an error. |
| + |
| + [docs-canvas]/[docs-page]/[docs-text] are this app's own |
| + vocabulary (SKILL.md §7: "ui is rastrillo's vocabulary, not |
| + yours"), not rastrillo's, so they are not spelled rst-*, same |
| + reasoning as list.html's [docs-thumb]. |
| + |
| + The canvas opts out of the surrounding theme on purpose (spec |
| + §10.2, the brief's canvas constraint): a document is a piece of |
| + paper, and paper does not follow the reader's dark-mode |
| + preference the way the chrome around it does. color-scheme: |
| + light plus literal colours below — never var(--rst-surface) or |
| + var(--rst-text), which would make the "page" repaint itself dark |
| + along with everything else on screen. |
| + |
| + [docs-page] is deliberately one page-shaped container with no |
| + page-break logic inside it yet: the brief is explicit that |
| + pagination arrives with a later plan's layout engine, and this |
| + shape (one box, its own padding, ready to hold more than one |
| + sibling) is what lets that land without restructuring this |
| + markup — a later version of this template repeats [docs-page] |
| + once per page rather than replacing it with something else. |
| + |
| + Writes the bare rst-* attribute grammar throughout — see |
| + list.html's own comment on why, and |
| + render_test.go's TestOurTemplatesUseTheAttributeGrammar, which |
| + holds it against this file too. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}}<style> |
| + /* [docs-canvas] is the tray the page sits in — it is chrome around |
| + paper, so it stays close to the surrounding theme's tone, but it |
| + still opts out of the scheme (color-scheme: light) because its |
| + own background below is a literal, not a token: a scheme without |
| + a matching literal is what makes dark-mode form controls repaint |
| + themselves onto a light strip. */ |
| + [docs-canvas] { |
| + color-scheme: light; |
| + display: flex; |
| + justify-content: center; |
| + padding: var(--rst-sp-6) var(--rst-sp-4); |
| + background: #d8d8dc; |
| + } |
| + /* [docs-page]: white, centred, with margins — literal colours per |
| + spec §10.2, not var(--rst-surface)/var(--rst-text): a document |
| + stored as dark paper by its author must still read as dark paper |
| + to a reader in light mode, and vice versa, so nothing here may |
| + depend on which theme is currently active. Sized to a US Letter |
| + page's proportions at 96dpi; a real page-geometry constant |
| + arrives with the layout engine, not this task. */ |
| + [docs-page] { |
| + box-sizing: border-box; |
| + inline-size: min(100%, 8.5in); |
| + min-block-size: 11in; |
| + margin-block-end: var(--rst-sp-6); |
| + padding: 1in; |
| + background: #ffffff; |
| + color: #1a1a1a; |
| + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 12px rgba(0, 0, 0, 0.12); |
| + } |
| + [docs-text] { |
| + white-space: pre-wrap; |
| + overflow-wrap: break-word; |
| + font-family: Georgia, "Times New Roman", serif; |
| + font-size: 12pt; |
| + line-height: 1.5; |
| + } |
| +</style> |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Title}}</h1> |
| + </div> |
| +</header> |
| +<div docs-canvas> |
| + <div docs-page> |
| + <div docs-text>{{.Text}}</div> |
| + </div> |
| +</div> |
| +{{end}} |