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
|
@@ -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
|
+
}
|
data/app/models/plum/asset.rb
CHANGED
data/app/models/plum/entry.rb
CHANGED
|
@@ -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")
|
data/app/models/plum/global.rb
CHANGED
data/app/models/plum/nav_item.rb
CHANGED
data/app/models/plum/nav_menu.rb
CHANGED
data/app/models/plum/site.rb
CHANGED
|
@@ -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
|
data/app/models/plum/taxonomy.rb
CHANGED