plum-cms 0.2.1 → 0.2.3
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 +249 -0
- data/README.md +68 -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/cp.html.erb +1 -1
- data/app/views/layouts/plum/session.html.erb +1 -1
- 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 +422 -0
- data/docs/static-caching.md +163 -0
- data/docs/zero-to-agency.md +172 -0
- data/lib/generators/plum/install/install_generator.rb +13 -1
- data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
- data/lib/plum/configuration.rb +12 -1
- data/lib/plum/engine.rb +18 -4
- 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/plum.rb +7 -0
- 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 +30 -5
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Zero to Agency
|
|
2
|
+
|
|
3
|
+
How to go from an empty directory to hosting client sites — with one
|
|
4
|
+
cheap VPS, the `plum` CLI, and nothing else. No GitHub required, no
|
|
5
|
+
container registry, no CI pipeline, no platform-as-a-service bill. Your
|
|
6
|
+
laptop builds, your server runs, SSH connects them.
|
|
7
|
+
|
|
8
|
+
The stack under the hood: Rails + Plum in a Docker container, managed by
|
|
9
|
+
[Once](https://once.com) (37signals' single-container deploy tool) behind
|
|
10
|
+
its bundled TLS-terminating proxy, on any Linux box. The examples use
|
|
11
|
+
DigitalOcean; any VPS provider works the same way.
|
|
12
|
+
|
|
13
|
+
## What you need
|
|
14
|
+
|
|
15
|
+
- A [DigitalOcean](https://digitalocean.com) account (or any VPS provider)
|
|
16
|
+
- Docker on your laptop (Docker Desktop or Docker Engine) — `plum deploy`
|
|
17
|
+
builds images locally
|
|
18
|
+
- The `plum` CLI
|
|
19
|
+
- Ruby and Rails — and if you don't have them, `plum new` offers to
|
|
20
|
+
install both for you (via [mise](https://mise.jdx.dev))
|
|
21
|
+
|
|
22
|
+
## 1. Create the server
|
|
23
|
+
|
|
24
|
+
In DigitalOcean: **Create → Droplet**. Ubuntu LTS, the $6/month size is
|
|
25
|
+
enough to start (pick $12 for headroom). Add your SSH key if you have one
|
|
26
|
+
— if you don't, that's fine: `plum connect` will generate one and offer
|
|
27
|
+
to copy it over with the root password DigitalOcean emails you. Note the
|
|
28
|
+
droplet's IP.
|
|
29
|
+
|
|
30
|
+
That's the last time you touch a cloud dashboard.
|
|
31
|
+
|
|
32
|
+
## 2. Create the site
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
plum new client-site
|
|
36
|
+
cd client-site
|
|
37
|
+
bin/rails server
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You have a running Plum site at `http://localhost:3000` — the control
|
|
41
|
+
panel is at `/cp`. Build the client's theme in `app/themes/`, model
|
|
42
|
+
their content types in the CP (or as YAML in `plum/` — see
|
|
43
|
+
`docs/config-as-code.md`), and load in their content.
|
|
44
|
+
|
|
45
|
+
## 3. Connect the server
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
plum connect 203.0.113.5 # your droplet's IP
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The wizard handles the entire first-contact ritual: SSH keys, passwordless
|
|
52
|
+
login, detecting that this is a Once-shaped app, and — on a fresh droplet —
|
|
53
|
+
**installing Docker Engine and Once for you** (each with your
|
|
54
|
+
confirmation). When it asks for the app hostname, give it the client's
|
|
55
|
+
real domain (`clientname.com`): that's how the server routes requests and
|
|
56
|
+
provisions TLS.
|
|
57
|
+
|
|
58
|
+
## 4. Point DNS
|
|
59
|
+
|
|
60
|
+
At the client's DNS: an **A record** for their domain (and `www`) to the
|
|
61
|
+
droplet's IP. **This must happen before the first deploy** — Once
|
|
62
|
+
verifies `https://<domain>` end-to-end as the final step of a first
|
|
63
|
+
deploy and rolls back if the domain doesn't reach the server yet
|
|
64
|
+
(`plum deploy` checks this up front and warns you). Proxied DNS
|
|
65
|
+
(Cloudflare in Full strict mode) is fine.
|
|
66
|
+
|
|
67
|
+
Building ahead of a domain cutover? That's what previews are for:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
plum deploy --preview
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This ships the site as a *separate* Once app under a preview hostname —
|
|
74
|
+
by default `<app>.<server-ip>.sslip.io`, magic wildcard DNS that reaches
|
|
75
|
+
your server with zero configuration, so you have a shareable client
|
|
76
|
+
preview URL before any real DNS exists. For branded previews, point one
|
|
77
|
+
wildcard record (`*.preview.your-agency.com`) at the droplet and set
|
|
78
|
+
`preview_host: client.preview.your-agency.com` on the remote in
|
|
79
|
+
plum.yml.
|
|
80
|
+
|
|
81
|
+
Iterate on the preview as long as you like — build the content, tune the
|
|
82
|
+
theme, let the client click around. Then, when they flip DNS to the
|
|
83
|
+
droplet:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
plum promote
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The preview app *becomes* the site: same container, same database, every
|
|
90
|
+
word of content you built during the preview — it simply starts answering
|
|
91
|
+
to the real domain, with TLS provisioned on the spot. No rebuild, no
|
|
92
|
+
content migration, no separate go-live deploy. (If the site should live
|
|
93
|
+
on its own droplet instead, `plum connect` it there and `plum deploy` —
|
|
94
|
+
site archives carry the content across.)
|
|
95
|
+
|
|
96
|
+
## 5. Deploy
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
plum deploy
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Watch it go: the image builds on your laptop, streams to the server over
|
|
103
|
+
SSH (`docker save | docker load` — this is why no registry account
|
|
104
|
+
exists in this guide), Once starts it behind the proxy, and the CLI waits
|
|
105
|
+
for the container to report healthy. `https://clientname.com` is live.
|
|
106
|
+
|
|
107
|
+
Deploys are explicit — the site changes when you run `plum deploy`,
|
|
108
|
+
never by surprise. Ship again any time with the same command.
|
|
109
|
+
|
|
110
|
+
## 6. The agency rhythm
|
|
111
|
+
|
|
112
|
+
Day-to-day, the CLI is your relationship with every client site:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
plum pull # production content → your laptop, any DB engine
|
|
116
|
+
plum push # content-model changes (plum/ YAML) → production
|
|
117
|
+
plum check # did anyone drift the model? (CI-friendly)
|
|
118
|
+
plum logs --follow # heroku-style tail, prettified
|
|
119
|
+
plum backup # timestamped archive on the server
|
|
120
|
+
plum run -- TASK # any rake task, any remote
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Content pulls down, structure pushes up. Clients edit in `/cp` on
|
|
124
|
+
production; you develop locally against a full copy of their real
|
|
125
|
+
content. Turn on **static caching** (`docs/static-caching.md`) and the
|
|
126
|
+
site serves rendered pages from disk — SQLite happily runs a
|
|
127
|
+
production site on the smallest droplet, because cached reads never
|
|
128
|
+
touch the database at all.
|
|
129
|
+
|
|
130
|
+
## 7. The second client
|
|
131
|
+
|
|
132
|
+
This is where it becomes an agency:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
plum new second-client
|
|
136
|
+
cd second-client
|
|
137
|
+
plum connect # the host prompt offers your existing server
|
|
138
|
+
plum deploy
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Once runs each site in its own container on the same box, routing by
|
|
142
|
+
hostname, each with its own TLS certificate. A $6–12 droplet comfortably
|
|
143
|
+
hosts several small client sites; each new client is marginal-cost-zero
|
|
144
|
+
until the box runs hot. Manage the fleet from anywhere:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
plum projects list # every site you run
|
|
148
|
+
plum use second-client # switch the active project
|
|
149
|
+
plum pull --project client-site # or target one explicitly
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## 8. When you outgrow one box
|
|
153
|
+
|
|
154
|
+
- **More sites than one droplet likes**: create a second droplet,
|
|
155
|
+
`plum connect` the new sites there. The registry doesn't care how many
|
|
156
|
+
servers are behind it.
|
|
157
|
+
- **A client needs Postgres** (heavy dynamic features): switch that app's
|
|
158
|
+
database and redeploy — `plum pull` still works across engines.
|
|
159
|
+
- **Backups off the box**: `plum backup` writes archives on the server;
|
|
160
|
+
Once's auto-backup handles scheduling. Pull copies down with `plum pull`
|
|
161
|
+
or scp them to storage you control.
|
|
162
|
+
|
|
163
|
+
## Costs, honestly
|
|
164
|
+
|
|
165
|
+
| Thing | Cost |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| Droplet (several small sites) | $6–12/mo |
|
|
168
|
+
| Domains | the client's problem |
|
|
169
|
+
| Registry, CI, PaaS, per-seat CMS pricing | $0 — none exist in this setup |
|
|
170
|
+
|
|
171
|
+
The whole model: you charge for design and content strategy; the
|
|
172
|
+
infrastructure is a rounding error.
|
|
@@ -30,7 +30,19 @@ module Plum
|
|
|
30
30
|
def mount_engine
|
|
31
31
|
return if options[:skip_route]
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
# Mounted at "/", Plum serves every path the app doesn't claim — so
|
|
34
|
+
# it must come AFTER the app's own routes (notably `get "up"`, the
|
|
35
|
+
# health check load balancers and deploy tooling probe). Rails'
|
|
36
|
+
# `route` helper prepends, which would shadow them; append at the
|
|
37
|
+
# bottom of the block instead.
|
|
38
|
+
inject_into_file "config/routes.rb", before: /^end\s*\z/ do
|
|
39
|
+
<<~RUBY.indent(2)
|
|
40
|
+
|
|
41
|
+
# Plum last: mounted at #{options[:mount_path].inspect} it serves every path
|
|
42
|
+
# the app doesn't claim, so app routes (like /up) must come first.
|
|
43
|
+
mount Plum::Engine, at: #{options[:mount_path].inspect}
|
|
44
|
+
RUBY
|
|
45
|
+
end
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
def print_next_steps
|
|
@@ -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
|
|
@@ -23,10 +30,17 @@ module Plum
|
|
|
23
30
|
end
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
# Build a Plum-scoped importmap instead of merging Plum's pins into the
|
|
34
|
+
# host app's global importmap. Plum's layouts render this map (host JS stack
|
|
35
|
+
# + Plum's additions), while the host app's own pages keep a clean importmap
|
|
36
|
+
# free of Plum's controllers/lexxy/activestorage pins. Runs after the host's
|
|
37
|
+
# importmap is configured so we can copy its paths.
|
|
38
|
+
initializer "plum.importmap", after: "importmap" do |app|
|
|
39
|
+
next unless defined?(Importmap::Map) && app.config.respond_to?(:importmap)
|
|
40
|
+
|
|
41
|
+
Plum.importmap = Importmap::Map.new
|
|
42
|
+
Array(app.config.importmap.paths).each { |path| Plum.importmap.draw(path) }
|
|
43
|
+
Plum.importmap.draw(root.join("config/plum_importmap.rb"))
|
|
30
44
|
end
|
|
31
45
|
end
|
|
32
46
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "rack/mime"
|
|
2
|
+
|
|
3
|
+
module Plum
|
|
4
|
+
# Full-page static cache. Rendered public pages are written to disk keyed by
|
|
5
|
+
# host + path; subsequent requests are served from the file without touching
|
|
6
|
+
# the database or Liquid. A web server can serve the same files directly
|
|
7
|
+
# (nginx try_files) for the "full measure" where Rails never sees the hit.
|
|
8
|
+
#
|
|
9
|
+
# Cache entries are invalidated by deletion (see StaticCacheInvalidation) —
|
|
10
|
+
# the next request re-renders and re-stores the page. Over-flushing is cheap,
|
|
11
|
+
# so anything ambiguous flushes the whole site.
|
|
12
|
+
module StaticCache
|
|
13
|
+
MARKER_HEADER = "X-Plum-Static-Cache".freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Explicit opt-in only — see docs/static-caching.md. There is
|
|
17
|
+
# deliberately no "auto-on in production" mode: this cache is only
|
|
18
|
+
# correct on a single-server deployment, and defaulting it on would
|
|
19
|
+
# silently corrupt pages the moment an app scales to 2+ nodes.
|
|
20
|
+
def enabled?
|
|
21
|
+
!!Plum.configuration.static_cache_enabled
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cache_root
|
|
25
|
+
Pathname(Plum.configuration.static_cache_path || Rails.root.join("storage", "plum_static_cache"))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Pages are stored as {host}/{path}/index.html so a web server can map
|
|
29
|
+
# URLs to files directly. Paths with a file extension (theme assets) are
|
|
30
|
+
# stored verbatim.
|
|
31
|
+
def file_path(host, path)
|
|
32
|
+
key = normalized_path(path)
|
|
33
|
+
return nil unless key
|
|
34
|
+
|
|
35
|
+
base = cache_root.join(sanitized_host(host))
|
|
36
|
+
full = File.extname(key).present? ? base.join(key) : base.join(key, "index.html")
|
|
37
|
+
full = Pathname(File.expand_path(full))
|
|
38
|
+
return nil unless full.to_s.start_with?(cache_root.to_s + File::SEPARATOR)
|
|
39
|
+
|
|
40
|
+
full
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read(host, path)
|
|
44
|
+
file = file_path(host, path)
|
|
45
|
+
file if file&.file?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def store(host, path, body)
|
|
49
|
+
file = file_path(host, path)
|
|
50
|
+
return unless file
|
|
51
|
+
|
|
52
|
+
file.dirname.mkpath
|
|
53
|
+
tmp = file.sub_ext(".tmp-#{Process.pid}-#{Thread.current.object_id}")
|
|
54
|
+
tmp.binwrite(body)
|
|
55
|
+
File.rename(tmp, file)
|
|
56
|
+
file
|
|
57
|
+
rescue SystemCallError => e
|
|
58
|
+
Rails.logger.error("[Plum] static cache write failed for #{path}: #{e.message}")
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def flush_site!(site)
|
|
63
|
+
return flush_all! if site.nil? || site.domain.blank?
|
|
64
|
+
|
|
65
|
+
[ site.domain, "www.#{site.domain}" ].each do |host|
|
|
66
|
+
dir = cache_root.join(sanitized_host(host))
|
|
67
|
+
FileUtils.rm_rf(dir) if dir.to_s.start_with?(cache_root.to_s) && dir.exist?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def flush_all!
|
|
72
|
+
return unless cache_root.exist?
|
|
73
|
+
|
|
74
|
+
cache_root.children.each { |child| FileUtils.rm_rf(child) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def content_type_for(file)
|
|
78
|
+
Rack::Mime.mime_type(File.extname(file.to_s), "text/html")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def sanitized_host(host)
|
|
84
|
+
cleaned = host.to_s.downcase.gsub(/[^a-z0-9.\-]/, "")
|
|
85
|
+
cleaned.presence || "_default"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Rejects anything that could escape the cache directory and normalizes
|
|
89
|
+
# "/" and "/about/" style paths to a shared key.
|
|
90
|
+
def normalized_path(path)
|
|
91
|
+
decoded = begin
|
|
92
|
+
URI.decode_www_form_component(path.to_s)
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
return nil if decoded.include?("..") || decoded.include?("\0")
|
|
97
|
+
|
|
98
|
+
trimmed = decoded.delete_prefix("/").chomp("/")
|
|
99
|
+
trimmed.presence || "_root"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/plum/version.rb
CHANGED
data/lib/plum.rb
CHANGED
|
@@ -3,6 +3,13 @@ require_relative "plum/content_source"
|
|
|
3
3
|
require_relative "plum/configuration"
|
|
4
4
|
|
|
5
5
|
module Plum
|
|
6
|
+
class << self
|
|
7
|
+
# A Plum-scoped importmap (host pins + Plum's own pins), used only by Plum's
|
|
8
|
+
# layouts. Keeps Plum's controllers/lexxy/activestorage out of the host
|
|
9
|
+
# app's global importmap so a mounted Plum can't pollute the host's JS.
|
|
10
|
+
attr_accessor :importmap
|
|
11
|
+
end
|
|
12
|
+
|
|
6
13
|
def self.table_name_prefix
|
|
7
14
|
"plum_"
|
|
8
15
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# This repo is both the engine and an app, so Rails loads lib/tasks twice
|
|
2
|
+
# (engine railtie + application). Rake appends actions on re-definition, which
|
|
3
|
+
# would run every task body twice — guard against the second load.
|
|
4
|
+
unless Rake::Task.task_defined?("plum:config:export")
|
|
5
|
+
namespace :plum do
|
|
6
|
+
namespace :config do
|
|
7
|
+
def plum_config_site
|
|
8
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
9
|
+
abort "No Plum site exists" unless site
|
|
10
|
+
site
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def plum_config_dir
|
|
14
|
+
ENV["DIR"].presence || Plum.configuration.config_path || Rails.root.join("plum")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Write the content model (content types, fieldsets) to YAML files (SITE_ID optional; DIR defaults to plum/)"
|
|
18
|
+
task export: :environment do
|
|
19
|
+
files = Plum::ConfigSync.export(site: plum_config_site, dir: plum_config_dir)
|
|
20
|
+
puts "Exported #{files.length} config files to #{plum_config_dir}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
desc "Apply YAML config files to the database (SITE_ID optional; PRUNE=1 deletes types absent from files; FORCE=1 allows deleting types with entries)"
|
|
24
|
+
task sync: :environment do
|
|
25
|
+
result = Plum::ConfigSync.apply(
|
|
26
|
+
site: plum_config_site,
|
|
27
|
+
dir: plum_config_dir,
|
|
28
|
+
prune: ENV["PRUNE"].present?,
|
|
29
|
+
force: ENV["FORCE"].present?
|
|
30
|
+
)
|
|
31
|
+
puts "Config sync: #{result.summary}"
|
|
32
|
+
rescue Plum::ConfigSync::UnsafePruneError => e
|
|
33
|
+
abort e.message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "Fail if the database content model has drifted from the YAML files (for CI)"
|
|
37
|
+
task check: :environment do
|
|
38
|
+
drift = Plum::ConfigSync.check(site: plum_config_site, dir: plum_config_dir)
|
|
39
|
+
if drift.any?
|
|
40
|
+
drift.each { |line| puts "DRIFT #{line}" }
|
|
41
|
+
abort "Content model has drifted from config files (#{drift.length} differences)"
|
|
42
|
+
else
|
|
43
|
+
puts "Content model matches config files"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -1,37 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
# This repo is both the engine and an app, so Rails loads lib/tasks twice
|
|
2
|
+
# (engine railtie + application). Rake appends actions on re-definition, which
|
|
3
|
+
# would run every task body twice — guard against the second load.
|
|
4
|
+
unless Rake::Task.task_defined?("plum:site:export")
|
|
5
|
+
namespace :plum do
|
|
6
|
+
namespace :site do
|
|
7
|
+
desc "Export a complete Plum site archive (SITE_ID or first site; ARCHIVE required)"
|
|
8
|
+
task export: :environment do
|
|
9
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
10
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
11
|
+
abort "No Plum site exists" unless site
|
|
12
|
+
|
|
13
|
+
archive = Plum::SiteArchive.dump(site: site, path: path)
|
|
14
|
+
puts "Exported #{site.name} to #{archive}"
|
|
15
|
+
end
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
desc "Import a Plum site archive as a new site (ARCHIVE required; NAME and DOMAIN optional)"
|
|
18
|
+
task import: :environment do
|
|
19
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
20
|
+
site = Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
|
|
21
|
+
puts "Imported #{site.name} as site #{site.id}"
|
|
22
|
+
end
|
|
18
23
|
end
|
|
19
|
-
end
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
namespace :site do
|
|
26
|
+
desc "Replace the local site with an archive — the 'pull' refresh (ARCHIVE required; SITE_ID optional; FORCE=1 required in production)"
|
|
27
|
+
task replace: :environment do
|
|
28
|
+
path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
|
|
29
|
+
if Rails.env.production? && ENV["FORCE"].blank?
|
|
30
|
+
abort "Refusing to replace a site in production (set FORCE=1 to override)"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
old_site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
34
|
+
old_name = old_site&.name
|
|
35
|
+
|
|
36
|
+
site = ActiveRecord::Base.transaction do
|
|
37
|
+
old_site&.destroy!
|
|
38
|
+
Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
puts old_name ? "Replaced #{old_name} with #{site.name} (site #{site.id})" : "Imported #{site.name} as site #{site.id}"
|
|
42
|
+
end
|
|
30
43
|
end
|
|
31
44
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
namespace :backup do
|
|
46
|
+
desc "Create a timestamped Plum site backup (SITE_ID optional; DIRECTORY defaults to backups/plum)"
|
|
47
|
+
task create: :environment do
|
|
48
|
+
site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
|
|
49
|
+
abort "No Plum site exists" unless site
|
|
50
|
+
directory = Rails.root.join(ENV.fetch("DIRECTORY", "backups/plum"))
|
|
51
|
+
filename = "plum-site-#{site.id}-#{Time.current.utc.strftime('%Y%m%d%H%M%S')}.plum.zip"
|
|
52
|
+
archive = Plum::SiteArchive.dump(site: site, path: directory.join(filename))
|
|
53
|
+
puts "Backed up #{site.name} to #{archive}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc "Restore a Plum backup as a new site (ARCHIVE required; NAME and DOMAIN optional)"
|
|
57
|
+
task restore: :environment do
|
|
58
|
+
Rake::Task["plum:site:import"].invoke
|
|
59
|
+
end
|
|
35
60
|
end
|
|
36
61
|
end
|
|
37
62
|
end
|
data/lib/tasks/plum_styles.rake
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
# This repo is both the engine and an app, so Rails loads lib/tasks twice
|
|
2
|
+
# (engine railtie + application). Rake appends actions on re-definition, which
|
|
3
|
+
# would run every task body twice — guard against the second load.
|
|
4
|
+
unless Rake::Task.task_defined?("plum:build_styles")
|
|
5
|
+
require "fileutils"
|
|
2
6
|
|
|
3
|
-
namespace :plum do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
namespace :plum do
|
|
8
|
+
desc "Build Tailwind and copy the result into Plum's packaged stylesheet"
|
|
9
|
+
task build_styles: :environment do
|
|
10
|
+
system(Rails.root.join("bin/rails").to_s, "tailwindcss:build", exception: true)
|
|
11
|
+
source = Rails.root.join("app/assets/builds/tailwind.css")
|
|
12
|
+
destination = Rails.root.join("app/assets/stylesheets/plum/control_panel.css")
|
|
13
|
+
FileUtils.cp(source, destination)
|
|
14
|
+
puts "Packaged #{destination.relative_path_from(Rails.root)}"
|
|
15
|
+
end
|
|
11
16
|
end
|
|
12
17
|
end
|