| 1 | # manifest/ — the declarative path |
| 2 | |
| 3 | Manifest-driven and code-driven are equal, optional paths: declare the |
| 4 | resources that are pure CRUD, hand-write the ones that aren't, per |
| 5 | resource, in one app. One TOML file here declares a whole screen set — |
| 6 | List → Show → Edit/New plus the confirm-page delete flow — generated |
| 7 | as real committed code under gen/ by `rastrillo generate`: |
| 8 | |
| 9 | name = "ticket_types" |
| 10 | route = "/admin/ticket_types" |
| 11 | |
| 12 | [list] |
| 13 | columns = [{ field = "Name", kind = "text" }, { field = "Price", kind = "money" }] |
| 14 | search = true |
| 15 | |
| 16 | [form] |
| 17 | basics = [{ name = "Name", required = true }, { name = "Price", kind = "money" }] |
| 18 | |
| 19 | The vocabulary covers one flat table per resource, three field kinds, |
| 20 | no relations. Add `scope = "user"` and every generated query is |
| 21 | owner-filtered by the session subject — someone else's row answers |
| 22 | 404 — for resources a user owns; mount those routes behind |
| 23 | `sessions.Require`. Add `store = "mergeable"` and the resource lives as |
| 24 | event-log streams (tombstone deletes, derive-on-read) instead of a |
| 25 | sqlc table — it needs no sqlc tool. Relations or a custom flow: |
| 26 | hand-write it. |
| 27 | |
| 28 | Adding the first manifest to this app: |
| 29 | |
| 30 | 1. `go get -tool github.com/sqlc-dev/sqlc/cmd/sqlc` (once — the |
| 31 | generated store is sqlc input). |
| 32 | 2. `rastrillo generate` writes gen/ — committed, never hand-edited. |
| 33 | 3. Embed the generated templates. This file goes at the MODULE ROOT, |
| 34 | not beside the hand templates: a go:embed pattern cannot contain |
| 35 | "..", and gen/templates is not under internal/slides. Call it |
| 36 | genassets.go — |
| 37 | |
| 38 | package slidesassets |
| 39 | |
| 40 | import "embed" |
| 41 | |
| 42 | //go:embed gen/templates |
| 43 | var GenTemplatesFS embed.FS |
| 44 | |
| 45 | `rastrillo generate` only ever clears and rewrites gen/actions/, |
| 46 | never a file sitting beside gen/, so this survives regeneration. |
| 47 | |
| 48 | 4. Write the Ctx.Render seam. A generated action cannot call an |
| 49 | app-private helper, so it calls Ctx.Render with a page name that is |
| 50 | always "<resource>/list", "<resource>/show" or "<resource>/form". |
| 51 | This is the whole adapter — internal/slides/genrender.go: |
| 52 | |
| 53 | package slides |
| 54 | |
| 55 | import ( |
| 56 | "bytes" |
| 57 | "fmt" |
| 58 | "html/template" |
| 59 | "io/fs" |
| 60 | "net/http" |
| 61 | "path" |
| 62 | "strings" |
| 63 | |
| 64 | slidesassets "slides" |
| 65 | "slides/gen/locales" |
| 66 | |
| 67 | "amadan.net/rastrillo/rastrillo" |
| 68 | "amadan.net/rastrillo/rastrillo/ui" |
| 69 | ) |
| 70 | |
| 71 | // One template per generated screen, keyed "<resource>/<page>". |
| 72 | var genPages = buildGenPages() |
| 73 | |
| 74 | func buildGenPages() map[string]*template.Template { |
| 75 | sub, err := fs.Sub(slidesassets.GenTemplatesFS, "gen/templates") |
| 76 | if err != nil { |
| 77 | panic(err) // embedded at compile time |
| 78 | } |
| 79 | dirs, err := fs.ReadDir(sub, ".") |
| 80 | if err != nil { |
| 81 | panic(err) |
| 82 | } |
| 83 | out := map[string]*template.Template{} |
| 84 | for _, d := range dirs { |
| 85 | if !d.IsDir() { |
| 86 | continue |
| 87 | } |
| 88 | files, err := fs.Glob(sub, d.Name()+"/*.html") |
| 89 | if err != nil { |
| 90 | panic(err) |
| 91 | } |
| 92 | for _, f := range files { |
| 93 | base := template.Must(template.New("layout"). |
| 94 | Funcs(ui.Funcs(ui.WithT(genT))). |
| 95 | Funcs(template.FuncMap{"asset": assets.Path}). |
| 96 | ParseFS(ui.Templates(), "*.html")) |
| 97 | base = template.Must(base.ParseFS(appFS, "templates/layout.html")) |
| 98 | key := d.Name() + "/" + strings.TrimSuffix(path.Base(f), ".html") |
| 99 | out[key] = template.Must(base.ParseFS(sub, f)) |
| 100 | } |
| 101 | } |
| 102 | return out |
| 103 | } |
| 104 | |
| 105 | // The generated templates call (T "resource.<name>.…"); those keys |
| 106 | // live in gen/locales, not in ui's default catalog. A missing key |
| 107 | // renders as itself, so a typo shows on the page instead of |
| 108 | // blanking a sentence. |
| 109 | func genT(key string, args ...any) string { |
| 110 | if v, ok := locales.BaseCatalog[key]; ok { |
| 111 | return v |
| 112 | } |
| 113 | return key |
| 114 | } |
| 115 | |
| 116 | func GenRender(ctx *rastrillo.Ctx, w http.ResponseWriter, name string, status int, data any) { |
| 117 | t, ok := genPages[name] |
| 118 | if !ok { |
| 119 | genFail(ctx, w, "rendering "+name, fmt.Errorf("no such page template")) |
| 120 | return |
| 121 | } |
| 122 | var buf bytes.Buffer |
| 123 | if err := t.ExecuteTemplate(&buf, "layout", data); err != nil { |
| 124 | genFail(ctx, w, "rendering "+name, err) |
| 125 | return |
| 126 | } |
| 127 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 128 | w.WriteHeader(status) |
| 129 | buf.WriteTo(w) |
| 130 | } |
| 131 | |
| 132 | func genFail(ctx *rastrillo.Ctx, w http.ResponseWriter, what string, err error) { |
| 133 | if ctx != nil && ctx.Logger != nil { |
| 134 | ctx.Logger.Error("slides: "+what, "err", err) |
| 135 | } |
| 136 | http.Error(w, "something went wrong", http.StatusInternalServerError) |
| 137 | } |
| 138 | |
| 139 | assets and appFS are render.go's, already in this package. The |
| 140 | generated screens go through the same layout.html the hand pages |
| 141 | use, because a generated content template defines "content" exactly |
| 142 | as a hand one does. When a generated screen needs this request's |
| 143 | flash or signed-in state, make GenRender a func(r) returning a |
| 144 | rastrillo.RenderFunc closed over r and call it from the ctx factory |
| 145 | — RenderFunc carries no *http.Request of its own. |
| 146 | |
| 147 | 5. Apply the generated store's migrations at boot, or the table the |
| 148 | screens read does not exist. In internal/slides/migrations.go: |
| 149 | |
| 150 | import ( |
| 151 | "fmt" |
| 152 | |
| 153 | bookmarksstore "slides/gen/store/bookmarks" |
| 154 | ) |
| 155 | |
| 156 | func genSchemaSet() *migrate.Set { |
| 157 | s := new(migrate.Set) |
| 158 | for i, stmt := range bookmarksstore.Migrations { |
| 159 | s.Add(migrate.Migration{ID: fmt.Sprintf("bookmarks_gen/%04d_init", i+1), SQL: stmt}) |
| 160 | } |
| 161 | return s |
| 162 | } |
| 163 | |
| 164 | var genSchema = genSchemaSet() |
| 165 | |
| 166 | var BootSchema = migrate.Merge(genSchema, Schema) |
| 167 | |
| 168 | genSchema goes in BootSchema and never in Schema: Schema is what |
| 169 | `rastrillo migration check` diffs against Models, and a generated |
| 170 | table in it reads as one Models forgot. |
| 171 | |
| 172 | The ledger trap, stated because it bites later rather than now: the |
| 173 | generated Migrations are rewritten verbatim from the TOML on every |
| 174 | `rastrillo generate`, and migrate.Apply treats applied SQL as |
| 175 | immutable. Reshape a manifest after the app has booted once and the |
| 176 | next boot refuses rather than silently reapplying it — |
| 177 | `rastrillo migration baseline` is the recovery. |
| 178 | |
| 179 | 6. Mount the generated router beside the chi router in |
| 180 | internal/slides/app.go, and wire its Ctx: |
| 181 | |
| 182 | gmux := gen.Router(func(*http.Request) *rastrillo.Ctx { |
| 183 | writer, _ := d.G.DB() |
| 184 | return &rastrillo.Ctx{DB: writer, Logger: logger, |
| 185 | Actor: rastrillo.Actor{Human: true}, Render: GenRender} |
| 186 | }) |
| 187 | mux.Handle("/admin/", gmux) |
| 188 | |
| 189 | 7. Add `rastrillo generate --check` to the Makefile's ci target. |
| 190 | |
| 191 | Drop to a .go manifest in this directory (package manifest, a |
| 192 | `var X = rastrillo.Resource{...}`) the moment you need a function |
| 193 | value — a custom Column.Render, say. To take any single generated |
| 194 | screen over by hand, write your own action file at the same computed |
| 195 | path under actions/ — the generator skips that one from then on. |
| 196 | |