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,177 @@
1
+ module Plum
2
+ # Field-by-field comparison between an entry's live content and its working
3
+ # draft, tokenized for git-style rendering (equal / deleted / inserted
4
+ # segments). Rich text is compared as readable text, structured fields as
5
+ # pretty-printed JSON.
6
+ class DraftDiff
7
+ # Beyond this many differing tokens, fall back to a wholesale
8
+ # replaced-block diff instead of an O(n*m) LCS table.
9
+ TOKEN_LIMIT = 1500
10
+
11
+ FieldChange = Struct.new(:handle, :label, :segments, keyword_init: true)
12
+
13
+ def initialize(entry)
14
+ @entry = entry
15
+ @content_type = entry.content_type
16
+ end
17
+
18
+ def changes
19
+ @changes ||= build_changes
20
+ end
21
+
22
+ def any?
23
+ changes.any?
24
+ end
25
+
26
+ # Word-level diff as [[op, text], ...] with op in :eq/:del/:ins.
27
+ # Whitespace is kept as tokens so spacing survives reconstruction.
28
+ def self.word_diff(old_text, new_text)
29
+ a = tokenize(old_text)
30
+ b = tokenize(new_text)
31
+
32
+ prefix = common_prefix_length(a, b)
33
+ suffix = common_suffix_length(a, b, prefix)
34
+ middle_a = a[prefix...(a.length - suffix)]
35
+ middle_b = b[prefix...(b.length - suffix)]
36
+
37
+ segments = []
38
+ segments << [ :eq, a.first(prefix).join ] if prefix.positive?
39
+ segments.concat(diff_middle(middle_a, middle_b))
40
+ segments << [ :eq, a.last(suffix).join ] if suffix.positive?
41
+ merge_segments(segments)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :entry, :content_type
47
+
48
+ def build_changes
49
+ list = []
50
+
51
+ if entry.draft_title.to_s != entry.title.to_s
52
+ list << FieldChange.new(handle: "title", label: "Title",
53
+ segments: self.class.word_diff(entry.title.to_s, entry.draft_title.to_s))
54
+ end
55
+
56
+ live_data = entry.data.to_h
57
+ draft_data = entry.draft_data.to_h["data"] || {}
58
+
59
+ (live_data.keys | draft_data.keys).each do |handle|
60
+ field = field_definition(handle)
61
+ live = normalize(live_data[handle], field)
62
+ draft = normalize(draft_data.key?(handle) ? draft_data[handle] : live_data[handle], field)
63
+ next if live == draft
64
+
65
+ list << FieldChange.new(handle: handle, label: label_for(handle, field),
66
+ segments: self.class.word_diff(live, draft))
67
+ end
68
+
69
+ list
70
+ end
71
+
72
+ def field_definition(handle)
73
+ content_type.fields.find { |field| field["handle"].to_s == handle }
74
+ end
75
+
76
+ def label_for(handle, field)
77
+ field&.dig("label").presence || handle.humanize
78
+ end
79
+
80
+ def normalize(value, field)
81
+ case value
82
+ when nil then ""
83
+ when Hash, Array then JSON.pretty_generate(value)
84
+ when String
85
+ field&.dig("type") == "rich_text" ? html_to_text(value) : value
86
+ else value.to_s
87
+ end
88
+ end
89
+
90
+ def html_to_text(html)
91
+ text = html.gsub(%r{<(br|/p|/h[1-6]|/li|/blockquote|/div|/tr|/figcaption)[^>]*>}i, "\n")
92
+ text = ActionController::Base.helpers.strip_tags(text).to_s
93
+ text.split("\n").map(&:strip).join("\n").gsub(/\n{3,}/, "\n\n").strip
94
+ end
95
+
96
+ class << self
97
+ private
98
+
99
+ def tokenize(text)
100
+ text.to_s.scan(/\S+|\s+/)
101
+ end
102
+
103
+ def common_prefix_length(a, b)
104
+ limit = [ a.length, b.length ].min
105
+ (0...limit).each { |i| return i if a[i] != b[i] }
106
+ limit
107
+ end
108
+
109
+ def common_suffix_length(a, b, prefix)
110
+ limit = [ a.length, b.length ].min - prefix
111
+ (0...limit).each { |i| return i if a[a.length - 1 - i] != b[b.length - 1 - i] }
112
+ limit
113
+ end
114
+
115
+ def diff_middle(a, b)
116
+ return [] if a.empty? && b.empty?
117
+ return [ [ :ins, b.join ] ] if a.empty?
118
+ return [ [ :del, a.join ] ] if b.empty?
119
+ if a.length > TOKEN_LIMIT || b.length > TOKEN_LIMIT
120
+ return [ [ :del, a.join ], [ :ins, b.join ] ]
121
+ end
122
+
123
+ lcs_segments(a, b)
124
+ end
125
+
126
+ # Standard LCS dynamic program with backtracking; inputs are bounded by
127
+ # TOKEN_LIMIT after prefix/suffix trimming.
128
+ def lcs_segments(a, b)
129
+ rows = a.length + 1
130
+ cols = b.length + 1
131
+ table = Array.new(rows) { Array.new(cols, 0) }
132
+
133
+ (a.length - 1).downto(0) do |i|
134
+ (b.length - 1).downto(0) do |j|
135
+ table[i][j] = if a[i] == b[j]
136
+ table[i + 1][j + 1] + 1
137
+ else
138
+ [ table[i + 1][j], table[i][j + 1] ].max
139
+ end
140
+ end
141
+ end
142
+
143
+ segments = []
144
+ i = 0
145
+ j = 0
146
+ while i < a.length && j < b.length
147
+ if a[i] == b[j]
148
+ segments << [ :eq, a[i] ]
149
+ i += 1
150
+ j += 1
151
+ elsif table[i + 1][j] >= table[i][j + 1]
152
+ segments << [ :del, a[i] ]
153
+ i += 1
154
+ else
155
+ segments << [ :ins, b[j] ]
156
+ j += 1
157
+ end
158
+ end
159
+ segments.concat(a[i..].map { |token| [ :del, token ] })
160
+ segments.concat(b[j..].map { |token| [ :ins, token ] })
161
+ segments
162
+ end
163
+
164
+ def merge_segments(segments)
165
+ segments.each_with_object([]) do |(op, text), merged|
166
+ next if text.empty?
167
+
168
+ if merged.last && merged.last[0] == op
169
+ merged.last[1] += text
170
+ else
171
+ merged << [ op, text.dup ]
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -24,11 +24,22 @@ module Plum
24
24
 
25
25
  def hidden_inputs
26
26
  [
27
- hidden_input(form["csrf_param"], form["csrf_token"]),
27
+ honeypot_input,
28
28
  hidden_input("return_to", form["return_to"])
29
29
  ].compact.join("\n ")
30
30
  end
31
31
 
32
+ # Spam trap instead of a CSRF token: tokens are per-session, which would
33
+ # both break under static caching and force a session cookie onto every
34
+ # visitor. Bots fill this in; humans never see it.
35
+ def honeypot_input
36
+ <<~HTML.strip
37
+ <div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true">
38
+ <input type="text" name="form_submission[website]" tabindex="-1" autocomplete="off">
39
+ </div>
40
+ HTML
41
+ end
42
+
32
43
  def fields_html
33
44
  Array(form["fields"]).map { |field| field_html(field) }.join("\n ")
34
45
  end
@@ -125,8 +125,6 @@ module Plum
125
125
  "handle" => form.handle,
126
126
  "fields" => form.form_fields,
127
127
  "action" => public_form_path(form),
128
- "csrf_token" => form_authenticity_token,
129
- "csrf_param" => "authenticity_token",
130
128
  "return_to" => controller.request.fullpath
131
129
  }
132
130
  end
@@ -0,0 +1,140 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <title>Writing — <%= Plum.configuration.cp_name %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
9
+ <%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
10
+ <%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
11
+ <%= javascript_importmap_tags %>
12
+ <script type="module">import "plum/application"</script>
13
+ <style>
14
+ :root { --plum-accent: <%= Plum.configuration.cp_accent_color %>; }
15
+
16
+ body.plum-write {
17
+ background: #faf9f7;
18
+ color: #1f2937;
19
+ }
20
+
21
+ .plum-write-bar {
22
+ position: fixed;
23
+ inset: 0 0 auto 0;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: space-between;
27
+ padding: 0.875rem 1.5rem;
28
+ background: linear-gradient(to bottom, rgba(250,249,247,0.97), rgba(250,249,247,0.85) 70%, rgba(250,249,247,0));
29
+ z-index: 10;
30
+ opacity: 0.35;
31
+ transition: opacity 0.25s ease;
32
+ }
33
+ .plum-write-bar:hover, .plum-write-bar:focus-within,
34
+ .plum-write-bar.plum-write-bar--active { opacity: 1; }
35
+
36
+ .plum-write-save-state {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 0.45rem;
40
+ padding: 0.3rem 0.75rem;
41
+ border-radius: 9999px;
42
+ font-size: 0.8125rem;
43
+ font-weight: 500;
44
+ color: #6b7280;
45
+ background: rgba(0,0,0,0.04);
46
+ transition: color 0.2s ease, background 0.2s ease;
47
+ }
48
+ .plum-write-save-state::before {
49
+ content: "";
50
+ width: 0.5rem;
51
+ height: 0.5rem;
52
+ border-radius: 9999px;
53
+ background: #9ca3af;
54
+ flex-shrink: 0;
55
+ transition: background 0.2s ease;
56
+ }
57
+ .plum-write-save-state[data-state="dirty"]::before { background: #f59e0b; }
58
+ .plum-write-save-state[data-state="saving"]::before {
59
+ background: #f59e0b;
60
+ animation: plum-write-pulse 1s ease-in-out infinite;
61
+ }
62
+ .plum-write-save-state[data-state="saved"] { color: #047857; background: #ecfdf5; }
63
+ .plum-write-save-state[data-state="saved"]::before { background: #10b981; }
64
+ .plum-write-save-state[data-state="error"] { color: #b91c1c; background: #fef2f2; }
65
+ .plum-write-save-state[data-state="error"]::before { background: #ef4444; }
66
+
67
+ @keyframes plum-write-pulse {
68
+ 50% { opacity: 0.3; }
69
+ }
70
+
71
+ .plum-write-page {
72
+ max-width: 44rem;
73
+ margin: 0 auto;
74
+ padding: 6.5rem 1.5rem 40vh;
75
+ }
76
+
77
+ .plum-write-title {
78
+ width: 100%;
79
+ border: 0;
80
+ background: transparent;
81
+ resize: none;
82
+ overflow: hidden;
83
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
84
+ font-size: 2.75rem;
85
+ line-height: 1.15;
86
+ font-weight: 700;
87
+ color: #111827;
88
+ outline: none;
89
+ padding: 0;
90
+ }
91
+ .plum-write-title::placeholder { color: #d1d5db; }
92
+
93
+ .plum-write-body { margin-top: 2rem; }
94
+ .plum-write-body lexxy-editor {
95
+ display: block;
96
+ border: 0;
97
+ background: transparent;
98
+ box-shadow: none;
99
+ }
100
+ .plum-write-body .lexxy-content,
101
+ .plum-write-body lexxy-editor [contenteditable] {
102
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
103
+ font-size: 1.3125rem;
104
+ line-height: 1.8;
105
+ color: #1f2937;
106
+ min-height: 55vh;
107
+ border: 0 !important;
108
+ box-shadow: none !important;
109
+ background: transparent !important;
110
+ padding: 0;
111
+ outline: none;
112
+ }
113
+ .plum-write-body lexxy-toolbar {
114
+ position: sticky;
115
+ top: 0;
116
+ opacity: 0.25;
117
+ transition: opacity 0.25s ease;
118
+ background: transparent;
119
+ border: 0;
120
+ }
121
+ .plum-write-body:hover lexxy-toolbar,
122
+ .plum-write-body:focus-within lexxy-toolbar { opacity: 1; }
123
+
124
+ .plum-write-status {
125
+ position: fixed;
126
+ right: 1.5rem;
127
+ bottom: 1.25rem;
128
+ display: flex;
129
+ gap: 1rem;
130
+ font-size: 0.75rem;
131
+ letter-spacing: 0.02em;
132
+ color: #9ca3af;
133
+ z-index: 10;
134
+ }
135
+ </style>
136
+ </head>
137
+ <body class="plum-write h-full">
138
+ <%= yield %>
139
+ </body>
140
+ </html>
@@ -1,6 +1,13 @@
1
- <div class="mb-8">
2
- <h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
3
- <p class="mt-1 text-sm text-gray-500">Welcome back<%= ", #{current_user_label}" if current_user_label %></p>
1
+ <div class="mb-8 flex items-start justify-between">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
4
+ <p class="mt-1 text-sm text-gray-500">Welcome back<%= ", #{current_user_label}" if current_user_label %></p>
5
+ </div>
6
+ <% if Plum::StaticCache.enabled? %>
7
+ <%= button_to "Clear page cache", cp_static_cache_path, method: :delete,
8
+ data: { turbo_confirm: "Clear the cached pages for this site? They re-render on the next visit." },
9
+ class: "px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 cursor-pointer" %>
10
+ <% end %>
4
11
  </div>
5
12
 
6
13
  <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
@@ -17,7 +17,7 @@
17
17
  <div class="space-y-6">
18
18
  <div>
19
19
  <%= f.label :title, class: "block text-sm font-medium text-gray-700" %>
20
- <%= f.text_field :title, class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" %>
20
+ <%= f.text_field :title, value: entry.draft_title, class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" %>
21
21
  </div>
22
22
 
23
23
  <div>
@@ -35,7 +35,7 @@
35
35
  <% @content_type.fields.each do |field| %>
36
36
  <% field_handle = field["handle"].to_s %>
37
37
  <% field_id = "entry_data_#{field_handle.parameterize}" %>
38
- <% field_value = entry.data&.dig(field_handle) %>
38
+ <% field_value = entry.draft_field_value(field_handle) %>
39
39
  <% field_value = field["default"] if entry.new_record? && field_value.nil? && field.key?("default") %>
40
40
  <% condition = field["condition"].presence %>
41
41
  <div class="<%= 'border-b border-gray-200 pb-2 pt-4' if field['type'] == 'section' %>" style="grid-column: span <%= field['type'] == 'section' ? 12 : (field['width'].presence || 12) %> / span <%= field['type'] == 'section' ? 12 : (field['width'].presence || 12) %>;"<% if condition %> data-field-condition="<%= condition.to_json %>"<% end %>>
@@ -0,0 +1,40 @@
1
+ <div class="mb-8 flex flex-wrap items-center justify-between gap-4">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Review draft changes</h1>
4
+ <p class="mt-1 text-sm text-gray-500">
5
+ <%= @entry.title %> &mdash;
6
+ <span class="inline-flex items-center gap-3">
7
+ <span><span class="inline-block h-2.5 w-2.5 rounded-sm bg-red-200 align-middle"></span> removed from live</span>
8
+ <span><span class="inline-block h-2.5 w-2.5 rounded-sm bg-green-200 align-middle"></span> added in draft</span>
9
+ </span>
10
+ </p>
11
+ </div>
12
+ <div class="flex items-center gap-3">
13
+ <%= link_to "Back to editor", edit_cp_content_type_entry_path(@content_type, @entry),
14
+ class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
15
+ <%= link_to "Continue writing", write_cp_content_type_entry_path(@content_type, @entry),
16
+ class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
17
+ <%= button_to "Discard draft", discard_draft_cp_content_type_entry_path(@content_type, @entry), method: :delete,
18
+ data: { turbo_confirm: "Discard the draft changes and go back to the published version?" },
19
+ class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-white hover:bg-red-50 cursor-pointer" %>
20
+ <%= button_to "Publish changes", publish_draft_cp_content_type_entry_path(@content_type, @entry),
21
+ class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 cursor-pointer" %>
22
+ </div>
23
+ </div>
24
+
25
+ <% if @diff.any? %>
26
+ <div class="space-y-6">
27
+ <% @diff.changes.each do |change| %>
28
+ <div class="bg-white shadow rounded-lg overflow-hidden">
29
+ <div class="px-6 py-3 border-b border-gray-200 bg-gray-50">
30
+ <h2 class="text-sm font-semibold text-gray-700"><%= change.label %></h2>
31
+ </div>
32
+ <div class="px-6 py-5 text-[0.9375rem] leading-7 text-gray-800" style="white-space: pre-wrap; overflow-wrap: anywhere;"><% change.segments.each do |op, text| %><% case op %><% when :eq %><%= text %><% when :del %><del class="rounded-sm bg-red-100 px-0.5 text-red-800 decoration-red-400"><%= text %></del><% when :ins %><ins class="rounded-sm bg-green-100 px-0.5 text-green-800 no-underline"><%= text %></ins><% end %><% end %></div>
33
+ </div>
34
+ <% end %>
35
+ </div>
36
+ <% else %>
37
+ <div class="bg-white shadow rounded-lg p-10 text-center">
38
+ <p class="text-sm text-gray-500">The draft matches the live version &mdash; nothing to review.</p>
39
+ </div>
40
+ <% end %>
@@ -1,6 +1,13 @@
1
1
  <div class="flex items-center justify-between mb-8">
2
2
  <h1 class="text-2xl font-bold text-gray-900">Edit <%= @entry.title %></h1>
3
3
  <div class="flex items-center gap-3">
4
+ <% if @content_type.fields.any? { |field| field["type"] == "rich_text" && field["handle"].present? } %>
5
+ <%= link_to write_cp_content_type_entry_path(@content_type, @entry),
6
+ class: "inline-flex items-center gap-1.5 px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" do %>
7
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.862 4.487z"/></svg>
8
+ Write
9
+ <% end %>
10
+ <% end %>
4
11
  <%= link_to "History (#{@entry.revisions.count})", cp_content_type_entry_revisions_path(@content_type, @entry),
5
12
  class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
6
13
  <% if @entry.published? %>
@@ -10,6 +17,23 @@
10
17
  </div>
11
18
  </div>
12
19
 
20
+ <% if @entry.has_draft? %>
21
+ <div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 flex flex-wrap items-center justify-between gap-3">
22
+ <p class="text-sm text-amber-800">
23
+ You're seeing unpublished draft changes. The live site still shows the last published version &mdash; <strong>Save</strong> publishes what's below.
24
+ </p>
25
+ <div class="flex items-center gap-2">
26
+ <%= link_to "Review changes", diff_cp_content_type_entry_path(@content_type, @entry),
27
+ class: "px-3 py-1.5 rounded-md border border-amber-300 bg-white text-sm font-medium text-amber-800 hover:bg-amber-100" %>
28
+ <%= link_to "Continue writing", write_cp_content_type_entry_path(@content_type, @entry),
29
+ class: "px-3 py-1.5 rounded-md border border-amber-300 bg-white text-sm font-medium text-amber-800 hover:bg-amber-100" %>
30
+ <%= button_to "Discard draft", discard_draft_cp_content_type_entry_path(@content_type, @entry), method: :delete,
31
+ data: { turbo_confirm: "Discard the draft changes and go back to the published version?" },
32
+ class: "px-3 py-1.5 rounded-md border border-amber-300 bg-white text-sm font-medium text-amber-800 hover:bg-amber-100 cursor-pointer" %>
33
+ </div>
34
+ </div>
35
+ <% end %>
36
+
13
37
  <%= render "form", entry: @entry %>
14
38
 
15
39
  <div class="mt-6 rounded-lg bg-white p-6 shadow">
@@ -29,6 +29,9 @@
29
29
  <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= entry.published? ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800' %>">
30
30
  <%= entry.status %>
31
31
  </span>
32
+ <% if entry.has_draft? %>
33
+ <span class="ml-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800" title="Has unpublished draft changes">draft edits</span>
34
+ <% end %>
32
35
  </td>
33
36
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= entry.updated_at.strftime("%b %d, %Y") %></td>
34
37
  <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@@ -0,0 +1,60 @@
1
+ <% field_handle = @field["handle"].to_s %>
2
+ <% field_value = @entry.draft_field_value(field_handle) %>
3
+ <% drafting = @entry.published? %>
4
+
5
+ <div data-controller="plum--write"
6
+ data-plum--write-publish-url-value="<%= publish_draft_cp_content_type_entry_path(@content_type, @entry) %>">
7
+ <header class="plum-write-bar" data-plum--write-target="bar">
8
+ <%= link_to "&larr; Back to editor".html_safe, edit_cp_content_type_entry_path(@content_type, @entry),
9
+ class: "text-sm font-medium text-gray-500 hover:text-gray-900" %>
10
+ <div class="flex items-center gap-4">
11
+ <span class="plum-write-save-state" data-plum--write-target="status" data-state="idle">
12
+ <span data-plum--write-target="statusText"><%= @entry.has_draft? ? "Draft saved" : "Saved" %></span>
13
+ </span>
14
+ <% if drafting %>
15
+ <span class="text-xs text-gray-400">Published &mdash; edits save as a draft</span>
16
+ <%= link_to "Review changes", diff_cp_content_type_entry_path(@content_type, @entry),
17
+ class: "text-sm font-medium text-gray-500 hover:text-gray-900" %>
18
+ <button type="button" data-action="plum--write#save"
19
+ class="px-4 py-1.5 rounded-md text-sm font-medium text-gray-700 border border-gray-300 bg-white hover:bg-gray-50 cursor-pointer">
20
+ Save draft
21
+ </button>
22
+ <button type="button" data-action="plum--write#publishDraft"
23
+ class="px-4 py-1.5 rounded-md text-sm font-medium text-white plum-btn-primary cursor-pointer">
24
+ Publish changes
25
+ </button>
26
+ <% else %>
27
+ <span class="text-xs text-gray-400"><%= @entry.status.titleize %></span>
28
+ <button type="button" data-action="plum--write#save"
29
+ class="px-4 py-1.5 rounded-md text-sm font-medium text-white plum-btn-primary cursor-pointer">
30
+ Save
31
+ </button>
32
+ <% end %>
33
+ </div>
34
+ </header>
35
+
36
+ <main class="plum-write-page">
37
+ <%= form_with model: [:cp, @content_type, @entry], id: "write-form", data: { "plum--write-target": "form" } do |f| %>
38
+ <%= hidden_field_tag "entry[write_mode]", "1" %>
39
+ <%= f.text_area :title, rows: 1, placeholder: "Untitled", autocomplete: "off",
40
+ value: @entry.draft_title,
41
+ class: "plum-write-title",
42
+ data: { "plum--write-target": "title", action: "input->plum--write#titleChanged" } %>
43
+
44
+ <div class="plum-write-body">
45
+ <%= hidden_field_tag "entry[data][#{field_handle}]", field_value, id: "write_field_hidden" %>
46
+ <lexxy-editor value="<%= field_value %>"
47
+ data-direct-upload-url="<%= main_app.rails_direct_uploads_url %>"
48
+ data-blob-url-template="<%= main_app.rails_service_blob_url(":signed_id", ":filename") %>"
49
+ data-hidden-field="write_field_hidden"
50
+ data-plum--write-target="editor"
51
+ class="lexxy-content"
52
+ placeholder="Write something&hellip;"></lexxy-editor>
53
+ </div>
54
+ <% end %>
55
+ </main>
56
+
57
+ <div class="plum-write-status">
58
+ <span data-plum--write-target="words"></span>
59
+ </div>
60
+ </div>
@@ -9,6 +9,10 @@ Plum::Engine.routes.draw do
9
9
  post :apply_fieldset, on: :member
10
10
  resources :entries do
11
11
  patch :image_field, on: :member
12
+ get :write, on: :member
13
+ get :diff, on: :member
14
+ post :publish_draft, on: :member
15
+ delete :discard_draft, on: :member
12
16
  post :translate, on: :member
13
17
  resources :revisions, controller: "entry_revisions", only: [ :index ] do
14
18
  post :restore, on: :member
@@ -27,6 +31,7 @@ Plum::Engine.routes.draw do
27
31
  resource :site_settings, only: [ :show, :edit, :update ]
28
32
  patch "site_settings/image_field", to: "site_settings#image_field", as: :site_settings_image_field
29
33
  resources :themes, only: [ :index, :create, :update ]
34
+ delete "static_cache", to: "static_cache#destroy", as: :static_cache
30
35
  resources :taxonomies do
31
36
  resources :terms, except: [ :index, :show ]
32
37
  end
@@ -0,0 +1,5 @@
1
+ class AddDraftDataToPlumEntries < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :plum_entries, :draft_data, :json
4
+ end
5
+ end
@@ -0,0 +1,103 @@
1
+ # Proposal: Config as Code
2
+
3
+ Status: phase 1 implemented 2026-08-12 (`Plum::ConfigSync` + `plum:config:export|sync|check`
4
+ rake tasks, content types + fieldsets, one-way). Phases 2-3 (two-way CP
5
+ write-back, dev file watcher, taxonomies/forms) remain as proposed below.
6
+
7
+ ## Problem
8
+
9
+ The content *model* (content type blueprints, taxonomies, form definitions)
10
+ lives only in the database, created by clicking in the control panel. That
11
+ means no git history, no code review, and no repeatable path from development
12
+ to production — adding a field to a live site means re-clicking it in the
13
+ production CP. This is the classic CMS pain (WordPress/ACF sync, Contentful
14
+ migrations, Drupal config management all exist to solve it).
15
+
16
+ ## Principle
17
+
18
+ Split the two kinds of data a CMS holds:
19
+
20
+ - **Content** (entries, terms, nav items, assets, submissions): editor-owned,
21
+ changes constantly, stays in the database. Untouched by this proposal.
22
+ - **Config** (content types + blueprints, taxonomies, forms, fieldsets):
23
+ developer-owned, changes rarely, belongs in version-controlled files.
24
+
25
+ Files are the source of truth; the DB holds a synced copy so rendering and
26
+ the CP keep working exactly as today.
27
+
28
+ ## File layout (host app repo)
29
+
30
+ ```
31
+ plum/
32
+ content_types/posts.yml
33
+ content_types/pages.yml
34
+ taxonomies/topics.yml
35
+ forms/contact.yml
36
+ ```
37
+
38
+ ```yaml
39
+ # plum/content_types/posts.yml
40
+ name: Blog Posts
41
+ handle: posts
42
+ icon: document
43
+ route_prefix: blog
44
+ fields:
45
+ - handle: body
46
+ type: rich_text
47
+ label: Body
48
+ - handle: reading_time
49
+ type: number
50
+ unit: min
51
+ ```
52
+
53
+ ## Mechanics
54
+
55
+ - `Plum::ConfigSync.apply(site:, dir:)` — files → DB. Upsert by handle,
56
+ transactional, flushes the static cache. Runs via `rails plum:sync` and
57
+ optionally on boot.
58
+ - `Plum::ConfigSync.export(site:, dir:)` — DB → files. Bootstraps existing
59
+ sites onto the workflow; also called by the CP after visual blueprint edits
60
+ so the clicky builder and git never diverge (**two-way sync** — the
61
+ Statamic trick that makes both editors and developers happy).
62
+ - Dev: `ActiveSupport::FileUpdateChecker` re-applies changed files on reload,
63
+ same feel as theme editing.
64
+ - CI: `rails plum:sync --check` fails when the DB has drifted from the files
65
+ (hand-edits in prod that were never exported).
66
+
67
+ ## Safety rails
68
+
69
+ - Removing a field from YAML never deletes entry data — entry data is a JSON
70
+ blob, so orphaned keys simply stop rendering (already true today).
71
+ - Renaming a handle is treated as remove+add unless a `renamed_from:` hint is
72
+ given.
73
+ - Deleting a content type that still has entries requires `--force`.
74
+
75
+ ## Multi-site / embedded mode
76
+
77
+ Standalone mode: sync targets the single site; two-way write-back (phase 2)
78
+ is appropriate because the developer owns both the repo and the CP.
79
+
80
+ Embedded/SaaS mode (Table Needs) inverts the flow: the platform owns the
81
+ content model, customer sites share it, and customers must not write YAML
82
+ into the platform repo. So in host mode:
83
+
84
+ - files are authoritative **one-way** (no CP write-back),
85
+ - `plum:config:sync` needs an `ALL_SITES=1` mode that migrates every site's
86
+ model on deploy, like db:migrate for blueprints (not yet implemented),
87
+ - the same files seed newly created sites.
88
+
89
+ ## Configuration
90
+
91
+ ```ruby
92
+ Plum.configure do |config|
93
+ config.config_path = Rails.root.join("plum") # nil disables the feature
94
+ config.config_sync = :two_way # :files_authoritative, :off
95
+ end
96
+ ```
97
+
98
+ ## Phasing & estimate
99
+
100
+ 1. One-way `apply` + `export` + rake tasks, content types + fieldsets only
101
+ (~2–3 days).
102
+ 2. Two-way CP write-back + dev file watcher (~2 days).
103
+ 3. Taxonomies, forms, CI check, docs (~1 day).