plum-cms 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +64 -0
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/controllers/plum/cp/entries_controller.rb +88 -5
  6. data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
  7. data/app/controllers/plum/form_submissions_controller.rb +13 -0
  8. data/app/controllers/plum/pages_controller.rb +8 -0
  9. data/app/controllers/plum/theme_assets_controller.rb +2 -0
  10. data/app/javascript/controllers/plum/write_controller.js +174 -0
  11. data/app/models/plum/asset.rb +1 -0
  12. data/app/models/plum/content_type.rb +1 -0
  13. data/app/models/plum/entry.rb +57 -0
  14. data/app/models/plum/entry_term.rb +1 -0
  15. data/app/models/plum/form_definition.rb +1 -0
  16. data/app/models/plum/global.rb +1 -0
  17. data/app/models/plum/nav_item.rb +1 -0
  18. data/app/models/plum/nav_menu.rb +1 -0
  19. data/app/models/plum/site.rb +2 -0
  20. data/app/models/plum/site_setting.rb +1 -0
  21. data/app/models/plum/static_cache_invalidation.rb +30 -0
  22. data/app/models/plum/taxonomy.rb +1 -0
  23. data/app/models/plum/term.rb +1 -0
  24. data/app/services/plum/config_sync.rb +252 -0
  25. data/app/services/plum/draft_diff.rb +177 -0
  26. data/app/services/plum/form_renderer.rb +12 -1
  27. data/app/services/plum/liquid_context.rb +0 -2
  28. data/app/views/layouts/plum/write.html.erb +140 -0
  29. data/app/views/plum/cp/dashboard/show.html.erb +10 -3
  30. data/app/views/plum/cp/entries/_form.html.erb +2 -2
  31. data/app/views/plum/cp/entries/diff.html.erb +40 -0
  32. data/app/views/plum/cp/entries/edit.html.erb +24 -0
  33. data/app/views/plum/cp/entries/index.html.erb +3 -0
  34. data/app/views/plum/cp/entries/write.html.erb +60 -0
  35. data/config/plum_routes.rb +5 -0
  36. data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
  37. data/docs/config-as-code.md +103 -0
  38. data/docs/plum-cli.md +356 -0
  39. data/docs/static-caching.md +163 -0
  40. data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
  41. data/lib/plum/configuration.rb +12 -1
  42. data/lib/plum/engine.rb +7 -0
  43. data/lib/plum/static_cache/middleware.rb +61 -0
  44. data/lib/plum/static_cache.rb +103 -0
  45. data/lib/plum/version.rb +1 -1
  46. data/lib/tasks/plum_config.rake +48 -0
  47. data/lib/tasks/plum_portability.rake +54 -29
  48. data/lib/tasks/plum_styles.rake +14 -9
  49. metadata +21 -3
@@ -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.1"
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
@@ -1,37 +1,62 @@
1
- namespace :plum do
2
- namespace :site do
3
- desc "Export a complete Plum site archive (SITE_ID or first site; ARCHIVE required)"
4
- task export: :environment do
5
- path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
6
- site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
7
- abort "No Plum site exists" unless site
8
-
9
- archive = Plum::SiteArchive.dump(site: site, path: path)
10
- puts "Exported #{site.name} to #{archive}"
11
- end
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
- desc "Import a Plum site archive as a new site (ARCHIVE required; NAME and DOMAIN optional)"
14
- task import: :environment do
15
- path = ENV.fetch("ARCHIVE") { abort "ARCHIVE is required" }
16
- site = Plum::SiteArchive.load(path: path, name: ENV["NAME"], domain: ENV["DOMAIN"])
17
- puts "Imported #{site.name} as site #{site.id}"
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
- namespace :backup do
22
- desc "Create a timestamped Plum site backup (SITE_ID optional; DIRECTORY defaults to backups/plum)"
23
- task create: :environment do
24
- site = ENV["SITE_ID"].present? ? Plum::Site.find(ENV["SITE_ID"]) : Plum::Site.first
25
- abort "No Plum site exists" unless site
26
- directory = Rails.root.join(ENV.fetch("DIRECTORY", "backups/plum"))
27
- filename = "plum-site-#{site.id}-#{Time.current.utc.strftime('%Y%m%d%H%M%S')}.plum.zip"
28
- archive = Plum::SiteArchive.dump(site: site, path: directory.join(filename))
29
- puts "Backed up #{site.name} to #{archive}"
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
- desc "Restore a Plum backup as a new site (ARCHIVE required; NAME and DOMAIN optional)"
33
- task restore: :environment do
34
- Rake::Task["plum:site:import"].invoke
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
@@ -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
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plum-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Simmons
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: bcrypt
@@ -222,6 +223,7 @@ files:
222
223
  - app/controllers/plum/cp/nav_items_controller.rb
223
224
  - app/controllers/plum/cp/nav_menus_controller.rb
224
225
  - app/controllers/plum/cp/site_settings_controller.rb
226
+ - app/controllers/plum/cp/static_cache_controller.rb
225
227
  - app/controllers/plum/cp/taxonomies_controller.rb
226
228
  - app/controllers/plum/cp/terms_controller.rb
227
229
  - app/controllers/plum/cp/theme_previews_controller.rb
@@ -246,6 +248,7 @@ files:
246
248
  - app/javascript/controllers/plum/image_picker_controller.js
247
249
  - app/javascript/controllers/plum/structured_field_controller.js
248
250
  - app/javascript/controllers/plum/theme_settings_controller.js
251
+ - app/javascript/controllers/plum/write_controller.js
249
252
  - app/models/plum/application_record.rb
250
253
  - app/models/plum/asset.rb
251
254
  - app/models/plum/content_type.rb
@@ -261,6 +264,7 @@ files:
261
264
  - app/models/plum/site.rb
262
265
  - app/models/plum/site_scoped.rb
263
266
  - app/models/plum/site_setting.rb
267
+ - app/models/plum/static_cache_invalidation.rb
264
268
  - app/models/plum/taxonomy.rb
265
269
  - app/models/plum/term.rb
266
270
  - app/models/plum/user.rb
@@ -268,6 +272,8 @@ files:
268
272
  - app/services/plum/block_editor_config.rb
269
273
  - app/services/plum/block_library.rb
270
274
  - app/services/plum/builder_renderer.rb
275
+ - app/services/plum/config_sync.rb
276
+ - app/services/plum/draft_diff.rb
271
277
  - app/services/plum/entry_serializer.rb
272
278
  - app/services/plum/field_expander.rb
273
279
  - app/services/plum/field_options.rb
@@ -320,6 +326,7 @@ files:
320
326
  - app/themes/default/theme.yml
321
327
  - app/views/layouts/plum/cp.html.erb
322
328
  - app/views/layouts/plum/session.html.erb
329
+ - app/views/layouts/plum/write.html.erb
323
330
  - app/views/plum/cp/assets/_form.html.erb
324
331
  - app/views/plum/cp/assets/edit.html.erb
325
332
  - app/views/plum/cp/assets/index.html.erb
@@ -332,9 +339,11 @@ files:
332
339
  - app/views/plum/cp/custom_fields/_input.html.erb
333
340
  - app/views/plum/cp/dashboard/show.html.erb
334
341
  - app/views/plum/cp/entries/_form.html.erb
342
+ - app/views/plum/cp/entries/diff.html.erb
335
343
  - app/views/plum/cp/entries/edit.html.erb
336
344
  - app/views/plum/cp/entries/index.html.erb
337
345
  - app/views/plum/cp/entries/new.html.erb
346
+ - app/views/plum/cp/entries/write.html.erb
338
347
  - app/views/plum/cp/entry_revisions/index.html.erb
339
348
  - app/views/plum/cp/fieldsets/index.html.erb
340
349
  - app/views/plum/cp/form_definitions/_form.html.erb
@@ -382,14 +391,18 @@ files:
382
391
  - db/engine_migrate/20260807140000_create_plum_fieldsets.rb
383
392
  - db/engine_migrate/20260807150000_add_focal_point_to_plum_assets.rb
384
393
  - db/engine_migrate/20260807160000_add_localization_to_plum_entries.rb
394
+ - db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb
385
395
  - docs/blueprint-fields.md
396
+ - docs/config-as-code.md
386
397
  - docs/extensions.md
398
+ - docs/plum-cli.md
387
399
  - docs/portability.md
388
400
  - docs/product-principles.md
389
401
  - docs/roadmap.md
390
402
  - docs/site/homepage.md
391
403
  - docs/site/information-architecture.md
392
404
  - docs/statamic-parity.md
405
+ - docs/static-caching.md
393
406
  - docs/vision.md
394
407
  - lib/generators/plum/install/install_generator.rb
395
408
  - lib/generators/plum/install/templates/plum_initializer.rb
@@ -400,7 +413,10 @@ files:
400
413
  - lib/plum/content_source_registry.rb
401
414
  - lib/plum/engine.rb
402
415
  - lib/plum/liquid_tags/form_tag.rb
416
+ - lib/plum/static_cache.rb
417
+ - lib/plum/static_cache/middleware.rb
403
418
  - lib/plum/version.rb
419
+ - lib/tasks/plum_config.rake
404
420
  - lib/tasks/plum_portability.rake
405
421
  - lib/tasks/plum_styles.rake
406
422
  - vendor/javascript/lexxy.js
@@ -412,6 +428,7 @@ metadata:
412
428
  rubygems_mfa_required: 'true'
413
429
  source_code_uri: https://github.com/tableneeds/plum
414
430
  bug_tracker_uri: https://github.com/tableneeds/plum/issues
431
+ post_install_message:
415
432
  rdoc_options: []
416
433
  require_paths:
417
434
  - lib
@@ -427,7 +444,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
427
444
  - !ruby/object:Gem::Version
428
445
  version: '0'
429
446
  requirements: []
430
- rubygems_version: 3.6.9
447
+ rubygems_version: 3.5.9
448
+ signing_key:
431
449
  specification_version: 4
432
450
  summary: A Rails-native CMS engine.
433
451
  test_files: []