plum-cms 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +64 -0
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/controllers/plum/cp/entries_controller.rb +88 -5
  6. data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
  7. data/app/controllers/plum/form_submissions_controller.rb +13 -0
  8. data/app/controllers/plum/pages_controller.rb +8 -0
  9. data/app/controllers/plum/theme_assets_controller.rb +2 -0
  10. data/app/javascript/controllers/plum/write_controller.js +174 -0
  11. data/app/models/plum/asset.rb +1 -0
  12. data/app/models/plum/content_type.rb +1 -0
  13. data/app/models/plum/entry.rb +57 -0
  14. data/app/models/plum/entry_term.rb +1 -0
  15. data/app/models/plum/form_definition.rb +1 -0
  16. data/app/models/plum/global.rb +1 -0
  17. data/app/models/plum/nav_item.rb +1 -0
  18. data/app/models/plum/nav_menu.rb +1 -0
  19. data/app/models/plum/site.rb +2 -0
  20. data/app/models/plum/site_setting.rb +1 -0
  21. data/app/models/plum/static_cache_invalidation.rb +30 -0
  22. data/app/models/plum/taxonomy.rb +1 -0
  23. data/app/models/plum/term.rb +1 -0
  24. data/app/services/plum/config_sync.rb +252 -0
  25. data/app/services/plum/draft_diff.rb +177 -0
  26. data/app/services/plum/form_renderer.rb +12 -1
  27. data/app/services/plum/liquid_context.rb +0 -2
  28. data/app/views/layouts/plum/write.html.erb +140 -0
  29. data/app/views/plum/cp/dashboard/show.html.erb +10 -3
  30. data/app/views/plum/cp/entries/_form.html.erb +2 -2
  31. data/app/views/plum/cp/entries/diff.html.erb +40 -0
  32. data/app/views/plum/cp/entries/edit.html.erb +24 -0
  33. data/app/views/plum/cp/entries/index.html.erb +3 -0
  34. data/app/views/plum/cp/entries/write.html.erb +60 -0
  35. data/config/plum_routes.rb +5 -0
  36. data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
  37. data/docs/config-as-code.md +103 -0
  38. data/docs/plum-cli.md +356 -0
  39. data/docs/static-caching.md +163 -0
  40. data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
  41. data/lib/plum/configuration.rb +12 -1
  42. data/lib/plum/engine.rb +7 -0
  43. data/lib/plum/static_cache/middleware.rb +61 -0
  44. data/lib/plum/static_cache.rb +103 -0
  45. data/lib/plum/version.rb +1 -1
  46. data/lib/tasks/plum_config.rake +48 -0
  47. data/lib/tasks/plum_portability.rake +54 -29
  48. data/lib/tasks/plum_styles.rake +14 -9
  49. metadata +21 -3
data/docs/plum-cli.md ADDED
@@ -0,0 +1,356 @@
1
+ # The Plum CLI
2
+
3
+ `cli/` (Go, `github.com/tableneeds/Plum/cli`) is the Plum command line. It
4
+ wraps the rake tasks that ship with the engine — `plum:site:export`,
5
+ `plum:site:replace`, `plum:config:sync`, `plum:backup:create`, and so on —
6
+ over a transport of your choosing. **The CLI adds transport and ergonomics,
7
+ never behavior.** Anything it does, you could also do by hand with `ssh` and
8
+ `bin/rails`; it just saves you from typing the same incantation every time
9
+ and from having to remember which task does what.
10
+
11
+ Status: early. Every command has been exercised end-to-end against a local
12
+ Plum install (`via: ssh` with `host: local`), and `via: once` has been
13
+ verified against a live Once deployment on a real VPS (`plum connect`,
14
+ tooling detection, and `plum logs` included). `via: kamal`'s
15
+ argument-building is unit-tested against fake binaries and smoke-tested
16
+ against the real `kamal` executable's argument parser, but hasn't been run
17
+ against a real Kamal fleet yet.
18
+
19
+ ## Look and feel
20
+
21
+ On a terminal, the CLI speaks heroku-style: `▸` step headers, animated
22
+ spinners that settle into timed `✓ Downloading archive (700ms)` lines,
23
+ remote rake output framed behind a dim `│` gutter (so the server's chatter
24
+ reads as quoted material, not the CLI's own voice), and arrow-key prompts
25
+ in `plum connect`. The accent color is, naturally, plum — adaptive to
26
+ light and dark backgrounds.
27
+
28
+ All of it degrades automatically: pipe any command, run it in CI, set
29
+ `NO_COLOR`, or use `TERM=dumb`, and you get plain sequential text with the
30
+ classic `Prompt [default]:` stdin reads — scripted answers and log parsing
31
+ keep working unchanged.
32
+
33
+ ## Install
34
+
35
+ No published binary yet — build from source:
36
+
37
+ ```bash
38
+ cd cli
39
+ go build -o plum .
40
+ # put ./plum somewhere on your PATH, e.g.:
41
+ mv plum ~/.local/bin/plum
42
+ ```
43
+
44
+ ## The built-in tutorial
45
+
46
+ ```
47
+ plum tutorial
48
+ ```
49
+
50
+ An interactive, full-screen tour of Plum and the CLI — eight short
51
+ chapters covering the content model, themes, writing mode and drafts,
52
+ static caching, config as code, and every CLI command, rendered as styled
53
+ markdown right in your terminal (bubbletea + glamour). Chapters slide in
54
+ on spring physics (harmonica), a gradient progress bar tracks where you
55
+ are, the CLI chapters have **live demos** — press `d` and watch
56
+ `plum pull` or a connect health check type itself out, spinners and all —
57
+ and the tour ends with a three-question quiz. Harmonica does real work:
58
+ a splash intro drops a bouncing plum across the springing wordmark,
59
+ wrong quiz answers shake on an underdamped spring, and finishing the
60
+ quiz bursts projectile-physics confetti.
61
+
62
+ It's also a game. Progress persists (chapters read, demos watched, quiz
63
+ passed) so the tour resumes where you left off; everything earns XP with
64
+ plum-themed ranks in the header (Seedling through Orchardist). CLI
65
+ chapters have typing challenges — press `t` and type the real command
66
+ with per-character feedback; success rolls the demo. And the title
67
+ screen hides **Plum Drop** (press `p`): catch falling plums with an
68
+ arrow-key basket, chase combos, and defend a persisted high score — it's
69
+ endless, so play until you've had enough. Navigate with ←/→, scroll
70
+ with ↑/↓, jump with 1–9, quit with q. Piped, it prints the chapters as
71
+ plain markdown.
72
+
73
+ And because it's bubbletea, it also serves over SSH (charm's wish):
74
+
75
+ ```
76
+ plum tutorial --serve # listens on :2222
77
+ plum tutorial --serve :2345 # or wherever
78
+ ```
79
+
80
+ Anyone who can reach the port gets the full animated tour with nothing
81
+ installed — `ssh -p 2222 your-server` and they're in. Sessions run only
82
+ the tutorial program (no shell, no exec); any or no public key is
83
+ accepted since the content is public. The host key persists at
84
+ `~/.config/plum/tutorial_host_ed25519` so returning visitors don't get
85
+ host-key warnings.
86
+
87
+ ## Two layers of configuration
88
+
89
+ This mirrors the Firebase CLI's model deliberately, because it solves the
90
+ same two problems: "how does this directory reach its server" and "how do I
91
+ work with a project without cd-ing into its directory first."
92
+
93
+ 1. **`plum.yml`** — lives in a site's own repo, defines its **remotes**
94
+ (servers). This is the source of truth for how to reach that one site.
95
+ 2. **The global project registry** (`~/.config/plum/config.yml`, or
96
+ `$XDG_CONFIG_HOME/plum/config.yml`) — lives on your dev machine, not in
97
+ any repo. It's just a name → directory index plus one "active project"
98
+ pointer, so you can run `plum pull` from your home directory and have it
99
+ know you mean the Table Needs site, without `cd`-ing there first.
100
+
101
+ ### `plum.yml`
102
+
103
+ The easy way — guided setup, no YAML editing:
104
+
105
+ ```bash
106
+ plum connect 203.0.113.5
107
+ ```
108
+
109
+ Walks you through: checking for a local SSH key (offers to `ssh-keygen` one
110
+ if you don't have one), testing whether key-based login to the server
111
+ already works, offering to run `ssh-copy-id` if it doesn't, optionally
112
+ adding a `Host` alias to your real `~/.ssh/config` (so `ssh
113
+ plum-production` also works, and plum.yml can reference the alias instead
114
+ of raw host/user), and finally writing `plum.yml` for you.
115
+
116
+ It also **detects how the repo deploys** and asks the right questions for
117
+ that shape: `config/deploy.yml` means Kamal, a `Dockerfile` without one
118
+ usually means Once (you confirm the guess either way). A plain ssh remote
119
+ gets asked for the app's path on the server; a Once remote gets asked for
120
+ the app's hostname (`once_app`) instead, since the app lives in a container
121
+ rather than at a path; a Kamal remote needs neither.
122
+
123
+ Connect asks as little as it can get away with — ideally nothing.
124
+ **On an already-configured project, bare `plum connect` skips the interview
125
+ entirely**: it shows what plum.yml says and verifies it still works (SSH
126
+ login, server tooling, the app actually deployed), exiting nonzero if a
127
+ check fails — a doctor, usable from CI. The wizard only runs on first
128
+ setup, with `--reconfigure`, or when you pass a new ip/host explicitly.
129
+
130
+ When the wizard does run, **re-runs prefill every prompt from the existing
131
+ plum.yml** — Enter through everything and you reproduce the current setup.
132
+ Even a brand-new project isn't a blank slate: **the host prompt offers the
133
+ plum-* aliases already in your ~/.ssh/config** (servers this CLI set up
134
+ before) as a pick-list, so connecting a second site to the same VPS is all
135
+ Enter keys. And for Once remotes, once SSH login works it **asks the
136
+ server itself** (`once list`) and offers the deployed apps as a pick-list —
137
+ with your previous choice highlighted — instead of a blank hostname
138
+ prompt.
139
+
140
+ For Kamal and Once shapes it then **checks the server's tooling** — Docker
141
+ Engine for both, plus the `once` binary for Once — and offers to install
142
+ whatever's missing (`curl -fsSL https://get.docker.com | sh`, `curl
143
+ https://get.once.com | sh`), each only with your explicit confirmation. It
144
+ stops short of deploying the app itself: pushing images and registry
145
+ credentials stay in your hands. Finally it verifies what it can — that
146
+ `bin/rails` exists at the path you gave (ssh), or that `once list` shows
147
+ your app (once) — so you find out immediately if something's off instead of
148
+ on your first `plum pull`. Along the way it registers the project in the
149
+ global registry under the directory's name (and makes it the active project
150
+ if none is set), so `plum use <name>` and `--project <name>` work without a
151
+ separate `plum projects add`.
152
+
153
+ `plum connect` (with no IP) prompts for the host interactively too. Run it
154
+ again to add a second remote (e.g. staging) to an existing plum.yml — it
155
+ merges in the new remote rather than overwriting the file, though note that
156
+ re-saving via `connect` does not preserve hand-written comments in an
157
+ existing plum.yml.
158
+
159
+ The manual way — writes an editable template with comments intact:
160
+
161
+ ```bash
162
+ plum init
163
+ ```
164
+
165
+ ```yaml
166
+ default: production # which remote to use when none is named
167
+
168
+ remotes:
169
+ production:
170
+ host: your-server.example.com
171
+ user: deploy
172
+ path: /var/www/your-site
173
+ # rails: bin/rails # how to invoke rails in `path` (default shown)
174
+ # ssh_args: ["-p", "2222"] # extra ssh/scp args: custom port, identity file, etc.
175
+
176
+ staging:
177
+ host: staging.example.com
178
+ user: deploy
179
+ path: /var/www/your-site-staging
180
+ ```
181
+
182
+ Omit the remote name on any command to use `default:` (or the only remote,
183
+ if there's just one).
184
+
185
+ ### The project registry (fleet management)
186
+
187
+ ```bash
188
+ plum projects add table-needs ~/Work/TableNeeds # register a directory
189
+ plum projects list # see what's registered
190
+ plum use table-needs # make it the active project
191
+ plum projects remove table-needs # forget it (files untouched)
192
+ ```
193
+
194
+ Once a project is active, every command works from anywhere:
195
+
196
+ ```bash
197
+ cd ~ # nowhere near the TableNeeds repo
198
+ plum use table-needs
199
+ plum pull # pulls TableNeeds' production data into TableNeeds' local site
200
+ ```
201
+
202
+ Resolution order for any command (`pull`, `push`, `check`, `backup`, `logs`, `run`):
203
+
204
+ 1. `--project NAME` on the command itself — always wins.
205
+ 2. A `plum.yml` in the **current directory** — so working inside a repo
206
+ behaves exactly like today, unaffected by whatever project is active
207
+ elsewhere.
208
+ 3. The globally **active project** (`plum use`) — the fallback that makes
209
+ fleet commands work from anywhere.
210
+ 4. Otherwise: an error telling you to `plum init` or `plum use`.
211
+
212
+ ```bash
213
+ plum pull --project table-needs # one-off target, doesn't change what's active
214
+ ```
215
+
216
+ ## Commands
217
+
218
+ ```
219
+ plum connect [ip-or-host]
220
+ ```
221
+ Guided setup — see above. The only command that touches your local SSH
222
+ config or generates keys, and only with your confirmation at each step.
223
+
224
+ ```
225
+ plum pull [remote] [--yes]
226
+ ```
227
+ Replaces your local site with the remote's: exports an archive there,
228
+ downloads it, and runs `plum:site:replace` locally. Content-only — the
229
+ underlying database engine doesn't have to match (Postgres in production,
230
+ SQLite locally works fine; see `docs/config-as-code.md` and the site archive
231
+ format). Prompts for confirmation unless `--yes` is passed.
232
+
233
+ ```
234
+ plum push [remote] [--prune] [--force]
235
+ plum check [remote]
236
+ ```
237
+ Upload the local `plum/` config-as-code directory (see
238
+ `docs/config-as-code.md`) and apply or verify it against the remote. On a
239
+ project with no `plum/` yet, both commands offer to run
240
+ `bin/rails plum:config:export` for you on the spot (interactively only —
241
+ scripts and CI still get a hard error, since a fresh export would make
242
+ the drift check meaningless). `check`'s exit code is nonzero on
243
+ drift — wire it into CI. `--prune` deletes content types missing from the
244
+ files; `--force` allows that even when they still have entries.
245
+
246
+ Push moves *structure* (content types, fieldsets), pull moves *content* —
247
+ the same asymmetry as "push code, pull data", because in Plum's model your
248
+ content model **is** code. Neither touches entry data on the other side.
249
+ (`plum sync` still works as an alias for push; it was renamed because sync
250
+ sounded two-way.)
251
+
252
+ ```
253
+ plum backup [remote]
254
+ ```
255
+ Runs `plum:backup:create` on the remote (writes a timestamped archive on the
256
+ server; doesn't download it — that's what `pull` is for).
257
+
258
+ ```
259
+ plum logs [remote] [--follow]
260
+ ```
261
+ Shows the app's recent logs; `--follow` (or `--tail`) streams them until you
262
+ Ctrl-C, heroku-style. On a terminal, structured JSON log lines (the
263
+ slog/thruster shape modern Rails containers emit) are prettified — dim
264
+ local-time stamps, color-coded levels and HTTP statuses, request lines
265
+ compacted to `GET /up 200 (2ms)` with the noisy fields dropped — while
266
+ plain Rails log text passes through untouched. Piped or under `NO_COLOR`
267
+ you get the raw bytes the server sent, so `plum logs | jq` still works.
268
+
269
+ What "the logs" means depends on the transport: plain ssh tails
270
+ `log/production.log` under the app path, Kamal runs `kamal app logs` (all
271
+ hosts, not just the primary), and Once streams `docker logs` from the app's
272
+ container on the server.
273
+
274
+ ```
275
+ plum run [remote] -- TASK [ENV=value ...]
276
+ ```
277
+ Escape hatch: run any rake task on the remote. `plum run production --
278
+ plum:site:export ARCHIVE=/tmp/x.zip` is what `pull` does internally, spelled
279
+ out.
280
+
281
+ ## Transports (`via:`)
282
+
283
+ Every remote picks a `via:`. It only changes *how* the command above reaches
284
+ the server — the rake tasks and their behavior are identical either way.
285
+
286
+ ### `via: ssh` (default)
287
+
288
+ Plain `ssh`/`scp`, inheriting your `~/.ssh/config`, SSH agent, and jump
289
+ hosts. Needs `host`, usually `user` and `path`.
290
+
291
+ ### `via: kamal` — for apps deployed with [Kamal](https://kamalmanual.com)
292
+
293
+ ```yaml
294
+ remotes:
295
+ production:
296
+ via: kamal
297
+ # kamal_bin: bin/kamal # default: bin/kamal if present, else `kamal` on PATH
298
+ # kamal_config: config/deploy.yml
299
+ # kamal_destination: staging # for config/deploy.staging.yml
300
+ ```
301
+
302
+ No `host` or `path` needed — Kamal already knows your servers from
303
+ `config/deploy.yml` and owns its own SSH connection. Commands run as
304
+ `kamal app exec --reuse --primary "<command>"`, matching the pattern this
305
+ repo's own `config/deploy.yml` already uses for its `console`/`shell`
306
+ aliases. `--primary` targets one host even if you deploy to several, so a
307
+ rake task like `plum:site:export` runs exactly once.
308
+
309
+ ### `via: once` — for apps deployed with [Once](https://once.com)
310
+
311
+ ```yaml
312
+ remotes:
313
+ production:
314
+ via: once
315
+ host: plum-production # how to SSH into the server (IP or ~/.ssh/config alias)
316
+ once_app: your-app.example.com # the hostname you gave `once deploy --host ...`
317
+ # user: root # if host isn't an alias that already sets it
318
+ # once_bin: once # path to once ON THE SERVER (default shown)
319
+ ```
320
+
321
+ Unlike Kamal, Once's CLI runs **on the server**, not on your dev machine —
322
+ it talks straight to the local Docker daemon. So this transport is really
323
+ ssh underneath: it connects to `host` (same semantics as `via: ssh` —
324
+ aliases, agents, and `ssh_args` all work) and runs
325
+ `once exec <once_app> <command> [args...]` there.
326
+
327
+ That's why there are two identifiers: `host` is how you *reach the box*,
328
+ `once_app` is which *app on it* you mean — commonly different values, e.g.
329
+ an SSH alias pointing at a bare IP vs. the app's public DNS hostname. One
330
+ server can run several Once apps; `once_app` picks yours.
331
+
332
+ `plum logs` is the one command that doesn't go through `once exec`: the
333
+ Rails image logs to the container's stdout (there's no `log/production.log`
334
+ inside it), and deployed Once has no `logs` subcommand — so the CLI finds
335
+ your app's container by the `once` label Once stamps on it and streams
336
+ `docker logs`, the same daemon Once itself drives.
337
+
338
+ ## What this doesn't do (yet)
339
+
340
+ - **No deploys.** `plum connect` will install the *tooling* a fresh server
341
+ needs (Docker Engine, the `once` binary — each with your confirmation),
342
+ but actually deploying the app — building images, registry credentials,
343
+ `once deploy` / `kamal deploy` — stays in your hands. Kamal and Once
344
+ already do that well; wrapping them for exec is as far as this goes on
345
+ purpose (see `docs/config-as-code.md` and the CLI design notes for why
346
+ re-inventing deploy tooling isn't the goal).
347
+ - **No `--all` / fleet-wide command yet.** Today you target one project (or
348
+ one remote) per invocation. Running one command across every registered
349
+ project is a natural next step, not yet built.
350
+ - **No downloaded database snapshot for SQLite-in-production setups.**
351
+ `pull` always goes through the portable site archive, which works
352
+ regardless of the production database engine. A faster SQLite-specific
353
+ path (`sqlite3 prod.db "VACUUM INTO ..."`) was considered and deliberately
354
+ skipped — the archive is the one code path that works whether production
355
+ runs SQLite or Postgres, and duplicating that logic for a marginal speedup
356
+ wasn't worth the two-path maintenance cost.
@@ -0,0 +1,163 @@
1
+ # Static Page Caching
2
+
3
+ Plum ships a full-page static cache: the first request for a public page
4
+ renders through Liquid as normal, and the complete HTML response is written to
5
+ disk. Subsequent requests are served from that file without touching the
6
+ database or the template engine. When content changes, the affected site's
7
+ cache is deleted and pages lazily re-render on their next visit.
8
+
9
+ This is a cache on the one true render path — not a static site generator.
10
+ There is no build step, no dependency graph to get wrong, and the control
11
+ panel always shows live data.
12
+
13
+ **It is off by default and must be turned on explicitly.** Read "Should you
14
+ enable this?" below before flipping it on — the wrong setting on the wrong
15
+ deployment topology causes a specific, silent content bug (stale pages served
16
+ forever), not a crash you'd notice.
17
+
18
+ ## Should you enable this?
19
+
20
+ Ask one question: **does your app run on more than one server/dyno/instance
21
+ at the same time?**
22
+
23
+ | Your deployment | Enable it? | Why |
24
+ |---|---|---|
25
+ | Single VPS/Droplet/EC2 box (Plum's standalone SQLite mode) | **Yes** | This is the shape it was designed for. One disk, one cache, invalidation always reaches it. Free page-load speed and DB-load reduction. |
26
+ | Docker Compose on one host, one app container | **Yes** | Still one filesystem for the app process. |
27
+ | Heroku with 1 dyno, Render with 1 instance | **Yes, cautiously** | Correct today, but re-check this the moment you scale to 2 dynos — nothing will warn you when you cross that line. |
28
+ | Heroku/Render/Fly with **2+ dynos or instances**, or any horizontally-scaled/load-balanced deployment | **No** | See "Why this fails on multiple nodes" below. Use a CDN in front of the app instead (see that section). |
29
+ | Plum mounted inside a larger host app (embedded/SaaS mode, e.g. many customer sites behind one Rails app) | **Depends entirely on the host app's node count** — almost always multi-node in practice, so usually **No** | The host app's deployment shape governs, not Plum's. |
30
+ | Not sure / evaluating locally | **Leave it off** | It has zero effect on correctness either way in dev; there's no reason to enable it before you know your production topology. |
31
+
32
+ If you're unsure, leave it off. The cost of leaving it off is slower page
33
+ responses and more DB load. The cost of turning it on wrongly is **visitors
34
+ silently seeing outdated or wrong content**, which is worse and harder to
35
+ debug — nothing errors, nothing logs, pages simply disagree depending on
36
+ which server answered the request.
37
+
38
+ ### Why this fails on multiple nodes
39
+
40
+ The cache is plain files on the server's own local disk. Invalidation works
41
+ by *deleting those files* when content changes. On one server, that's
42
+ airtight: the file that would be stale no longer exists, so the next request
43
+ always re-renders fresh.
44
+
45
+ With two or more servers, each has its **own separate disk**. An editor
46
+ publishes a change; the request that triggers invalidation lands on
47
+ whichever server handled that specific HTTP request — say, server A. Server A
48
+ deletes its cached file. Server B's copy of that same file is untouched.
49
+ Visitors routed to server B by the load balancer keep seeing the old page
50
+ **indefinitely** — not for a few seconds until some TTL expires, but forever,
51
+ until something else happens to touch that page on server B too.
52
+
53
+ This is why there is deliberately no "auto-detect production and turn it on"
54
+ behavior. A setting that's silently wrong 100% of the time on a common
55
+ deployment shape (small teams scaling from 1 to 2 dynos is one of the most
56
+ ordinary things that happens to a growing app) is worse than a setting you
57
+ have to think about once.
58
+
59
+ ### If you're on a multi-node platform and still want caching
60
+
61
+ Don't use the disk store. Put a CDN (Cloudflare, CloudFront, Fastly) in front
62
+ of the app and let *it* hold the cached pages — a CDN has exactly one shared
63
+ cache, not one per node, so the invalidation problem above doesn't exist. You
64
+ still get the "no build step, one render path" benefit described above; only
65
+ the storage layer changes. See "Serving cached files from the web server"
66
+ below for the shape this takes; a first-class CDN/surrogate-key purge backend
67
+ driven by the same capture/invalidation hooks used here is on the roadmap.
68
+
69
+ ## How it works
70
+
71
+ 1. `Plum::StaticCache::Middleware` checks every `GET` request with no query
72
+ string. If a cached file exists for the request host + path, it is served
73
+ immediately (`X-Plum-Static-Cache: hit`).
74
+ 2. On a miss, the request renders normally. `Plum::PagesController` and
75
+ `Plum::ThemeAssetsController` mark their successful responses, and the
76
+ middleware writes the body to
77
+ `storage/plum_static_cache/{host}/{path}/index.html`
78
+ (theme assets keep their real filename and extension).
79
+ 3. Content models flush their site's cache directory in an `after_commit`
80
+ hook (`Plum::StaticCacheInvalidation`). Flushing deletes files; it never
81
+ generates anything, so over-flushing is cheap and always correct — this is
82
+ what makes it safe on one server and unsafe across many, per above.
83
+
84
+ Requests with query strings (search, pagination, UTM-tagged links) are never
85
+ cached and never served from the cache. The control panel, API, and form
86
+ endpoints are untouched.
87
+
88
+ ## Configuration
89
+
90
+ ```ruby
91
+ Plum.configure do |config|
92
+ # Explicit opt-in. Off by default. Read "Should you enable this?" above
93
+ # before setting this to true.
94
+ config.static_cache_enabled = true
95
+
96
+ # Where cache files live. Default: Rails.root/storage/plum_static_cache
97
+ config.static_cache_path = "/var/www/site-cache"
98
+ end
99
+ ```
100
+
101
+ There is no environment-based default (no "automatically on in production")
102
+ — it must be set explicitly, on purpose, by someone who has confirmed the
103
+ deployment is single-node. This is a deliberate change: earlier versions of
104
+ this feature turned on automatically in `production`, which was correct for
105
+ the single-VPS case this was designed around but silently wrong the moment an
106
+ app scaled to multiple nodes. Explicit opt-in means the person flipping the
107
+ switch is the person who knows the deployment topology.
108
+
109
+ Cache directories are keyed by request host. A site with a `domain` set
110
+ flushes `{domain}` and `www.{domain}`; sites without a domain flush the whole
111
+ cache root.
112
+
113
+ Administrators can clear a site's cache manually from the dashboard
114
+ ("Clear page cache").
115
+
116
+ ## Serving cached files from the web server (full measure)
117
+
118
+ The middleware already skips the database and rendering, but nginx or Caddy
119
+ can serve the files without invoking Ruby at all:
120
+
121
+ ```nginx
122
+ server {
123
+ server_name example.com;
124
+ root /path/to/app/storage/plum_static_cache/$host;
125
+
126
+ location / {
127
+ try_files $uri $uri/index.html @rails;
128
+ }
129
+
130
+ location @rails {
131
+ proxy_pass http://app_upstream;
132
+ proxy_set_header Host $host;
133
+ }
134
+ }
135
+ ```
136
+
137
+ Anything not in the cache (first hits, search, forms, the control panel)
138
+ falls through to Rails. Because invalidation deletes files, the web server
139
+ can never serve a stale page that Plum knows is stale.
140
+
141
+ The same layout works for pushing to object storage: sync the cache directory
142
+ to S3/CloudFront and invalidate on deploy, or point a CDN at the app origin
143
+ and let the middleware serve as the fast backend.
144
+
145
+ ## Forms and CSRF
146
+
147
+ Cached pages cannot carry per-session CSRF tokens, so public `{% form %}`
148
+ submissions use a hidden honeypot field (`form_submission[website]`) instead.
149
+ Submissions with a filled honeypot are silently accepted-and-dropped. The
150
+ control panel keeps standard Rails CSRF protection — only the public form
151
+ endpoint opts out.
152
+
153
+ This also means rendering a public page no longer writes a session cookie,
154
+ which is what makes responses safely shareable between visitors — and is true
155
+ regardless of whether the cache itself is enabled.
156
+
157
+ ## What stays dynamic
158
+
159
+ - `/search` (query-dependent)
160
+ - Collection pagination beyond page 1 (`?page=2`)
161
+ - Form submission POSTs
162
+ - The JSON content API
163
+ - The entire control panel
@@ -12,6 +12,14 @@ Plum.configure do |config|
12
12
  Plum::Engine.root.join("app/themes")
13
13
  ]
14
14
 
15
+ # Static page caching serves rendered pages from disk instead of hitting
16
+ # the database/Liquid on every request — a big win on a single server, but
17
+ # only correct on a single server. See docs/static-caching.md before
18
+ # enabling this on a multi-node/PaaS deployment (Heroku, Render, etc. with
19
+ # 2+ dynos or instances).
20
+ #
21
+ # config.static_cache_enabled = true
22
+
15
23
  # White-label the control panel:
16
24
  #
17
25
  # config.cp_name = "My Brand"
@@ -6,7 +6,8 @@ module Plum
6
6
  :mailer_sender, :cp_name, :cp_subtitle, :cp_logo_path,
7
7
  :cp_accent_color, :cp_sidebar_bg, :cp_sidebar_header_bg, :cp_sidebar_text, :cp_sidebar_muted,
8
8
  :cp_back_url, :cp_back_label,
9
- :powered_by_name, :powered_by_url
9
+ :powered_by_name, :powered_by_url,
10
+ :static_cache_enabled, :static_cache_path, :config_path
10
11
  attr_writer :theme_paths
11
12
  attr_reader :content_sources
12
13
 
@@ -25,6 +26,16 @@ module Plum
25
26
  @cp_back_label = "← Back"
26
27
  @powered_by_name = "Plum"
27
28
  @powered_by_url = "https://plumcms.com"
29
+ # Explicit opt-in — see docs/static-caching.md before enabling. Off by
30
+ # default because it's only correct on a single-server deployment;
31
+ # silently corrupts pages (stale content served forever from whichever
32
+ # node didn't get the invalidation) on multi-node platforms like
33
+ # Heroku/Render with 2+ dynos.
34
+ @static_cache_enabled = false
35
+ @static_cache_path = nil
36
+ # Directory for config-as-code YAML (content types, fieldsets).
37
+ # nil = default to Rails.root/plum when the tasks are invoked.
38
+ @config_path = nil
28
39
  @content_sources = ContentSourceRegistry.new
29
40
  @current_site_resolver = ->(_controller) { Plum::Site.first_or_create_standalone! }
30
41
  @current_user_resolver = lambda { |controller|
data/lib/plum/engine.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  require_relative "configuration"
2
+ require_relative "static_cache"
3
+ require_relative "static_cache/middleware"
2
4
 
3
5
  module Plum
4
6
  class Engine < ::Rails::Engine
@@ -6,6 +8,11 @@ module Plum
6
8
  config.paths["config/routes.rb"] = "config/plum_routes.rb"
7
9
  config.paths["db/migrate"] = [ "db/engine_migrate" ]
8
10
 
11
+ initializer "plum.static_cache" do |app|
12
+ # Always installed; it no-ops per request unless StaticCache.enabled?
13
+ app.middleware.use Plum::StaticCache::Middleware
14
+ end
15
+
9
16
  initializer "plum.assets" do |app|
10
17
  app.config.assets.paths << root.join("app/assets/javascripts")
11
18
  # Importmap exposes these files as `plum/*`, so Propshaft must resolve
@@ -0,0 +1,61 @@
1
+ require_relative "../static_cache"
2
+
3
+ module Plum
4
+ module StaticCache
5
+ # Serves cached pages before the request reaches Rails and captures
6
+ # renders on the way out. Controllers opt responses in by setting the
7
+ # X-Plum-Static-Cache header to "store" (see PagesController); everything
8
+ # else passes through untouched.
9
+ class Middleware
10
+ MARKER = StaticCache::MARKER_HEADER.downcase
11
+
12
+ def initialize(app)
13
+ @app = app
14
+ end
15
+
16
+ def call(env)
17
+ if StaticCache.enabled? && cacheable_request?(env) && (file = StaticCache.read(env["HTTP_HOST"], env["PATH_INFO"]))
18
+ return serve(file, env)
19
+ end
20
+
21
+ status, headers, body = @app.call(env)
22
+ maybe_store(env, status, headers, body)
23
+ end
24
+
25
+ private
26
+
27
+ def cacheable_request?(env)
28
+ return false unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
29
+
30
+ env["QUERY_STRING"].to_s.empty?
31
+ end
32
+
33
+ def serve(file, env)
34
+ body = env["REQUEST_METHOD"] == "HEAD" ? [] : [ file.binread ]
35
+ headers = {
36
+ "content-type" => StaticCache.content_type_for(file),
37
+ "content-length" => file.size.to_s,
38
+ MARKER => "hit"
39
+ }
40
+ [ 200, headers, body ]
41
+ end
42
+
43
+ def maybe_store(env, status, headers, body)
44
+ # The marker is internal — strip it even when the response isn't stored.
45
+ marker = headers.delete(MARKER) || headers.delete(StaticCache::MARKER_HEADER)
46
+ return [ status, headers, body ] unless marker == "store" && StaticCache.enabled? && cacheable_request?(env)
47
+ return [ status, headers, body ] unless status == 200 && env["REQUEST_METHOD"] == "GET"
48
+ return [ status, headers, body ] if headers["set-cookie"].present? || headers["Set-Cookie"].present?
49
+
50
+ chunks = []
51
+ body.each { |chunk| chunks << chunk.to_s }
52
+ body.close if body.respond_to?(:close)
53
+ full_body = chunks.join
54
+
55
+ StaticCache.store(env["HTTP_HOST"], env["PATH_INFO"], full_body)
56
+ headers[MARKER] = "miss"
57
+ [ status, headers, [ full_body ] ]
58
+ end
59
+ end
60
+ end
61
+ end