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,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
 
@@ -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