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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +249 -0
  3. data/README.md +68 -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/cp.html.erb +1 -1
  29. data/app/views/layouts/plum/session.html.erb +1 -1
  30. data/app/views/layouts/plum/write.html.erb +140 -0
  31. data/app/views/plum/cp/dashboard/show.html.erb +10 -3
  32. data/app/views/plum/cp/entries/_form.html.erb +2 -2
  33. data/app/views/plum/cp/entries/diff.html.erb +40 -0
  34. data/app/views/plum/cp/entries/edit.html.erb +24 -0
  35. data/app/views/plum/cp/entries/index.html.erb +3 -0
  36. data/app/views/plum/cp/entries/write.html.erb +60 -0
  37. data/config/plum_routes.rb +5 -0
  38. data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
  39. data/docs/config-as-code.md +103 -0
  40. data/docs/plum-cli.md +422 -0
  41. data/docs/static-caching.md +163 -0
  42. data/docs/zero-to-agency.md +172 -0
  43. data/lib/generators/plum/install/install_generator.rb +13 -1
  44. data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
  45. data/lib/plum/configuration.rb +12 -1
  46. data/lib/plum/engine.rb +18 -4
  47. data/lib/plum/static_cache/middleware.rb +61 -0
  48. data/lib/plum/static_cache.rb +103 -0
  49. data/lib/plum/version.rb +1 -1
  50. data/lib/plum.rb +7 -0
  51. data/lib/tasks/plum_config.rake +48 -0
  52. data/lib/tasks/plum_portability.rake +54 -29
  53. data/lib/tasks/plum_styles.rake +14 -9
  54. metadata +30 -5
@@ -1,6 +1,15 @@
1
1
  module Plum
2
2
  class FormSubmissionsController < ApplicationController
3
+ # Public forms are served from the static cache, so they can't carry a
4
+ # per-session CSRF token. Submissions are unauthenticated writes guarded
5
+ # by the honeypot below instead.
6
+ skip_forgery_protection
7
+
3
8
  def create
9
+ # Pretend success when the honeypot is filled so bots don't learn
10
+ # they were caught.
11
+ return redirect_to safe_return_path, notice: "Form submitted" if honeypot_tripped?
12
+
4
13
  form_definition = current_site.form_definitions.find_by!(handle: params[:handle])
5
14
  submission = form_definition.form_submissions.build(
6
15
  site: current_site,
@@ -17,6 +26,10 @@ module Plum
17
26
 
18
27
  private
19
28
 
29
+ def honeypot_tripped?
30
+ params.dig(:form_submission, :website).present?
31
+ end
32
+
20
33
  def deliver_submission_notification(submission)
21
34
  return if submission.form_definition.notification_email.blank?
22
35
 
@@ -2,6 +2,10 @@ module Plum
2
2
  class PagesController < ApplicationController
3
3
  HOMEPAGE_SLUG = "home".freeze
4
4
 
5
+ # Search stays dynamic (query-dependent); the middleware also refuses to
6
+ # store any response with a query string or a non-200 status.
7
+ after_action :mark_static_cacheable, only: [ :home, :localized_home, :show ]
8
+
5
9
  def home
6
10
  @site_settings = SiteSetting.instance(current_site)
7
11
  @entry = Entry.for_site(current_site).live
@@ -91,6 +95,10 @@ module Plum
91
95
 
92
96
  PER_PAGE = 12
93
97
 
98
+ def mark_static_cacheable
99
+ response.headers[Plum::StaticCache::MARKER_HEADER] = "store" if response.status == 200
100
+ end
101
+
94
102
  def render_collection(content_type)
95
103
  context = build_context
96
104
  page = [ params.fetch(:page, 1).to_i, 1 ].max
@@ -6,6 +6,8 @@ module Plum
6
6
  asset_path = ThemeResolver.new.find_asset(params[:theme_handle], params[:path])
7
7
  return head :not_found unless asset_path
8
8
 
9
+ expires_in 1.hour, public: true
10
+ response.headers[Plum::StaticCache::MARKER_HEADER] = "store"
9
11
  send_file asset_path,
10
12
  type: Rack::Mime.mime_type(asset_path.extname, "application/octet-stream"),
11
13
  disposition: "inline"
@@ -0,0 +1,174 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Distraction-free writing mode: autosaves the title and one rich text field
4
+ // a moment after typing stops, without leaving the page.
5
+ export default class extends Controller {
6
+ static targets = ["form", "title", "editor", "status", "statusText", "words", "bar"]
7
+ static values = { publishUrl: String }
8
+
9
+ connect() {
10
+ this.dirty = false
11
+ this.saving = false
12
+ this.resizeTitle()
13
+ this.refreshWordCount()
14
+
15
+ // Lexical swallows native input events, so "input" alone misses typing
16
+ // in the body — lexxy:change is the editor's own any-edit signal.
17
+ this.editorListener = () => this.contentChanged()
18
+ this.editorTarget.addEventListener("lexxy:change", this.editorListener)
19
+ this.editorTarget.addEventListener("input", this.editorListener)
20
+
21
+ this.keyListener = (event) => {
22
+ if ((event.metaKey || event.ctrlKey) && event.key === "s") {
23
+ event.preventDefault()
24
+ this.save()
25
+ }
26
+ }
27
+ document.addEventListener("keydown", this.keyListener)
28
+
29
+ // Flush pending edits when leaving instead of nagging with a dialog;
30
+ // keepalive lets the request outlive the page. Only block navigation
31
+ // when a save actually failed and edits would truly be lost.
32
+ this.unloadListener = (event) => {
33
+ if (!this.dirty) return
34
+ this.flushSave()
35
+ if (this.saveFailed) {
36
+ event.preventDefault()
37
+ event.returnValue = ""
38
+ }
39
+ }
40
+ window.addEventListener("beforeunload", this.unloadListener)
41
+ }
42
+
43
+ disconnect() {
44
+ this.editorTarget.removeEventListener("lexxy:change", this.editorListener)
45
+ this.editorTarget.removeEventListener("input", this.editorListener)
46
+ document.removeEventListener("keydown", this.keyListener)
47
+ window.removeEventListener("beforeunload", this.unloadListener)
48
+ clearTimeout(this.saveTimer)
49
+ }
50
+
51
+ titleChanged() {
52
+ this.resizeTitle()
53
+ this.contentChanged()
54
+ }
55
+
56
+ contentChanged() {
57
+ this.dirty = true
58
+ this.setStatus("dirty", "Unsaved changes")
59
+ this.refreshWordCount()
60
+ clearTimeout(this.saveTimer)
61
+ this.saveTimer = setTimeout(() => this.save(), 2000)
62
+ }
63
+
64
+ // The top bar normally fades away; save activity has to be able to pull
65
+ // it back so the state change is actually seen.
66
+ setStatus(state, text) {
67
+ this.statusTarget.dataset.state = state
68
+ this.statusTextTarget.textContent = text
69
+
70
+ clearTimeout(this.attentionTimer)
71
+ if (state === "saving" || state === "error") {
72
+ this.barTarget.classList.add("plum-write-bar--active")
73
+ } else if (state === "saved") {
74
+ this.barTarget.classList.add("plum-write-bar--active")
75
+ this.attentionTimer = setTimeout(() => this.barTarget.classList.remove("plum-write-bar--active"), 1800)
76
+ } else {
77
+ this.barTarget.classList.remove("plum-write-bar--active")
78
+ }
79
+ }
80
+
81
+ save() {
82
+ if (this.saving) return this.savePromise
83
+ this.savePromise = this.performSave()
84
+ return this.savePromise
85
+ }
86
+
87
+ async performSave() {
88
+ clearTimeout(this.saveTimer)
89
+ this.saving = true
90
+ this.dirty = false
91
+ this.setStatus("saving", "Saving…")
92
+
93
+ const hidden = document.getElementById(this.editorTarget.dataset.hiddenField)
94
+ if (hidden) hidden.value = this.editorTarget.value
95
+
96
+ try {
97
+ const response = await fetch(this.formTarget.action, {
98
+ method: "POST",
99
+ body: new FormData(this.formTarget),
100
+ headers: { Accept: "application/json" }
101
+ })
102
+ const result = await response.json().catch(() => ({}))
103
+
104
+ if (response.ok && result.saved) {
105
+ const label = result.draft ? "Draft saved" : "Saved"
106
+ this.setStatus("saved", `${label} at ${this.timeNow()}`)
107
+ this.saveFailed = false
108
+ } else {
109
+ this.dirty = true
110
+ this.saveFailed = true
111
+ this.setStatus("error", (result.errors || ["Couldn't save"]).join(", "))
112
+ }
113
+ } catch {
114
+ this.dirty = true
115
+ this.saveFailed = true
116
+ this.setStatus("error", "Offline — changes not saved yet")
117
+ } finally {
118
+ this.saving = false
119
+ }
120
+ }
121
+
122
+ flushSave() {
123
+ const hidden = document.getElementById(this.editorTarget.dataset.hiddenField)
124
+ if (hidden) hidden.value = this.editorTarget.value
125
+ this.dirty = false
126
+ fetch(this.formTarget.action, {
127
+ method: "POST",
128
+ body: new FormData(this.formTarget),
129
+ headers: { Accept: "application/json" },
130
+ keepalive: true
131
+ }).catch(() => {})
132
+ }
133
+
134
+ async publishDraft() {
135
+ await this.save()
136
+ if (this.dirty) return // the save failed; don't publish a stale draft
137
+
138
+ this.setStatus("saving", "Publishing…")
139
+ try {
140
+ const response = await fetch(this.publishUrlValue, {
141
+ method: "POST",
142
+ headers: {
143
+ Accept: "application/json",
144
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || ""
145
+ }
146
+ })
147
+ if (response.ok) {
148
+ this.setStatus("saved", `Published at ${this.timeNow()}`)
149
+ } else {
150
+ this.setStatus("error", "Couldn't publish")
151
+ }
152
+ } catch {
153
+ this.setStatus("error", "Offline — couldn't publish")
154
+ }
155
+ }
156
+
157
+ timeNow() {
158
+ return new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
159
+ }
160
+
161
+ resizeTitle() {
162
+ const title = this.titleTarget
163
+ title.style.height = "auto"
164
+ title.style.height = `${title.scrollHeight}px`
165
+ }
166
+
167
+ refreshWordCount() {
168
+ const scratch = document.createElement("div")
169
+ scratch.innerHTML = this.editorTarget.value || ""
170
+ const text = scratch.textContent || ""
171
+ const words = (text.trim().match(/\S+/g) || []).length
172
+ this.wordsTarget.textContent = `${words} ${words === 1 ? "word" : "words"}`
173
+ }
174
+ }
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class Asset < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  has_one_attached :file
6
7
 
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class ContentType < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  FIELD_TYPES = FieldTypeRegistry.handles.freeze
6
7
 
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class Entry < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  belongs_to :content_type
6
7
  belongs_to :author, class_name: "Plum::User", optional: true
@@ -10,6 +11,9 @@ module Plum
10
11
  has_many :terms, through: :entry_terms
11
12
  has_many :revisions, class_name: "Plum::EntryRevision", dependent: :destroy
12
13
  has_many :translations, class_name: "Plum::Entry", foreign_key: :origin_id, dependent: :destroy
14
+ # A deleted entry takes its nav links with it — nav items have a FK on
15
+ # entry_id, so without this both entry and site destroys crash.
16
+ has_many :nav_items, dependent: :destroy
13
17
 
14
18
  # The page served at "/" — Plum resolves the homepage by this slug
15
19
  # (convention over configuration). Its slug is locked and it can't be
@@ -45,6 +49,49 @@ module Plum
45
49
  data&.dig(handle)
46
50
  end
47
51
 
52
+ # Working copy ("draft of a published entry"): pending edits stored in
53
+ # draft_data as { "title" => ..., "data" => {...} }. The live `title` and
54
+ # `data` stay untouched until publish_draft!, so the public site never
55
+ # sees half-written content.
56
+ def has_draft?
57
+ draft_data.present?
58
+ end
59
+
60
+ def draft_title
61
+ draft_data.to_h["title"].presence || title
62
+ end
63
+
64
+ def draft_field_value(handle)
65
+ draft_data.to_h.dig("data", handle) || field_value(handle)
66
+ end
67
+
68
+ def save_draft!(title: nil, data: {})
69
+ base = draft_data.to_h
70
+ merged = (base["data"] || self.data.to_h).merge(data.to_h)
71
+ update!(draft_data: {
72
+ "title" => title.presence || base["title"].presence || self.title,
73
+ "data" => merged
74
+ })
75
+ end
76
+
77
+ def publish_draft!(editor: nil)
78
+ return false unless has_draft?
79
+
80
+ transaction do
81
+ update!(
82
+ title: draft_data["title"].presence || title,
83
+ data: draft_data["data"] || data,
84
+ draft_data: nil
85
+ )
86
+ record_revision!(editor: editor)
87
+ end
88
+ true
89
+ end
90
+
91
+ def discard_draft!
92
+ update!(draft_data: nil)
93
+ end
94
+
48
95
  def homepage?
49
96
  slug == HOMEPAGE_SLUG
50
97
  end
@@ -66,6 +113,12 @@ module Plum
66
113
  }
67
114
  }
68
115
  attributes[:editor] = editor if editor.is_a?(Plum::User)
116
+
117
+ # Autosave calls this on every save; identical content shouldn't pile
118
+ # up as duplicate history entries.
119
+ last = revisions.order(id: :desc).first
120
+ return last if last && last.snapshot == attributes[:snapshot]
121
+
69
122
  revisions.create!(attributes)
70
123
  end
71
124
 
@@ -278,6 +331,10 @@ module Plum
278
331
  end
279
332
 
280
333
  def prevent_homepage_destroy
334
+ # Only protect the homepage while its site survives — when the whole
335
+ # site is going away (site destroy, plum:site:replace), the cascade
336
+ # must be allowed through.
337
+ return if destroyed_by_association
281
338
  return unless homepage?
282
339
 
283
340
  errors.add(:base, "The homepage can't be deleted")
@@ -1,5 +1,6 @@
1
1
  module Plum
2
2
  class EntryTerm < ApplicationRecord
3
+ include StaticCacheInvalidation
3
4
  belongs_to :entry
4
5
  belongs_to :term
5
6
 
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class FormDefinition < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  FIELD_TYPES = %w[text email textarea select checkbox].freeze
6
7
  HANDLE_PATTERN = /\A[a-z][a-z0-9_]*\z/
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class Global < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  validates :name, presence: true
6
7
  validates :handle, presence: true, uniqueness: { scope: :site_id }, format: { with: /\A[a-z][a-z0-9_]*\z/ }
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class NavItem < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  belongs_to :nav_menu
6
7
  belongs_to :parent, class_name: "Plum::NavItem", optional: true
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class NavMenu < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  has_many :nav_items, dependent: :destroy
6
7
 
@@ -1,5 +1,7 @@
1
1
  module Plum
2
2
  class Site < ApplicationRecord
3
+ include StaticCacheInvalidation
4
+
3
5
  belongs_to :owner, polymorphic: true, optional: true
4
6
 
5
7
  has_one :site_setting, dependent: :destroy
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class SiteSetting < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  before_validation :set_defaults
6
7
  after_save :sync_site_summary
@@ -0,0 +1,30 @@
1
+ module Plum
2
+ # Flushes the static page cache whenever content that appears in rendered
3
+ # pages changes. Flushing is deletion-only and per-site, so being liberal
4
+ # here costs one lazy re-render per page, never a wrong page.
5
+ module StaticCacheInvalidation
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ after_commit :flush_plum_static_cache
10
+ end
11
+
12
+ # Columns that never appear in rendered pages — saves touching only these
13
+ # (draft autosaves) keep the cache warm.
14
+ CACHE_IRRELEVANT_COLUMNS = %w[draft_data updated_at].freeze
15
+
16
+ private
17
+
18
+ def flush_plum_static_cache
19
+ return unless Plum::StaticCache.enabled?
20
+
21
+ changed = respond_to?(:saved_changes) ? saved_changes.keys : []
22
+ return if changed.present? && (changed - CACHE_IRRELEVANT_COLUMNS).empty?
23
+
24
+ owner = is_a?(Plum::Site) ? self : (respond_to?(:site) ? site : nil)
25
+ Plum::StaticCache.flush_site!(owner)
26
+ rescue StandardError => e
27
+ Rails.logger.error("[Plum] static cache flush failed: #{e.message}")
28
+ end
29
+ end
30
+ end
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class Taxonomy < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  has_many :terms, dependent: :destroy
6
7
 
@@ -1,6 +1,7 @@
1
1
  module Plum
2
2
  class Term < ApplicationRecord
3
3
  include SiteScoped
4
+ include StaticCacheInvalidation
4
5
 
5
6
  belongs_to :taxonomy
6
7