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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +178 -0
- data/README.md +64 -0
- data/app/assets/builds/tailwind.css +1 -1
- data/app/controllers/plum/cp/entries_controller.rb +88 -5
- data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
- data/app/controllers/plum/form_submissions_controller.rb +13 -0
- data/app/controllers/plum/pages_controller.rb +8 -0
- data/app/controllers/plum/theme_assets_controller.rb +2 -0
- data/app/javascript/controllers/plum/write_controller.js +174 -0
- data/app/models/plum/asset.rb +1 -0
- data/app/models/plum/content_type.rb +1 -0
- data/app/models/plum/entry.rb +57 -0
- data/app/models/plum/entry_term.rb +1 -0
- data/app/models/plum/form_definition.rb +1 -0
- data/app/models/plum/global.rb +1 -0
- data/app/models/plum/nav_item.rb +1 -0
- data/app/models/plum/nav_menu.rb +1 -0
- data/app/models/plum/site.rb +2 -0
- data/app/models/plum/site_setting.rb +1 -0
- data/app/models/plum/static_cache_invalidation.rb +30 -0
- data/app/models/plum/taxonomy.rb +1 -0
- data/app/models/plum/term.rb +1 -0
- data/app/services/plum/config_sync.rb +252 -0
- data/app/services/plum/draft_diff.rb +177 -0
- data/app/services/plum/form_renderer.rb +12 -1
- data/app/services/plum/liquid_context.rb +0 -2
- data/app/views/layouts/plum/write.html.erb +140 -0
- data/app/views/plum/cp/dashboard/show.html.erb +10 -3
- data/app/views/plum/cp/entries/_form.html.erb +2 -2
- data/app/views/plum/cp/entries/diff.html.erb +40 -0
- data/app/views/plum/cp/entries/edit.html.erb +24 -0
- data/app/views/plum/cp/entries/index.html.erb +3 -0
- data/app/views/plum/cp/entries/write.html.erb +60 -0
- data/config/plum_routes.rb +5 -0
- data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
- data/docs/config-as-code.md +103 -0
- data/docs/plum-cli.md +356 -0
- data/docs/static-caching.md +163 -0
- data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
- data/lib/plum/configuration.rb +12 -1
- data/lib/plum/engine.rb +7 -0
- data/lib/plum/static_cache/middleware.rb +61 -0
- data/lib/plum/static_cache.rb +103 -0
- data/lib/plum/version.rb +1 -1
- data/lib/tasks/plum_config.rake +48 -0
- data/lib/tasks/plum_portability.rake +54 -29
- data/lib/tasks/plum_styles.rake +14 -9
- 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"
|
data/lib/plum/configuration.rb
CHANGED
|
@@ -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
|