plum-cms 0.1.2 → 0.2.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +81 -3
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/assets/stylesheets/plum/control_panel.css +1 -1
  6. data/app/assets/tailwind/application.css +42 -0
  7. data/app/controllers/plum/api/v1/entries_controller.rb +48 -0
  8. data/app/controllers/plum/cp/assets_controller.rb +4 -1
  9. data/app/controllers/plum/cp/content_types_controller.rb +25 -2
  10. data/app/controllers/plum/cp/entries_controller.rb +119 -9
  11. data/app/controllers/plum/cp/entry_revisions_controller.rb +27 -0
  12. data/app/controllers/plum/cp/fieldsets_controller.rb +37 -0
  13. data/app/controllers/plum/cp/site_settings_controller.rb +20 -1
  14. data/app/controllers/plum/pages_controller.rb +26 -1
  15. data/app/javascript/controllers/plum/asset_collection_controller.js +18 -0
  16. data/app/javascript/controllers/plum/blueprint_controller.js +202 -25
  17. data/app/javascript/controllers/plum/conditional_fields_controller.js +43 -0
  18. data/app/javascript/controllers/plum/focal_point_controller.js +16 -0
  19. data/app/javascript/controllers/plum/structured_field_controller.js +133 -0
  20. data/app/models/plum/asset.rb +5 -1
  21. data/app/models/plum/content_type.rb +63 -1
  22. data/app/models/plum/entry.rb +225 -1
  23. data/app/models/plum/entry_revision.rb +14 -0
  24. data/app/models/plum/fieldset.rb +17 -0
  25. data/app/models/plum/site.rb +15 -0
  26. data/app/models/plum/user.rb +1 -0
  27. data/app/services/plum/entry_serializer.rb +36 -0
  28. data/app/services/plum/field_expander.rb +12 -1
  29. data/app/services/plum/field_options.rb +21 -0
  30. data/app/services/plum/field_type_registry.rb +88 -0
  31. data/app/services/plum/liquid_context.rb +7 -1
  32. data/app/services/plum/site_archive.rb +367 -0
  33. data/app/views/layouts/plum/cp.html.erb +2 -0
  34. data/app/views/plum/cp/assets/_form.html.erb +13 -1
  35. data/app/views/plum/cp/content_types/_form.html.erb +81 -24
  36. data/app/views/plum/cp/custom_fields/_input.html.erb +5 -0
  37. data/app/views/plum/cp/entries/_form.html.erb +108 -31
  38. data/app/views/plum/cp/entries/edit.html.erb +16 -0
  39. data/app/views/plum/cp/entry_revisions/index.html.erb +25 -0
  40. data/app/views/plum/cp/fieldsets/index.html.erb +30 -0
  41. data/app/views/plum/cp/site_settings/edit.html.erb +12 -0
  42. data/config/plum_routes.rb +15 -0
  43. data/db/engine_migrate/20260807130000_create_plum_entry_revisions.rb +16 -0
  44. data/db/engine_migrate/20260807140000_create_plum_fieldsets.rb +13 -0
  45. data/db/engine_migrate/20260807150000_add_focal_point_to_plum_assets.rb +6 -0
  46. data/db/engine_migrate/20260807160000_add_localization_to_plum_entries.rb +9 -0
  47. data/docs/blueprint-fields.md +55 -0
  48. data/docs/extensions.md +32 -0
  49. data/docs/portability.md +40 -0
  50. data/docs/product-principles.md +73 -0
  51. data/docs/roadmap.md +100 -0
  52. data/docs/site/homepage.md +104 -0
  53. data/docs/site/information-architecture.md +176 -0
  54. data/docs/statamic-parity.md +28 -0
  55. data/docs/vision.md +82 -0
  56. data/lib/plum/engine.rb +3 -1
  57. data/lib/plum/version.rb +1 -1
  58. data/lib/plum.rb +4 -0
  59. data/lib/tasks/plum_portability.rake +37 -0
  60. data/lib/tasks/plum_styles.rake +12 -0
  61. metadata +36 -8
@@ -29,64 +29,90 @@
29
29
  </div>
30
30
 
31
31
  <!-- Dynamic fields based on blueprint -->
32
- <div class="bg-white shadow rounded-lg p-6 space-y-6">
32
+ <div class="bg-white shadow rounded-lg p-6">
33
33
  <h3 class="text-lg font-medium text-gray-900">Content</h3>
34
+ <div class="mt-6 grid grid-cols-12 gap-6" data-controller="plum--conditional-fields">
34
35
  <% @content_type.fields.each do |field| %>
35
36
  <% field_handle = field["handle"].to_s %>
36
37
  <% field_id = "entry_data_#{field_handle.parameterize}" %>
37
- <div>
38
- <label for="<%= field_id %>" class="block text-sm font-medium text-gray-700"><%= field["label"] || field_handle.titleize %></label>
38
+ <% field_value = entry.data&.dig(field_handle) %>
39
+ <% field_value = field["default"] if entry.new_record? && field_value.nil? && field.key?("default") %>
40
+ <% condition = field["condition"].presence %>
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 %>>
42
+ <label for="<%= field_id %>" class="block <%= field['type'] == 'section' ? 'text-lg font-semibold text-gray-900' : 'text-sm font-medium text-gray-700' %>"><%= field["label"] || field_handle.titleize %></label>
43
+ <% if field["instructions"].present? %><p class="mt-1 text-sm text-gray-500"><%= field["instructions"] %></p><% end %>
39
44
  <% case field["type"] %>
45
+ <% when "section" %>
40
46
  <% when "text" %>
41
- <%= text_field_tag "entry[data][#{field_handle}]", entry.data&.dig(field_handle), id: field_id,
47
+ <%= text_field_tag "entry[data][#{field_handle}]", field_value, id: field_id, placeholder: field["placeholder"], required: field["required"],
42
48
  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" %>
43
49
  <% when "textarea" %>
44
- <%= text_area_tag "entry[data][#{field_handle}]", entry.data&.dig(field_handle), id: field_id, rows: 4,
50
+ <%= text_area_tag "entry[data][#{field_handle}]", field_value, id: field_id, rows: 4, placeholder: field["placeholder"], required: field["required"],
45
51
  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" %>
46
52
  <% when "rich_text" %>
47
53
  <div class="mt-1">
48
- <%= hidden_field_tag "entry[data][#{field_handle}]", entry.data&.dig(field_handle), id: "#{field_id}_hidden" %>
49
- <lexxy-editor value="<%= entry.data&.dig(field_handle) %>"
54
+ <%= hidden_field_tag "entry[data][#{field_handle}]", field_value, id: "#{field_id}_hidden" %>
55
+ <lexxy-editor value="<%= field_value %>"
50
56
  data-direct-upload-url="<%= main_app.rails_direct_uploads_url %>"
51
57
  data-blob-url-template="<%= main_app.rails_service_blob_url(":signed_id", ":filename") %>"
52
58
  data-hidden-field="<%= field_id %>_hidden"
53
59
  class="lexxy-content min-h-48 rounded-lg border border-gray-300 bg-white shadow-sm"
54
- placeholder="Write something…"></lexxy-editor>
60
+ placeholder="<%= field['placeholder'].presence || 'Write something…' %>"></lexxy-editor>
55
61
  </div>
56
62
  <% when "number" %>
57
- <%= number_field_tag "entry[data][#{field_handle}]", entry.data&.dig(field_handle), id: field_id,
58
- 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" %>
63
+ <div class="mt-1 flex items-center gap-2">
64
+ <%= number_field_tag "entry[data][#{field_handle}]", field_value, id: field_id, placeholder: field["placeholder"], required: field["required"],
65
+ min: field["min"], max: field["max"], step: field["number_kind"] == "integer" ? 1 : (field["step"].presence || "any"),
66
+ class: "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" %>
67
+ <% if field["unit"].present? %><span class="text-sm font-medium text-gray-500"><%= field["unit"] %></span><% end %>
68
+ </div>
59
69
  <% when "boolean" %>
60
70
  <div class="mt-1">
61
71
  <%= hidden_field_tag "entry[data][#{field_handle}]", "false" %>
62
- <%= check_box_tag "entry[data][#{field_handle}]", "true", ActiveModel::Type::Boolean.new.cast(entry.data&.dig(field_handle)), id: field_id %>
72
+ <%= check_box_tag "entry[data][#{field_handle}]", "true", ActiveModel::Type::Boolean.new.cast(field_value), id: field_id %>
63
73
  </div>
64
74
  <% when "date" %>
65
- <%= date_field_tag "entry[data][#{field_handle}]", entry.data&.dig(field_handle), id: field_id,
66
- 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" %>
75
+ <% date_options = { id: field_id, required: field["required"], min: field["min"], max: field["max"], 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" } %>
76
+ <% if field["date_mode"] == "time" %>
77
+ <%= time_field_tag "entry[data][#{field_handle}]", field_value, date_options %>
78
+ <% elsif field["date_mode"] == "datetime" %>
79
+ <%= datetime_local_field_tag "entry[data][#{field_handle}]", field_value, date_options %>
80
+ <% else %>
81
+ <%= date_field_tag "entry[data][#{field_handle}]", field_value, date_options %>
82
+ <% end %>
67
83
  <% when "select" %>
68
- <% options = Array(field["options"]) %>
84
+ <% options = Plum::FieldOptions.pairs(field["options"]) %>
69
85
  <%= select_tag "entry[data][#{field_handle}]",
70
- options_for_select([["— Select —", ""]] + options.map { |o| [o, o] }, entry.data&.dig(field_handle)),
71
- id: field_id,
86
+ options_for_select([["— Select —", ""]] + options, field_value),
87
+ id: field_id, required: field["required"],
72
88
  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" %>
89
+ <% when "radio", "button_group" %>
90
+ <% options = Plum::FieldOptions.pairs(field["options"]) %>
91
+ <div class="mt-2 flex flex-wrap gap-2">
92
+ <% options.each do |label, value| %>
93
+ <label class="relative cursor-pointer">
94
+ <%= radio_button_tag "entry[data][#{field_handle}]", value, field_value.to_s == value, required: field["required"], class: "peer sr-only" %>
95
+ <span class="block rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 peer-checked:border-purple-500 peer-checked:bg-purple-50 peer-checked:text-purple-700 peer-focus:ring-2 peer-focus:ring-purple-500"><%= label %></span>
96
+ </label>
97
+ <% end %>
98
+ </div>
73
99
  <% when "checkboxes" %>
74
- <% options = Array(field["options"]) %>
75
- <% selected = Array(entry.data&.dig(field_handle)) %>
100
+ <% options = Plum::FieldOptions.pairs(field["options"]) %>
101
+ <% selected = Array(field_value) %>
76
102
  <div class="mt-1 space-y-2">
77
- <% options.each do |option| %>
103
+ <% options.each do |label, value| %>
78
104
  <label class="flex items-center gap-2 text-sm text-gray-700">
79
- <%= check_box_tag "entry[data][#{field_handle}][]", option, selected.include?(option),
105
+ <%= check_box_tag "entry[data][#{field_handle}][]", value, selected.include?(value),
80
106
  class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
81
- <%= option %>
107
+ <%= label %>
82
108
  </label>
83
109
  <% end %>
84
110
  </div>
85
111
  <% when "color" %>
86
112
  <div class="mt-1 flex items-center gap-3">
87
- <%= color_field_tag "entry[data][#{field_handle}]", entry.data&.dig(field_handle) || "#000000", id: field_id,
113
+ <%= color_field_tag "entry[data][#{field_handle}]", field_value || "#000000", id: field_id,
88
114
  class: "h-10 w-14 p-0 border border-gray-300 rounded-md cursor-pointer" %>
89
- <span class="text-sm text-gray-500 font-mono"><%= entry.data&.dig(field_handle) %></span>
115
+ <span class="text-sm text-gray-500 font-mono"><%= field_value %></span>
90
116
  </div>
91
117
  <% when "taxonomy" %>
92
118
  <% taxonomy_handle = field["taxonomy"].to_s %>
@@ -105,23 +131,62 @@
105
131
  <% end %>
106
132
  </div>
107
133
  <% when "url" %>
108
- <%= url_field_tag "entry[data][#{field_handle}]", entry.data&.dig(field_handle), id: field_id,
109
- placeholder: "https://",
134
+ <%= url_field_tag "entry[data][#{field_handle}]", field_value, id: field_id,
135
+ placeholder: field["placeholder"].presence || "https://", required: field["required"],
110
136
  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" %>
111
137
  <% when "image" %>
112
138
  <%= render "plum/cp/shared/image_picker",
113
139
  input_name: "entry[data][#{field_handle}]",
114
- value: entry.data&.dig(field_handle).to_s,
140
+ value: field_value.to_s,
115
141
  field_id: field_id,
116
142
  save_url: entry.persisted? ? "#{cp_content_type_entry_path(@content_type, entry)}/image_field" : nil,
117
143
  field_handle: field_handle %>
144
+ <% when "images" %>
145
+ <% selected_asset_ids = Array(field_value).map(&:to_i) %>
146
+ <div class="mt-2 space-y-3" data-controller="plum--asset-collection" <% if field['max_items'].present? %>data-plum--asset-collection-max-value="<%= field['max_items'] %>"<% end %>>
147
+ <%= hidden_field_tag "entry[data][#{field_handle}][]", "" %>
148
+ <div class="flex items-center justify-between">
149
+ <p class="text-xs text-gray-500">Choose images from the asset library.</p>
150
+ <span class="text-xs font-medium text-gray-600" data-plum--asset-collection-target="count"></span>
151
+ </div>
152
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
153
+ <% @assets.each do |asset| %>
154
+ <label class="group relative cursor-pointer overflow-hidden rounded-lg border border-gray-200 bg-gray-50 p-2 has-checked:border-purple-500 has-checked:ring-2 has-checked:ring-purple-500">
155
+ <%= check_box_tag "entry[data][#{field_handle}][]", asset.id, selected_asset_ids.include?(asset.id), class: "sr-only", data: { "plum--asset-collection-target": "checkbox", action: "change->plum--asset-collection#refresh" } %>
156
+ <%= image_tag asset.url, alt: asset.alt_text.to_s, class: "aspect-square w-full rounded-md object-cover" %>
157
+ <span class="mt-1 block truncate text-xs text-gray-600"><%= asset.filename %></span>
158
+ </label>
159
+ <% end %>
160
+ </div>
161
+ <% if @assets.empty? %><p class="text-sm text-gray-500">No images available. <%= link_to "Upload images", cp_assets_path, class: "text-purple-600" %>.</p><% end %>
162
+ </div>
118
163
  <% when "relationship" %>
119
- <% selected_entry_id = entry.data&.dig(field_handle).to_s %>
120
164
  <% relationship_entries = (@relationship_entries_by_field || {}).fetch(field_handle, []) %>
121
- <%= select_tag "entry[data][#{field_handle}]",
122
- options_for_select([["No entry", ""]] + relationship_entries.map { |related_entry| ["#{related_entry.title} (#{related_entry.content_type.name})", related_entry.id] }, selected_entry_id),
123
- id: field_id,
124
- 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" %>
165
+ <% relationship_options = relationship_entries.map { |related_entry| ["#{related_entry.title} (#{related_entry.content_type.name})", related_entry.id] } %>
166
+ <% if field["multiple"] %>
167
+ <%= select_tag "entry[data][#{field_handle}][]", options_for_select(relationship_options, Array(field_value).map(&:to_s)), multiple: true,
168
+ id: field_id, required: field["required"], size: [relationship_options.length, 8].min,
169
+ 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" %>
170
+ <% else %>
171
+ <%= select_tag "entry[data][#{field_handle}]", options_for_select([["No entry", ""]] + relationship_options, field_value.to_s),
172
+ id: field_id, required: field["required"], 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" %>
173
+ <% end %>
174
+ <% when "list", "group", "repeater" %>
175
+ <% structured_value = field_value || (field["type"] == "group" ? {} : []) %>
176
+ <div class="mt-2 space-y-3" data-controller="plum--structured-field"
177
+ data-plum--structured-field-type-value="<%= field['type'] %>"
178
+ data-plum--structured-field-fields-value="<%= Array(field['fields']).to_json %>"
179
+ <% if field['min_items'].present? %>data-plum--structured-field-min-items-value="<%= field['min_items'] %>"<% end %>
180
+ <% if field['max_items'].present? %>data-plum--structured-field-max-items-value="<%= field['max_items'] %>"<% end %>>
181
+ <%= hidden_field_tag "entry[data][#{field_handle}]", structured_value.to_json,
182
+ data: { "plum--structured-field-target": "input" } %>
183
+ <p class="hidden text-sm text-gray-500" data-plum--structured-field-target="empty">No items yet.</p>
184
+ <div class="space-y-3" data-plum--structured-field-target="rows"></div>
185
+ <% unless field["type"] == "group" %>
186
+ <button type="button" data-action="plum--structured-field#add" data-plum--structured-field-target="add"
187
+ class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40">+ Add <%= field["type"] == "list" ? "item" : "row" %></button>
188
+ <% end %>
189
+ </div>
125
190
  <% when "blocks" %>
126
191
  <% editor_config = Plum::BlockEditorConfig.new(@theme, allowed: field["blocks"]) %>
127
192
  <% current_blocks = entry.data&.dig(field_handle) %>
@@ -150,9 +215,15 @@
150
215
  </div>
151
216
  </div>
152
217
  <% end %>
218
+ <% else %>
219
+ <% definition = Plum::FieldTypeRegistry.find(field["type"]) %>
220
+ <% if definition&.partial.present? %>
221
+ <%= render definition.partial, field: field, field_handle: field_handle, field_id: field_id, field_value: field_value, entry: entry %>
222
+ <% end %>
153
223
  <% end %>
154
224
  </div>
155
225
  <% end %>
226
+ </div>
156
227
  <% if @content_type.fields.empty? %>
157
228
  <p class="text-sm text-gray-500">No fields defined. <%= link_to "Edit content type", edit_cp_content_type_path(@content_type), class: "text-purple-600 hover:text-purple-900" %> to add fields.</p>
158
229
  <% end %>
@@ -164,6 +235,12 @@
164
235
  <div class="bg-white shadow rounded-lg p-6 space-y-4">
165
236
  <h3 class="text-lg font-medium text-gray-900">Publishing</h3>
166
237
 
238
+ <div>
239
+ <%= f.label :locale, class: "block text-sm font-medium text-gray-700" %>
240
+ <%= f.select :locale, current_site.locales.map { |locale| [locale, locale] }, {},
241
+ class: "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-purple-500 focus:ring-purple-500" %>
242
+ </div>
243
+
167
244
  <div>
168
245
  <%= f.label :status, class: "block text-sm font-medium text-gray-700" %>
169
246
  <%= f.select :status, Plum::Entry.statuses.keys.map { |s| [s.titleize, s] }, {},
@@ -1,9 +1,25 @@
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
+ <div class="flex items-center gap-3">
4
+ <%= link_to "History (#{@entry.revisions.count})", cp_content_type_entry_revisions_path(@content_type, @entry),
5
+ 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" %>
3
6
  <% if @entry.published? %>
4
7
  <%= link_to "View", "#{root_path.chomp('/')}/#{@entry.slug}", target: "_blank",
5
8
  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
9
  <% end %>
10
+ </div>
7
11
  </div>
8
12
 
9
13
  <%= render "form", entry: @entry %>
14
+
15
+ <div class="mt-6 rounded-lg bg-white p-6 shadow">
16
+ <h2 class="text-lg font-semibold text-gray-900">Translations</h2>
17
+ <div class="mt-3 flex flex-wrap gap-2">
18
+ <% @entry.translation_group.each do |translation| %>
19
+ <%= link_to translation.locale, edit_cp_content_type_entry_path(@content_type, translation), class: "rounded-md border border-gray-300 px-3 py-2 text-sm font-medium #{translation == @entry ? 'bg-purple-50 text-purple-700' : 'bg-white text-gray-700'}" %>
20
+ <% end %>
21
+ <% (current_site.locales - @entry.translation_group.map(&:locale)).each do |locale| %>
22
+ <%= button_to "+ #{locale}", translate_cp_content_type_entry_path(@content_type, @entry), params: { locale: locale }, class: "rounded-md border border-dashed border-purple-300 px-3 py-2 text-sm font-medium text-purple-700" %>
23
+ <% end %>
24
+ </div>
25
+ </div>
@@ -0,0 +1,25 @@
1
+ <div class="mb-8 flex items-center justify-between gap-4">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Revision history</h1>
4
+ <p class="mt-1 text-sm text-gray-500"><%= @entry.title %></p>
5
+ </div>
6
+ <%= link_to "Back to entry", edit_cp_content_type_entry_path(@content_type, @entry), class: "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50" %>
7
+ </div>
8
+
9
+ <div class="overflow-hidden rounded-lg bg-white shadow">
10
+ <ul class="divide-y divide-gray-200">
11
+ <% @revisions.each_with_index do |revision, index| %>
12
+ <li class="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
13
+ <div>
14
+ <p class="text-sm font-semibold text-gray-900"><%= index.zero? ? "Current saved version" : revision.created_at.to_fs(:long) %></p>
15
+ <p class="mt-1 text-xs text-gray-500"><%= revision.editor_label %> · <%= revision.snapshot["status"].to_s.titleize %></p>
16
+ </div>
17
+ <% unless index.zero? %>
18
+ <%= button_to "Restore", restore_cp_content_type_entry_revision_path(@content_type, @entry, revision), method: :post,
19
+ form: { data: { turbo_confirm: "Restore this version? The current version will remain in history." } },
20
+ class: "rounded-md border border-purple-200 bg-purple-50 px-3 py-2 text-sm font-semibold text-purple-700 hover:bg-purple-100" %>
21
+ <% end %>
22
+ </li>
23
+ <% end %>
24
+ </ul>
25
+ </div>
@@ -0,0 +1,30 @@
1
+ <div class="mb-8">
2
+ <h1 class="text-2xl font-bold text-gray-900">Fieldsets</h1>
3
+ <p class="mt-1 text-sm text-gray-500">Save a content type's fields, then reuse them in other blueprints.</p>
4
+ </div>
5
+
6
+ <div class="grid gap-6 lg:grid-cols-3">
7
+ <section class="rounded-lg bg-white p-6 shadow lg:col-span-2">
8
+ <h2 class="text-lg font-semibold text-gray-900">Saved fieldsets</h2>
9
+ <div class="mt-4 divide-y divide-gray-200">
10
+ <% @fieldsets.each do |fieldset| %>
11
+ <div class="flex items-center justify-between gap-4 py-4">
12
+ <div><p class="text-sm font-semibold text-gray-900"><%= fieldset.name %></p><p class="text-xs text-gray-500"><%= fieldset.handle %> · <%= fieldset.fields.length %> fields</p></div>
13
+ <%= button_to "Delete", cp_fieldset_path(fieldset), method: :delete, form: { data: { turbo_confirm: "Delete this fieldset? Existing blueprints are unaffected." } }, class: "text-sm font-medium text-red-600" %>
14
+ </div>
15
+ <% end %>
16
+ <% if @fieldsets.empty? %><p class="py-6 text-sm text-gray-500">No saved fieldsets yet.</p><% end %>
17
+ </div>
18
+ </section>
19
+
20
+ <section class="rounded-lg bg-white p-6 shadow">
21
+ <h2 class="text-lg font-semibold text-gray-900">Create from content type</h2>
22
+ <%= form_with url: cp_fieldsets_path, scope: :fieldset, class: "mt-4 space-y-4" do |form| %>
23
+ <%= form.text_field :name, value: @fieldset&.name, required: true, placeholder: "SEO fields", class: "block w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
24
+ <%= form.text_field :handle, value: @fieldset&.handle, placeholder: "Generated from name", class: "block w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm" %>
25
+ <%= form.collection_select :content_type_id, @content_types, :id, :name, { prompt: "Choose source content type" }, required: true, class: "block w-full rounded-md border border-gray-300 px-3 py-2 text-sm" %>
26
+ <% if @fieldset&.errors&.any? %><p class="text-sm text-red-600"><%= @fieldset.errors.full_messages.to_sentence %></p><% end %>
27
+ <%= form.submit "Save fieldset", class: "rounded-md bg-purple-600 px-4 py-2 text-sm font-semibold text-white hover:bg-purple-700" %>
28
+ <% end %>
29
+ </section>
30
+ </div>
@@ -60,6 +60,18 @@
60
60
  <%= f.email_field :support_email,
61
61
  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" %>
62
62
  </div>
63
+
64
+ <div class="grid gap-4 sm:grid-cols-2">
65
+ <div>
66
+ <%= f.label :locales, "Locales", class: "block text-sm font-medium text-gray-700" %>
67
+ <%= text_field_tag "site_setting[locales]", current_site.locales.join(", "), class: "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm", placeholder: "en, es, fr-CA" %>
68
+ <p class="mt-1 text-xs text-gray-500">Comma-separated language or language-region codes.</p>
69
+ </div>
70
+ <div>
71
+ <%= f.label :default_locale, "Default locale", class: "block text-sm font-medium text-gray-700" %>
72
+ <%= text_field_tag "site_setting[default_locale]", current_site.default_locale, class: "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm" %>
73
+ </div>
74
+ </div>
63
75
  </div>
64
76
 
65
77
  <div class="bg-white shadow rounded-lg p-6 space-y-6">
@@ -6,10 +6,16 @@ Plum::Engine.routes.draw do
6
6
  namespace :cp do
7
7
  root "dashboard#show"
8
8
  resources :content_types do
9
+ post :apply_fieldset, on: :member
9
10
  resources :entries do
10
11
  patch :image_field, on: :member
12
+ post :translate, on: :member
13
+ resources :revisions, controller: "entry_revisions", only: [ :index ] do
14
+ post :restore, on: :member
15
+ end
11
16
  end
12
17
  end
18
+ resources :fieldsets, only: [ :index, :create, :destroy ]
13
19
  resources :assets, except: [ :show ]
14
20
  resources :globals
15
21
  resources :nav_menus do
@@ -28,10 +34,19 @@ Plum::Engine.routes.draw do
28
34
  end
29
35
 
30
36
  post "forms/:handle", to: "form_submissions#create", as: :form
37
+ namespace :api do
38
+ namespace :v1 do
39
+ get "collections/:collection_handle/entries", to: "entries#index", as: :collection_entries
40
+ get "collections/:collection_handle/entries/:slug", to: "entries#show", as: :collection_entry
41
+ end
42
+ end
31
43
  get "theme_assets/:theme_handle/*path", to: "theme_assets#show", as: :theme_asset, format: false
32
44
 
33
45
  get "search", to: "pages#search", as: :search
34
46
 
47
+ get ":locale", to: "pages#localized_home", constraints: { locale: /[a-z]{2}(?:-[A-Z]{2})?/ }, as: :localized_root
48
+ get ":locale/*slug", to: "pages#show", constraints: { locale: /[a-z]{2}(?:-[A-Z]{2})?/ }, as: :localized_page
49
+
35
50
  root "pages#home"
36
51
  get "*slug", to: "pages#show", constraints: ->(req) { !req.path.start_with?("/rails/") }
37
52
  end
@@ -0,0 +1,16 @@
1
+ class CreatePlumEntryRevisions < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :plum_entry_revisions do |t|
4
+ t.references :site, null: false, foreign_key: { to_table: :plum_sites }
5
+ t.references :entry, null: false, foreign_key: { to_table: :plum_entries }
6
+ t.references :editor, null: true, foreign_key: { to_table: :plum_users }
7
+ t.string :editor_name
8
+ t.string :editor_email
9
+ t.string :editor_gid
10
+ t.json :snapshot, null: false, default: {}
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :plum_entry_revisions, [ :entry_id, :created_at ]
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ class CreatePlumFieldsets < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :plum_fieldsets do |t|
4
+ t.references :site, null: false, foreign_key: { to_table: :plum_sites }
5
+ t.string :name, null: false
6
+ t.string :handle, null: false
7
+ t.json :fields, null: false, default: []
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :plum_fieldsets, [ :site_id, :handle ], unique: true
12
+ end
13
+ end
@@ -0,0 +1,6 @@
1
+ class AddFocalPointToPlumAssets < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :plum_assets, :focal_x, :integer, null: false, default: 50
4
+ add_column :plum_assets, :focal_y, :integer, null: false, default: 50
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ class AddLocalizationToPlumEntries < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :plum_entries, :locale, :string, null: false, default: "en"
4
+ add_reference :plum_entries, :origin, null: true, foreign_key: { to_table: :plum_entries }
5
+ remove_index :plum_entries, [ :site_id, :slug ], if_exists: true
6
+ add_index :plum_entries, [ :site_id, :locale, :slug ], unique: true
7
+ add_index :plum_entries, [ :origin_id, :locale ], unique: true
8
+ end
9
+ end
@@ -0,0 +1,55 @@
1
+ # Blueprint fields
2
+
3
+ Plum stores a content type blueprint as JSON with a `fields` array. Field
4
+ handles are stable template and storage keys: lowercase letters, numbers, and
5
+ underscores, beginning with a letter.
6
+
7
+ ## Shared configuration
8
+
9
+ Every data field accepts `handle`, `type`, `label`, `instructions`, `required`,
10
+ `default`, `placeholder`, and `width`. Width is a grid span from 1 through 12.
11
+ A `section` organizes the entry form but stores no value.
12
+
13
+ `condition` controls visibility using another field:
14
+
15
+ ```json
16
+ { "field": "format", "operator": "equals", "value": "feature" }
17
+ ```
18
+
19
+ Operators: `equals`, `not_equals`, `contains`, `empty`, and `not_empty`.
20
+
21
+ ## Type-specific configuration
22
+
23
+ | Type | Configuration | Stored value |
24
+ |---|---|---|
25
+ | `text`, `textarea`, `rich_text`, `url`, `color` | Shared options | String |
26
+ | `number` | `number_kind`, `min`, `max`, `step`, `unit` | Number |
27
+ | `boolean` | Shared options | Boolean |
28
+ | `date` | `date_mode`, `min`, `max` | ISO date/time string |
29
+ | `select`, `radio`, `button_group` | `options` | Option value |
30
+ | `checkboxes` | `options` | Option-value array |
31
+ | `taxonomy` | `taxonomy` | Entry-term associations |
32
+ | `image` | Optional `folder` | Asset ID |
33
+ | `images` | `min_items`, `max_items` | Asset-ID array |
34
+ | `relationship` | `content_type`, `multiple`, item limits | Entry ID or ID array |
35
+ | `blocks` | Optional `blocks` allowlist | Block array |
36
+ | `list` | `min_items`, `max_items`, `unique` | String array |
37
+ | `group` | Nested `fields` | Object |
38
+ | `repeater` | Nested `fields`, item limits | Object array |
39
+ | `section` | Shared presentation options | Nothing |
40
+
41
+ Options may be strings or `{ "label": "Human label", "value": "stable_value" }`
42
+ objects. Existing string options remain supported.
43
+
44
+ ## Theme values
45
+
46
+ Liquid receives expanded assets and relationships. An `image` becomes an asset
47
+ object, `images` becomes an array of asset objects, and relationships become an
48
+ entry object or ordered array. Stored IDs remain internal to the editor.
49
+
50
+ ## Compatibility policy
51
+
52
+ Blueprint additions are backward compatible. Plum continues to read legacy
53
+ string options and single-value relationship/image fields. Changing a field's
54
+ type or handle is a content migration and should be performed in application
55
+ code before deploying the new blueprint.
@@ -0,0 +1,32 @@
1
+ # Extending Plum
2
+
3
+ Plum exposes Rails-native extension points for host applications and gems. An
4
+ initializer can register a field type with its control-panel partial and value
5
+ pipeline:
6
+
7
+ ```ruby
8
+ Plum.register_field_type(
9
+ handle: :postal_code,
10
+ label: "Postal Code",
11
+ partial: "my_engine/fields/postal_code",
12
+ normalizer: ->(value:, **) { value.to_s.upcase.delete(" ") },
13
+ validator: ->(value:, **) { "is invalid" unless value.to_s.match?(/\A\d{5}\z/) },
14
+ expander: ->(value:, **) { { "value" => value, "country" => "US" } }
15
+ )
16
+ ```
17
+
18
+ The partial receives `field`, `field_handle`, `field_id`, `field_value`, and
19
+ `entry`. Its submitted input must use `entry[data][HANDLE]`.
20
+
21
+ The normalizer runs before persistence and receives `value`, `field`, `entry`,
22
+ and `controller`. The validator runs on the normalized entry value and returns
23
+ one message, multiple messages, or `nil`. The expander controls the value sent
24
+ to Liquid and the JSON content API and receives `value`, `field`, `site`, and
25
+ `expander`.
26
+
27
+ Registration rejects invalid or duplicate handles. Custom types automatically
28
+ appear in the visual blueprint type picker and participate in normal required,
29
+ default, instruction, conditional-visibility, and field-width behavior.
30
+
31
+ Plum also supports registered content sources and host-owned authorization; see
32
+ the corresponding README sections for those APIs.
@@ -0,0 +1,40 @@
1
+ # Site export, import, backup, and restore
2
+
3
+ Plum 0.3 archives are versioned ZIP files containing a JSON manifest and the
4
+ original bytes of every attached asset. The manifest carries schemas, entries,
5
+ relationships, translations, taxonomies, navigation, globals, forms, fieldsets,
6
+ settings, revisions, and submissions. Import remaps database identifiers so an
7
+ archive can be restored into a different database without collisions.
8
+
9
+ ## Export and import
10
+
11
+ ```sh
12
+ bin/rails plum:site:export ARCHIVE=/safe/location/site.plum.zip SITE_ID=1
13
+ bin/rails plum:site:import ARCHIVE=/safe/location/site.plum.zip NAME="Imported site" DOMAIN=example.com
14
+ ```
15
+
16
+ `SITE_ID` is optional when exporting and defaults to the first site. `NAME` and
17
+ `DOMAIN` are optional import overrides. Import always creates a new site; it
18
+ never overwrites an existing site.
19
+
20
+ ## Backup and restore
21
+
22
+ ```sh
23
+ bin/rails plum:backup:create SITE_ID=1 DIRECTORY=/var/backups/plum
24
+ bin/rails plum:backup:restore ARCHIVE=/var/backups/plum/plum-site-1-TIMESTAMP.plum.zip
25
+ ```
26
+
27
+ The backup task writes a timestamped archive. Restore uses the same safe,
28
+ new-site import path. Assets are checked against their recorded byte size and
29
+ checksum before the database transaction commits. Missing, corrupt, or
30
+ unsupported archives fail without leaving a partially imported site.
31
+
32
+ Backups must be copied off the application host and tested regularly. An archive
33
+ does not include application code, environment secrets, users, or the host
34
+ database outside the selected Plum site.
35
+
36
+ ## Archive compatibility
37
+
38
+ The manifest currently uses format version `1`. Plum rejects archive versions
39
+ it does not understand instead of guessing. Future format changes will either
40
+ remain readable or ship an explicit migration path.
@@ -0,0 +1,73 @@
1
+ # Product Principles
2
+
3
+ These principles guide product and architectural decisions in Plum.
4
+
5
+ ## Rails Owns the Application
6
+
7
+ The host application has the final say. Plum should integrate with its users,
8
+ authorization, tenancy, models, jobs, mail, storage, routes, and deployment
9
+ instead of creating parallel systems.
10
+
11
+ ## The First Useful Result Is Fast
12
+
13
+ Installation must lead to an editable, published page in less than ten minutes.
14
+ Defaults should be good enough to ship and understandable enough to replace.
15
+
16
+ ## The Escape Hatches Are Ruby
17
+
18
+ When a project exceeds Plum's conventions, developers should extend it with
19
+ ordinary Ruby, Rails configuration, models, controllers, helpers, and views.
20
+ Plum should not require a proprietary plugin runtime.
21
+
22
+ ## Editors Do Not Need to Know Rails
23
+
24
+ Developers define content structure and presentation. Editors should receive a
25
+ clear, forgiving interface with preview, validation, history, and recovery.
26
+
27
+ ## Content Is Structured and Portable
28
+
29
+ Content should be reusable across templates and exportable with its schema and
30
+ assets. A site owner must be able to leave without scraping rendered HTML or
31
+ depending on a Plum service.
32
+
33
+ ## One Stack Is a Feature
34
+
35
+ Plum should not require Node services, a remote CMS database, webhooks, API
36
+ tokens, or a separate frontend. Optional integrations may add capability, but
37
+ the complete product must work as a conventional Rails application.
38
+
39
+ ## SQLite Is a First-Class Production Path
40
+
41
+ Small content sites should run reliably with SQLite and persistent storage.
42
+ Plum must also remain database-agnostic for embedded and higher-scale uses.
43
+
44
+ ## Sites Are Operationally Independent
45
+
46
+ For agency work, one client site should be one portable application, container,
47
+ database, and backup unit. For SaaS products, site scoping should support many
48
+ tenants inside one host application. Plum should document both patterns rather
49
+ than forcing one onto the other.
50
+
51
+ ## Deployment Tools Have Clear Boundaries
52
+
53
+ Plum creates and manages content applications. A Plum CLI may scaffold, inspect,
54
+ export, import, upgrade, and package them. ONCE, Kamal, and other deployment
55
+ tools operate the resulting containers and servers. Plum should integrate
56
+ cleanly without becoming an infrastructure orchestrator.
57
+
58
+ ## Documentation Is Part of the Product
59
+
60
+ Every public capability needs an example and a clear contract. If a feature is
61
+ difficult to explain, its design is not finished.
62
+
63
+ ## Dogfood Without Special Cases
64
+
65
+ The Plum marketing and documentation site must run on the public product. Any
66
+ general improvement it needs belongs in Plum; site-specific behavior belongs in
67
+ the site's application or theme.
68
+
69
+ ## Prefer Coherence Over Feature Count
70
+
71
+ Plum should excel at the everyday path from modeling content to publishing a
72
+ site. Features that strengthen installation, authoring confidence, portability,
73
+ and Rails integration take priority over breadth for its own sake.