oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev 84e06fad8975c019d441916a1743f8b8d4a8e520 parent c4389b8
5 files changed, +222 −5
  • internal/docs/app.go +9 −0
  • internal/docs/handlers.go +78 −3
  • internal/docs/list_test.go +38 −0
  • internal/docs/render.go +13 −2
  • internal/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}}