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
@@ -11,16 +11,16 @@ module Plum
11
11
  ].freeze
12
12
 
13
13
  before_action :set_content_type
14
- before_action :set_entry, only: [ :edit, :update, :destroy, :image_field ]
14
+ before_action :set_entry, only: [ :edit, :update, :destroy, :image_field, :translate ]
15
15
  before_action :set_form_collections, only: [ :new, :create, :edit, :update ]
16
- before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy, :image_field ]
16
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy, :image_field, :translate ]
17
17
 
18
18
  def index
19
19
  @entries = @content_type.entries.order(updated_at: :desc)
20
20
  end
21
21
 
22
22
  def new
23
- @entry = @content_type.entries.build(status: :draft, site: current_site)
23
+ @entry = @content_type.entries.build(status: :draft, site: current_site, locale: current_site.default_locale)
24
24
  end
25
25
 
26
26
  def create
@@ -73,6 +73,29 @@ module Plum
73
73
  redirect_to cp_content_type_entries_path(@content_type), notice: "Entry deleted"
74
74
  end
75
75
 
76
+ def translate
77
+ locale = params[:locale].to_s
78
+ unless current_site.locales.include?(locale)
79
+ return redirect_to edit_cp_content_type_entry_path(@content_type, @entry), alert: "Locale is not configured"
80
+ end
81
+
82
+ source = @entry.origin || @entry
83
+ translation = source.translations.find_or_initialize_by(locale: locale)
84
+ if translation.new_record?
85
+ translation.assign_attributes(
86
+ site: current_site,
87
+ content_type: @content_type,
88
+ author: current_user.is_a?(Plum::User) ? current_user : nil,
89
+ title: @entry.title,
90
+ slug: @entry.slug,
91
+ status: :draft,
92
+ data: @entry.data.to_h.deep_dup
93
+ )
94
+ translation.save!
95
+ end
96
+ redirect_to edit_cp_content_type_entry_path(@content_type, translation), notice: "#{locale} translation ready"
97
+ end
98
+
76
99
  private
77
100
 
78
101
  def set_content_type
@@ -96,7 +119,7 @@ module Plum
96
119
  end
97
120
 
98
121
  def entry_params
99
- permitted = params.require(:entry).permit(:title, :slug, :status, :published_at)
122
+ permitted = params.require(:entry).permit(:title, :slug, :status, :published_at, :locale)
100
123
  if params[:entry][:data].is_a?(ActionController::Parameters)
101
124
  permitted[:data] = params[:entry][:data].permit!.to_h
102
125
  end
@@ -104,10 +127,17 @@ module Plum
104
127
  end
105
128
 
106
129
  def save_entry(entry)
107
- return false unless entry.valid?
108
- return false unless attach_uploaded_image_fields(entry)
130
+ saved = false
131
+ entry.transaction do
132
+ unless attach_uploaded_image_fields(entry) && entry.valid? && entry.save
133
+ raise ActiveRecord::Rollback
134
+ end
109
135
 
110
- entry.save.tap { |saved| sync_entry_terms(entry) if saved }
136
+ sync_entry_terms(entry)
137
+ entry.record_revision!(editor: current_user)
138
+ saved = true
139
+ end
140
+ saved
111
141
  end
112
142
 
113
143
  def sync_entry_terms(entry)
@@ -150,7 +180,7 @@ module Plum
150
180
 
151
181
  @content_type.fields.each do |field|
152
182
  handle = field["handle"].to_s
153
- next if handle.blank?
183
+ next if handle.blank? || field["type"] == "section"
154
184
 
155
185
  value = entry.data[handle]
156
186
  entry.data[handle] = normalized_field_value(entry, field, value)
@@ -161,25 +191,105 @@ module Plum
161
191
  end
162
192
 
163
193
  def normalized_field_value(entry, field, value)
194
+ definition = FieldTypeRegistry.find(field["type"])
195
+ if definition&.normalizer
196
+ return definition.normalizer.call(value: value, field: field, entry: entry, controller: self)
197
+ end
198
+
164
199
  case field["type"]
165
200
  when "boolean"
166
201
  ActiveModel::Type::Boolean.new.cast(value)
202
+ when "number"
203
+ normalized_number_value(value, field)
167
204
  when "checkboxes"
168
205
  Array(value).select(&:present?)
169
206
  when "image"
170
- value.present? ? value.to_i : nil
207
+ normalized_image_value(entry, field, value)
208
+ when "images"
209
+ normalized_asset_ids(entry, field, value)
171
210
  when "rich_text"
172
211
  value
173
212
  when "relationship"
174
213
  normalized_relationship_value(entry, field, value)
175
214
  when "blocks"
176
215
  normalized_blocks_value(value)
216
+ when "list"
217
+ Array(parsed_structured_value(value, fallback: [])).map { |item| item.to_s.strip }.select(&:present?)
218
+ when "group"
219
+ normalized_structured_row(field, parsed_structured_value(value, fallback: {}))
220
+ when "repeater"
221
+ Array(parsed_structured_value(value, fallback: [])).filter_map do |row|
222
+ normalized = normalized_structured_row(field, row)
223
+ normalized if normalized.values.any?(&:present?)
224
+ end
177
225
  else
178
226
  value
179
227
  end
180
228
  end
181
229
 
230
+ def normalized_asset_ids(entry, field, value)
231
+ ids = Array(value).select(&:present?).map(&:to_i).uniq
232
+ valid_ids = current_site.assets.where(id: ids).pluck(:id)
233
+ if valid_ids.length != ids.length
234
+ entry.errors.add(:base, "#{field_label(field)} contains an invalid image")
235
+ return []
236
+ end
237
+
238
+ ids
239
+ end
240
+
241
+ def normalized_image_value(entry, field, value)
242
+ return nil if value.blank?
243
+
244
+ asset = current_site.assets.find_by(id: value.to_i)
245
+ unless asset
246
+ entry.errors.add(:base, "#{field_label(field)} is not a valid image")
247
+ return nil
248
+ end
249
+
250
+ asset.update!(alt_text: default_asset_alt_text(entry, field)) if asset.alt_text.blank?
251
+ asset.id
252
+ end
253
+
254
+ def normalized_number_value(value, field)
255
+ return nil if value.blank?
256
+
257
+ field["number_kind"] == "integer" ? Integer(value, 10) : Float(value)
258
+ rescue ArgumentError, TypeError
259
+ value
260
+ end
261
+
262
+ def parsed_structured_value(value, fallback:)
263
+ return value unless value.is_a?(String)
264
+
265
+ JSON.parse(value)
266
+ rescue JSON::ParserError
267
+ fallback
268
+ end
269
+
270
+ def normalized_structured_row(field, value)
271
+ row = value.respond_to?(:to_h) ? value.to_h : {}
272
+ Array(field["fields"]).each_with_object({}) do |nested_field, normalized|
273
+ handle = nested_field["handle"].to_s
274
+ next if handle.blank?
275
+
276
+ nested_value = row[handle]
277
+ nested_value = nested_field["default"] if nested_value.nil? && nested_field.key?("default")
278
+ normalized[handle] = nested_field["type"] == "boolean" ? ActiveModel::Type::Boolean.new.cast(nested_value) : nested_value
279
+ end
280
+ end
281
+
182
282
  def normalized_relationship_value(entry, field, value)
283
+ if ActiveModel::Type::Boolean.new.cast(field["multiple"])
284
+ ids = Array(value).select(&:present?).map(&:to_i).uniq
285
+ valid_ids = relationship_entry_scope(entry, field).where(id: ids).pluck(:id)
286
+ if valid_ids.length != ids.length
287
+ entry.errors.add(:base, "#{field_label(field)} contains an invalid entry")
288
+ return []
289
+ end
290
+ return ids
291
+ end
292
+
183
293
  return nil if value.blank?
184
294
 
185
295
  related_entry = relationship_entry_scope(entry, field).find_by(id: value.to_i)
@@ -0,0 +1,27 @@
1
+ module Plum
2
+ module Cp
3
+ class EntryRevisionsController < BaseController
4
+ before_action :require_editor
5
+ before_action :set_entry
6
+
7
+ def index
8
+ @revisions = @entry.revisions.order(created_at: :desc)
9
+ end
10
+
11
+ def restore
12
+ revision = @entry.revisions.find(params[:id])
13
+ @entry.restore_revision!(revision, editor: current_user)
14
+ redirect_to edit_cp_content_type_entry_path(@content_type, @entry), notice: "Revision restored"
15
+ rescue ActiveRecord::RecordInvalid => error
16
+ redirect_to cp_content_type_entry_revisions_path(@content_type, @entry), alert: "Could not restore revision: #{error.record.errors.full_messages.to_sentence}"
17
+ end
18
+
19
+ private
20
+
21
+ def set_entry
22
+ @content_type = current_site.content_types.find(params[:content_type_id])
23
+ @entry = @content_type.entries.find(params[:entry_id])
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,37 @@
1
+ module Plum
2
+ module Cp
3
+ class FieldsetsController < BaseController
4
+ before_action :require_admin
5
+
6
+ def index
7
+ @fieldsets = current_site.fieldsets.order(:name)
8
+ @content_types = current_site.content_types.order(:name)
9
+ end
10
+
11
+ def create
12
+ source = current_site.content_types.find(params.require(:fieldset)[:content_type_id])
13
+ fieldset = current_site.fieldsets.build(fieldset_params.except(:content_type_id))
14
+ fieldset.fields = source.fields.deep_dup
15
+ if fieldset.save
16
+ redirect_to cp_fieldsets_path, notice: "Fieldset created from #{source.name}"
17
+ else
18
+ @fieldsets = current_site.fieldsets.order(:name)
19
+ @content_types = current_site.content_types.order(:name)
20
+ @fieldset = fieldset
21
+ render :index, status: :unprocessable_entity
22
+ end
23
+ end
24
+
25
+ def destroy
26
+ current_site.fieldsets.find(params[:id]).destroy!
27
+ redirect_to cp_fieldsets_path, notice: "Fieldset deleted"
28
+ end
29
+
30
+ private
31
+
32
+ def fieldset_params
33
+ params.require(:fieldset).permit(:name, :handle, :content_type_id)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -20,10 +20,19 @@ module Plum
20
20
  def update
21
21
  settings_attributes = site_settings_params
22
22
  theme_settings_by_theme = settings_attributes.delete(:theme_settings_by_theme).to_h
23
+ locales = normalized_locales(settings_attributes.delete(:locales))
24
+ default_locale = settings_attributes.delete(:default_locale).to_s
23
25
  @site_settings.assign_attributes(settings_attributes)
24
26
 
27
+ unless locales.any? && locales.include?(default_locale)
28
+ @site_settings.errors.add(:base, "Locales must be valid codes and include the default locale")
29
+ set_theme_options
30
+ return render :edit, status: :unprocessable_entity
31
+ end
32
+
25
33
  if @site_settings.save
26
- current_site.update!(theme_settings: theme_settings_for(@site_settings.theme_name, theme_settings_by_theme))
34
+ site_settings = current_site.settings.to_h.merge("locales" => locales, "default_locale" => default_locale)
35
+ current_site.update!(theme_settings: theme_settings_for(@site_settings.theme_name, theme_settings_by_theme), settings: site_settings)
27
36
  redirect_to edit_cp_site_settings_path, notice: "Site settings updated"
28
37
  else
29
38
  set_theme_options
@@ -92,9 +101,19 @@ module Plum
92
101
  :primary_color,
93
102
  :support_email,
94
103
  :theme_name,
104
+ :locales,
105
+ :default_locale,
95
106
  theme_settings_by_theme: {}
96
107
  )
97
108
  end
109
+
110
+ def normalized_locales(value)
111
+ value.to_s.split(/[\s,]+/).filter_map do |locale|
112
+ parts = locale.split("-", 2)
113
+ normalized = [ parts[0]&.downcase, parts[1]&.upcase ].compact.join("-")
114
+ normalized if normalized.match?(/\A[a-z]{2}(?:-[A-Z]{2})?\z/)
115
+ end.uniq
116
+ end
98
117
  end
99
118
  end
100
119
  end
@@ -5,6 +5,7 @@ module Plum
5
5
  def home
6
6
  @site_settings = SiteSetting.instance(current_site)
7
7
  @entry = Entry.for_site(current_site).live
8
+ .where(locale: requested_locale)
8
9
  .includes(:content_type, terms: :taxonomy)
9
10
  .find_by(slug: HOMEPAGE_SLUG)
10
11
 
@@ -13,10 +14,19 @@ module Plum
13
14
  render html: html.html_safe, layout: false
14
15
  end
15
16
 
17
+ def localized_home
18
+ raise ActiveRecord::RecordNotFound unless current_site.locales.include?(params[:locale])
19
+
20
+ home
21
+ rescue ActiveRecord::RecordNotFound
22
+ render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
23
+ end
24
+
16
25
  def search
17
26
  query = params[:q].to_s.strip
18
27
  context = build_context
19
28
  results = Entry.for_site(current_site).live.search(query)
29
+ .where(locale: requested_locale)
20
30
  .includes(:content_type)
21
31
  .order(published_at: :desc)
22
32
  .limit(50)
@@ -33,6 +43,8 @@ module Plum
33
43
  end
34
44
 
35
45
  def show
46
+ raise ActiveRecord::RecordNotFound unless current_site.locales.include?(requested_locale)
47
+
36
48
  slug = params[:slug]
37
49
 
38
50
  @content_type = ContentType.for_site(current_site).find_by(handle: slug)
@@ -47,6 +59,7 @@ module Plum
47
59
  end
48
60
  if content_type
49
61
  @entry = Entry.for_site(current_site).live
62
+ .where(locale: requested_locale)
50
63
  .includes(:content_type, terms: :taxonomy)
51
64
  .find_by!(content_type: content_type, slug: parts[1])
52
65
  html = Plum::LiquidRenderer.render_template("entries/#{content_type.handle}", build_context(@entry).to_h)
@@ -64,6 +77,7 @@ module Plum
64
77
  end
65
78
 
66
79
  @entry = Entry.for_site(current_site).live
80
+ .where(locale: requested_locale)
67
81
  .includes(:content_type, terms: :taxonomy)
68
82
  .find_by!(slug: slug)
69
83
  template = "entries/#{@entry.content_type.handle}"
@@ -81,6 +95,7 @@ module Plum
81
95
  context = build_context
82
96
  page = [ params.fetch(:page, 1).to_i, 1 ].max
83
97
  all_entries = Entry.for_site(current_site).live
98
+ .where(locale: requested_locale)
84
99
  .where(content_type: content_type)
85
100
  .order(published_at: :desc, created_at: :desc)
86
101
  total = all_entries.count
@@ -112,6 +127,7 @@ module Plum
112
127
  context = build_context
113
128
  page = [ params.fetch(:page, 1).to_i, 1 ].max
114
129
  all_entries = Entry.for_site(current_site).live
130
+ .where(locale: requested_locale)
115
131
  .joins(:entry_terms)
116
132
  .where(plum_entry_terms: { term_id: term.id })
117
133
  .order(published_at: :desc)
@@ -146,7 +162,7 @@ module Plum
146
162
  "name" => taxonomy.name,
147
163
  "handle" => taxonomy.handle,
148
164
  "slug" => taxonomy.slug,
149
- "terms" => terms.map { |t| { "name" => t.name, "slug" => t.slug, "url" => "/#{taxonomy.slug}/#{t.slug}", "entries_count" => t.entries.live.count } }
165
+ "terms" => terms.map { |t| { "name" => t.name, "slug" => t.slug, "url" => localized_path("#{taxonomy.slug}/#{t.slug}"), "entries_count" => t.entries.live.where(locale: requested_locale).count } }
150
166
  }
151
167
  )
152
168
  template = "taxonomies/#{taxonomy.handle}_index"
@@ -157,5 +173,14 @@ module Plum
157
173
  def build_context(entry = nil)
158
174
  LiquidContext.new(controller: self, site: current_site, entry: entry)
159
175
  end
176
+
177
+ def requested_locale
178
+ params[:locale].presence || current_site.default_locale
179
+ end
180
+
181
+ def localized_path(path)
182
+ prefix = requested_locale == current_site.default_locale ? nil : requested_locale
183
+ "/#{[ prefix, path ].compact.join('/')}"
184
+ end
160
185
  end
161
186
  end
@@ -0,0 +1,18 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["checkbox", "count"]
5
+ static values = { max: Number }
6
+
7
+ connect() {
8
+ this.refresh()
9
+ }
10
+
11
+ refresh() {
12
+ const selected = this.checkboxTargets.filter(checkbox => checkbox.checked).length
13
+ if (this.hasCountTarget) this.countTarget.textContent = `${selected} selected`
14
+ this.checkboxTargets.forEach(checkbox => {
15
+ checkbox.disabled = this.hasMaxValue && this.maxValue > 0 && selected >= this.maxValue && !checkbox.checked
16
+ })
17
+ }
18
+ }