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,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).