plum-cms 0.2.0 → 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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +79 -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/services/plum/site_archive.rb +367 -0
  29. data/app/views/layouts/plum/write.html.erb +140 -0
  30. data/app/views/plum/cp/dashboard/show.html.erb +10 -3
  31. data/app/views/plum/cp/entries/_form.html.erb +2 -2
  32. data/app/views/plum/cp/entries/diff.html.erb +40 -0
  33. data/app/views/plum/cp/entries/edit.html.erb +24 -0
  34. data/app/views/plum/cp/entries/index.html.erb +3 -0
  35. data/app/views/plum/cp/entries/write.html.erb +60 -0
  36. data/config/plum_routes.rb +5 -0
  37. data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
  38. data/docs/config-as-code.md +103 -0
  39. data/docs/plum-cli.md +356 -0
  40. data/docs/portability.md +40 -0
  41. data/docs/roadmap.md +71 -69
  42. data/docs/static-caching.md +163 -0
  43. data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
  44. data/lib/plum/configuration.rb +12 -1
  45. data/lib/plum/engine.rb +10 -1
  46. data/lib/plum/static_cache/middleware.rb +61 -0
  47. data/lib/plum/static_cache.rb +103 -0
  48. data/lib/plum/version.rb +1 -1
  49. data/lib/tasks/plum_config.rake +48 -0
  50. data/lib/tasks/plum_portability.rake +62 -0
  51. data/lib/tasks/plum_styles.rake +14 -9
  52. metadata +26 -5
@@ -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,9 +8,16 @@ 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
- app.config.assets.paths << root.join("app/javascript/controllers/plum")
18
+ # Importmap exposes these files as `plum/*`, so Propshaft must resolve
19
+ # them relative to the parent controllers directory.
20
+ app.config.assets.paths << root.join("app/javascript/controllers")
12
21
  app.config.assets.paths << root.join("vendor/javascript")
13
22
 
14
23
  lexxy_spec = Gem.loaded_specs["lexxy"]
@@ -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
@@ -1,6 +1,6 @@
1
1
  module Plum
2
2
  module Version
3
- STRING = "0.2.0"
3
+ STRING = "0.2.2"
4
4
  end
5
5
 
6
6
  VERSION = Version::STRING
@@ -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
@@ -0,0 +1,62 @@
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
16
+
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
23
+ end
24
+
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
43
+ end
44
+
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
60
+ end
61
+ end
62
+ end
@@ -1,12 +1,17 @@
1
- require "fileutils"
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
- desc "Build Tailwind and copy the result into Plum's packaged stylesheet"
5
- task build_styles: :environment do
6
- system(Rails.root.join("bin/rails").to_s, "tailwindcss:build", exception: true)
7
- source = Rails.root.join("app/assets/builds/tailwind.css")
8
- destination = Rails.root.join("app/assets/stylesheets/plum/control_panel.css")
9
- FileUtils.cp(source, destination)
10
- puts "Packaged #{destination.relative_path_from(Rails.root)}"
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