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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +196 -0
- data/README.md +79 -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/services/plum/site_archive.rb +367 -0
- 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 +356 -0
- data/docs/portability.md +40 -0
- data/docs/roadmap.md +71 -69
- data/docs/static-caching.md +163 -0
- data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
- data/lib/plum/configuration.rb +12 -1
- data/lib/plum/engine.rb +10 -1
- 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/tasks/plum_config.rake +48 -0
- data/lib/tasks/plum_portability.rake +62 -0
- data/lib/tasks/plum_styles.rake +14 -9
- metadata +26 -5
|
@@ -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
data/app/models/plum/term.rb
CHANGED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Plum
|
|
4
|
+
# Config-as-code (phase 1): the content model lives as YAML files in the
|
|
5
|
+
# host repo and syncs into the database, like db:migrate for content types.
|
|
6
|
+
#
|
|
7
|
+
# plum/content_types/posts.yml -> Plum::ContentType (blueprint)
|
|
8
|
+
# plum/fieldsets/seo.yml -> Plum::Fieldset
|
|
9
|
+
#
|
|
10
|
+
# Files are the source of truth; `apply` upserts the DB from them, `export`
|
|
11
|
+
# writes the DB back out (bootstrap + CP write-back later), and `check`
|
|
12
|
+
# reports drift for CI. Content (entries, terms, assets) is never touched.
|
|
13
|
+
class ConfigSync
|
|
14
|
+
Result = Struct.new(:created, :updated, :unchanged, :deleted, keyword_init: true) do
|
|
15
|
+
def summary
|
|
16
|
+
"#{created.length} created, #{updated.length} updated, #{unchanged.length} unchanged, #{deleted.length} deleted"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class DriftError < StandardError; end
|
|
21
|
+
class UnsafePruneError < StandardError; end
|
|
22
|
+
|
|
23
|
+
CONTENT_TYPE_DIR = "content_types".freeze
|
|
24
|
+
FIELDSET_DIR = "fieldsets".freeze
|
|
25
|
+
|
|
26
|
+
def self.export(site:, dir:)
|
|
27
|
+
new(site: site, dir: dir).export
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.apply(site:, dir:, prune: false, force: false)
|
|
31
|
+
new(site: site, dir: dir).apply(prune: prune, force: force)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.check(site:, dir:)
|
|
35
|
+
new(site: site, dir: dir).check
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(site:, dir:)
|
|
39
|
+
@site = site
|
|
40
|
+
@dir = Pathname(dir)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# DB -> files. Mirrors the database exactly: stale files for handles that
|
|
44
|
+
# no longer exist are removed.
|
|
45
|
+
def export
|
|
46
|
+
written = []
|
|
47
|
+
written += export_kind(CONTENT_TYPE_DIR, content_types.order(:handle)) { |record| content_type_config(record) }
|
|
48
|
+
written += export_kind(FIELDSET_DIR, fieldsets.order(:handle)) { |record| fieldset_config(record) }
|
|
49
|
+
written
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Files -> DB. Upserts by handle inside a transaction. Deleting requires
|
|
53
|
+
# prune: true, and deleting a content type that still has entries
|
|
54
|
+
# additionally requires force: true.
|
|
55
|
+
def apply(prune: false, force: false)
|
|
56
|
+
result = Result.new(created: [], updated: [], unchanged: [], deleted: [])
|
|
57
|
+
|
|
58
|
+
site.transaction do
|
|
59
|
+
apply_content_types(result)
|
|
60
|
+
apply_fieldsets(result)
|
|
61
|
+
prune_missing(result, force: force) if prune
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns human-readable drift lines; empty means files and DB agree.
|
|
68
|
+
def check
|
|
69
|
+
drift = []
|
|
70
|
+
drift += check_kind(CONTENT_TYPE_DIR, content_types)
|
|
71
|
+
drift += check_kind(FIELDSET_DIR, fieldsets)
|
|
72
|
+
drift
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
attr_reader :site, :dir
|
|
78
|
+
|
|
79
|
+
# Fresh relations every time — going through site.content_types would
|
|
80
|
+
# cache the association on the site instance and hide later DB changes
|
|
81
|
+
# from repeated check/apply calls.
|
|
82
|
+
def content_types
|
|
83
|
+
ContentType.for_site(site)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def fieldsets
|
|
87
|
+
Fieldset.for_site(site)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def export_kind(subdir, records)
|
|
91
|
+
path = dir.join(subdir)
|
|
92
|
+
path.mkpath
|
|
93
|
+
keep = []
|
|
94
|
+
|
|
95
|
+
records.each do |record|
|
|
96
|
+
file = path.join("#{record.handle}.yml")
|
|
97
|
+
file.write(yaml_for(yield(record)))
|
|
98
|
+
keep << file
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
path.glob("*.yml").each { |file| file.delete unless keep.include?(file) }
|
|
102
|
+
keep
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def apply_content_types(result)
|
|
106
|
+
each_config(CONTENT_TYPE_DIR) do |config, file|
|
|
107
|
+
record = content_types.find_or_initialize_by(handle: config.fetch("handle"))
|
|
108
|
+
record.site = site if record.new_record?
|
|
109
|
+
record.name = config["name"]
|
|
110
|
+
record.icon = config["icon"]
|
|
111
|
+
record.singleton = config["singleton"] unless config["singleton"].nil?
|
|
112
|
+
record.blueprint = merged_blueprint(record, config)
|
|
113
|
+
track(result, record, file)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def apply_fieldsets(result)
|
|
118
|
+
each_config(FIELDSET_DIR) do |config, file|
|
|
119
|
+
record = fieldsets.find_or_initialize_by(handle: config.fetch("handle"))
|
|
120
|
+
record.site = site if record.new_record?
|
|
121
|
+
record.name = config["name"]
|
|
122
|
+
record.fields = config["fields"] || []
|
|
123
|
+
track(result, record, file)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Blueprint keys the files don't know about are preserved so config
|
|
128
|
+
# written by newer Plum versions survives a sync from older files.
|
|
129
|
+
def merged_blueprint(record, config)
|
|
130
|
+
blueprint = (record.blueprint || {}).deep_dup
|
|
131
|
+
blueprint["fields"] = config["fields"] || []
|
|
132
|
+
if config["route_prefix"].present?
|
|
133
|
+
blueprint["route_prefix"] = config["route_prefix"]
|
|
134
|
+
else
|
|
135
|
+
blueprint.delete("route_prefix")
|
|
136
|
+
end
|
|
137
|
+
blueprint
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def track(result, record, file)
|
|
141
|
+
if record.new_record?
|
|
142
|
+
record.save!
|
|
143
|
+
result.created << record.handle
|
|
144
|
+
elsif record.changed?
|
|
145
|
+
record.save!
|
|
146
|
+
result.updated << record.handle
|
|
147
|
+
else
|
|
148
|
+
result.unchanged << record.handle
|
|
149
|
+
end
|
|
150
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
151
|
+
raise ActiveRecord::RecordInvalid.new(e.record), "#{file.basename}: #{e.message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def prune_missing(result, force:)
|
|
155
|
+
file_handles = handles_in(CONTENT_TYPE_DIR)
|
|
156
|
+
content_types.where.not(handle: file_handles).find_each do |record|
|
|
157
|
+
if record.entries.exists? && !force
|
|
158
|
+
raise UnsafePruneError,
|
|
159
|
+
"Content type '#{record.handle}' has #{record.entries.count} entries; " \
|
|
160
|
+
"re-run with FORCE=1 to delete them"
|
|
161
|
+
end
|
|
162
|
+
record.destroy!
|
|
163
|
+
result.deleted << record.handle
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
fieldsets.where.not(handle: handles_in(FIELDSET_DIR)).find_each do |record|
|
|
167
|
+
record.destroy!
|
|
168
|
+
result.deleted << record.handle
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handles_in(subdir)
|
|
173
|
+
handles = []
|
|
174
|
+
each_config(subdir) { |config, _file| handles << config.fetch("handle") }
|
|
175
|
+
handles
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def check_kind(subdir, records)
|
|
179
|
+
drift = []
|
|
180
|
+
configs = {}
|
|
181
|
+
each_config(subdir) { |config, _file| configs[config.fetch("handle")] = config }
|
|
182
|
+
|
|
183
|
+
db = records.index_by(&:handle)
|
|
184
|
+
configs.each do |handle, config|
|
|
185
|
+
record = db[handle]
|
|
186
|
+
if record.nil?
|
|
187
|
+
drift << "#{subdir}/#{handle}: in files but not in the database"
|
|
188
|
+
elsif normalize(subdir, config_for(subdir, record)) != normalize(subdir, config)
|
|
189
|
+
drift << "#{subdir}/#{handle}: files and database differ"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
(db.keys - configs.keys).each do |handle|
|
|
193
|
+
drift << "#{subdir}/#{handle}: in the database but not in files"
|
|
194
|
+
end
|
|
195
|
+
drift
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def config_for(subdir, record)
|
|
199
|
+
subdir == CONTENT_TYPE_DIR ? content_type_config(record) : fieldset_config(record)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Key order and empty-vs-absent values must not register as drift.
|
|
203
|
+
def normalize(subdir, config)
|
|
204
|
+
if subdir == CONTENT_TYPE_DIR
|
|
205
|
+
{
|
|
206
|
+
"name" => config["name"].to_s,
|
|
207
|
+
"handle" => config["handle"].to_s,
|
|
208
|
+
"icon" => config["icon"].presence,
|
|
209
|
+
"singleton" => !!config["singleton"],
|
|
210
|
+
"route_prefix" => config["route_prefix"].presence,
|
|
211
|
+
"fields" => config["fields"] || []
|
|
212
|
+
}
|
|
213
|
+
else
|
|
214
|
+
{
|
|
215
|
+
"name" => config["name"].to_s,
|
|
216
|
+
"handle" => config["handle"].to_s,
|
|
217
|
+
"fields" => config["fields"] || []
|
|
218
|
+
}
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def content_type_config(record)
|
|
223
|
+
config = {
|
|
224
|
+
"name" => record.name,
|
|
225
|
+
"handle" => record.handle,
|
|
226
|
+
"icon" => record.icon,
|
|
227
|
+
"singleton" => record.singleton,
|
|
228
|
+
"route_prefix" => record.route_prefix,
|
|
229
|
+
"fields" => record.fields
|
|
230
|
+
}
|
|
231
|
+
config.reject { |_key, value| value.nil? }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def fieldset_config(record)
|
|
235
|
+
{ "name" => record.name, "handle" => record.handle, "fields" => record.fields || [] }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def each_config(subdir)
|
|
239
|
+
dir.join(subdir).glob("*.yml").sort.each do |file|
|
|
240
|
+
config = YAML.safe_load(file.read, aliases: true)
|
|
241
|
+
raise DriftError, "#{file} is not a YAML mapping" unless config.is_a?(Hash)
|
|
242
|
+
|
|
243
|
+
config["handle"] ||= file.basename(".yml").to_s
|
|
244
|
+
yield config, file
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def yaml_for(config)
|
|
249
|
+
config.to_yaml
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|