cms42 0.1.0

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 (145) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +299 -0
  4. data/Rakefile +10 -0
  5. data/app/assets/stylesheets/cms/application.css +3 -0
  6. data/app/controllers/cms/admin/api_keys_controller.rb +52 -0
  7. data/app/controllers/cms/admin/base_controller.rb +54 -0
  8. data/app/controllers/cms/admin/documents_controller.rb +56 -0
  9. data/app/controllers/cms/admin/form_fields_controller.rb +70 -0
  10. data/app/controllers/cms/admin/form_submissions_controller.rb +36 -0
  11. data/app/controllers/cms/admin/images_controller.rb +67 -0
  12. data/app/controllers/cms/admin/pages_controller.rb +188 -0
  13. data/app/controllers/cms/admin/sections_controller.rb +177 -0
  14. data/app/controllers/cms/admin/sites_controller.rb +60 -0
  15. data/app/controllers/cms/admin/webhook_deliveries_controller.rb +19 -0
  16. data/app/controllers/cms/admin/webhooks_controller.rb +51 -0
  17. data/app/controllers/cms/api/base_controller.rb +45 -0
  18. data/app/controllers/cms/api/v1/base_controller.rb +29 -0
  19. data/app/controllers/cms/api/v1/pages_controller.rb +21 -0
  20. data/app/controllers/cms/api/v1/sites_controller.rb +18 -0
  21. data/app/controllers/cms/application_controller.rb +11 -0
  22. data/app/controllers/cms/public/base_controller.rb +23 -0
  23. data/app/controllers/cms/public/form_submissions_controller.rb +63 -0
  24. data/app/controllers/cms/public/previews_controller.rb +21 -0
  25. data/app/controllers/cms/public/sites_controller.rb +37 -0
  26. data/app/controllers/concerns/cms/admin/page_scoped_sections.rb +42 -0
  27. data/app/controllers/concerns/cms/current_site_resolver.rb +17 -0
  28. data/app/controllers/concerns/cms/public/page_paths.rb +31 -0
  29. data/app/controllers/concerns/cms/public/page_rendering.rb +23 -0
  30. data/app/controllers/concerns/cms/site_resolvable.rb +27 -0
  31. data/app/helpers/cms/admin/pages_helper.rb +29 -0
  32. data/app/helpers/cms/admin/sections_helper.rb +86 -0
  33. data/app/helpers/cms/admin/sites_helper.rb +8 -0
  34. data/app/helpers/cms/application_helper.rb +51 -0
  35. data/app/helpers/cms/media_helper.rb +28 -0
  36. data/app/helpers/cms/pages_helper.rb +16 -0
  37. data/app/helpers/cms/sections_helper.rb +25 -0
  38. data/app/helpers/cms/sites_helper.rb +11 -0
  39. data/app/javascript/cms/controllers/sortable_controller.js +38 -0
  40. data/app/jobs/cms/application_job.rb +6 -0
  41. data/app/jobs/cms/deliver_webhook_job.rb +66 -0
  42. data/app/mailers/cms/application_mailer.rb +9 -0
  43. data/app/mailers/cms/form_submission_mailer.rb +16 -0
  44. data/app/models/cms/api_key.rb +27 -0
  45. data/app/models/cms/application_record.rb +7 -0
  46. data/app/models/cms/document.rb +48 -0
  47. data/app/models/cms/form_field.rb +27 -0
  48. data/app/models/cms/form_submission.rb +42 -0
  49. data/app/models/cms/image.rb +61 -0
  50. data/app/models/cms/image_translation.rb +22 -0
  51. data/app/models/cms/page.rb +228 -0
  52. data/app/models/cms/page_section.rb +43 -0
  53. data/app/models/cms/page_translation.rb +22 -0
  54. data/app/models/cms/section/block_base.rb +32 -0
  55. data/app/models/cms/section/blocks/call_to_action_block.rb +16 -0
  56. data/app/models/cms/section/blocks/hero_block.rb +14 -0
  57. data/app/models/cms/section/blocks/image_block.rb +13 -0
  58. data/app/models/cms/section/blocks/rich_text_block.rb +12 -0
  59. data/app/models/cms/section/kind_registry.rb +66 -0
  60. data/app/models/cms/section.rb +94 -0
  61. data/app/models/cms/section_image.rb +10 -0
  62. data/app/models/cms/section_translation.rb +25 -0
  63. data/app/models/cms/site.rb +87 -0
  64. data/app/models/cms/webhook.rb +41 -0
  65. data/app/models/cms/webhook_delivery.rb +12 -0
  66. data/app/serializers/cms/api/base_serializer.rb +51 -0
  67. data/app/serializers/cms/api/page_serializer.rb +145 -0
  68. data/app/serializers/cms/api/site_serializer.rb +45 -0
  69. data/app/services/cms/locale_resolver.rb +30 -0
  70. data/app/services/cms/page_resolver.rb +73 -0
  71. data/app/services/cms/public_page_context.rb +49 -0
  72. data/app/views/cms/admin/api_keys/_form.html.erb +23 -0
  73. data/app/views/cms/admin/api_keys/create.html.erb +9 -0
  74. data/app/views/cms/admin/api_keys/edit.html.erb +5 -0
  75. data/app/views/cms/admin/api_keys/index.html.erb +36 -0
  76. data/app/views/cms/admin/api_keys/new.html.erb +5 -0
  77. data/app/views/cms/admin/documents/_form.html.erb +24 -0
  78. data/app/views/cms/admin/documents/edit.html.erb +2 -0
  79. data/app/views/cms/admin/documents/index.html.erb +37 -0
  80. data/app/views/cms/admin/documents/new.html.erb +2 -0
  81. data/app/views/cms/admin/form_fields/_form.html.erb +46 -0
  82. data/app/views/cms/admin/form_fields/edit.html.erb +2 -0
  83. data/app/views/cms/admin/form_fields/index.html.erb +41 -0
  84. data/app/views/cms/admin/form_fields/new.html.erb +2 -0
  85. data/app/views/cms/admin/form_submissions/index.html.erb +38 -0
  86. data/app/views/cms/admin/images/_form.html.erb +36 -0
  87. data/app/views/cms/admin/images/edit.html.erb +2 -0
  88. data/app/views/cms/admin/images/index.html.erb +25 -0
  89. data/app/views/cms/admin/images/new.html.erb +2 -0
  90. data/app/views/cms/admin/pages/_attach_section_panel.html.erb +20 -0
  91. data/app/views/cms/admin/pages/_form.html.erb +116 -0
  92. data/app/views/cms/admin/pages/_section_editor_frame.html.erb +3 -0
  93. data/app/views/cms/admin/pages/_sections_list.html.erb +9 -0
  94. data/app/views/cms/admin/pages/edit.html.erb +2 -0
  95. data/app/views/cms/admin/pages/index.html.erb +62 -0
  96. data/app/views/cms/admin/pages/new.html.erb +2 -0
  97. data/app/views/cms/admin/pages/show.html.erb +111 -0
  98. data/app/views/cms/admin/sections/_form.html.erb +128 -0
  99. data/app/views/cms/admin/sections/_section.html.erb +22 -0
  100. data/app/views/cms/admin/sections/edit.html.erb +9 -0
  101. data/app/views/cms/admin/sections/index.html.erb +47 -0
  102. data/app/views/cms/admin/sections/new.html.erb +9 -0
  103. data/app/views/cms/admin/sections/page_update.turbo_stream.erb +17 -0
  104. data/app/views/cms/admin/sections/show.html.erb +97 -0
  105. data/app/views/cms/admin/sites/_form.html.erb +44 -0
  106. data/app/views/cms/admin/sites/edit.html.erb +3 -0
  107. data/app/views/cms/admin/sites/new.html.erb +5 -0
  108. data/app/views/cms/admin/sites/show.html.erb +22 -0
  109. data/app/views/cms/admin/webhook_deliveries/index.html.erb +29 -0
  110. data/app/views/cms/admin/webhooks/_form.html.erb +38 -0
  111. data/app/views/cms/admin/webhooks/edit.html.erb +5 -0
  112. data/app/views/cms/admin/webhooks/index.html.erb +34 -0
  113. data/app/views/cms/admin/webhooks/new.html.erb +5 -0
  114. data/app/views/cms/form_submission_mailer/notify.html.erb +14 -0
  115. data/app/views/cms/form_submission_mailer/notify.text.erb +7 -0
  116. data/app/views/cms/public/pages/_content.html.erb +48 -0
  117. data/app/views/cms/public/pages/show.html.erb +44 -0
  118. data/app/views/cms/public/pages/templates/_custom.html.erb +3 -0
  119. data/app/views/cms/public/pages/templates/_form.html.erb +3 -0
  120. data/app/views/cms/public/pages/templates/_landing.html.erb +3 -0
  121. data/app/views/cms/public/pages/templates/_standard.html.erb +3 -0
  122. data/app/views/cms/sections/kinds/_cta.html.erb +13 -0
  123. data/app/views/cms/sections/kinds/_hero.html.erb +14 -0
  124. data/app/views/cms/sections/kinds/_image.html.erb +19 -0
  125. data/app/views/cms/sections/kinds/_rich_text.html.erb +6 -0
  126. data/app/views/layouts/cms/application.html.erb +13 -0
  127. data/app/views/layouts/cms/public.html.erb +14 -0
  128. data/bin/rails +19 -0
  129. data/bin/rubocop +9 -0
  130. data/cms.gemspec +29 -0
  131. data/config/importmap.rb +4 -0
  132. data/config/locales/activerecord.cms.en.yml +65 -0
  133. data/config/locales/en.yml +390 -0
  134. data/config/routes.rb +56 -0
  135. data/lib/cms/engine.rb +45 -0
  136. data/lib/cms/version.rb +5 -0
  137. data/lib/cms.rb +75 -0
  138. data/lib/cms42.rb +3 -0
  139. data/lib/generators/cms/install/install_generator.rb +26 -0
  140. data/lib/generators/cms/install/templates/create_cms_tables.rb +194 -0
  141. data/lib/generators/cms/install/templates/initializer.rb +21 -0
  142. data/lib/generators/cms/views/views_generator.rb +79 -0
  143. data/lib/tasks/cms_tasks.rake +6 -0
  144. data/lib/tasks/version.rake +8 -0
  145. metadata +281 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class ApiKey < ApplicationRecord
5
+ self.table_name = "cms_api_keys"
6
+
7
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :api_keys
8
+
9
+ validates :name, presence: true
10
+ validates :token, presence: true, uniqueness: true
11
+
12
+ before_validation :generate_token, on: :create
13
+
14
+ scope :active, -> { where(active: true) }
15
+ scope :ordered, -> { order(:name) }
16
+
17
+ def touch_last_used!
18
+ update_column(:last_used_at, Time.current)
19
+ end
20
+
21
+ private
22
+
23
+ def generate_token
24
+ self.token = SecureRandom.hex(32)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Document < ApplicationRecord
5
+ self.table_name = "cms_documents"
6
+
7
+ WORD_CONTENT_TYPES = [
8
+ "application/msword",
9
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
10
+ "application/vnd.ms-excel",
11
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
12
+ "application/vnd.ms-powerpoint",
13
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation"
14
+ ].freeze
15
+
16
+ PERMITTED_CONTENT_TYPES = [
17
+ "application/pdf",
18
+ "image/png",
19
+ "image/jpeg",
20
+ "image/tiff",
21
+ "text/plain",
22
+ "text/csv",
23
+ "application/zip",
24
+ "application/x-zip-compressed"
25
+ ] + WORD_CONTENT_TYPES
26
+
27
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :documents
28
+ has_one_attached :file
29
+
30
+ validates :title, :file, presence: true
31
+ validate :file_must_be_a_supported_document
32
+
33
+ scope :ordered, -> { order(:title) }
34
+
35
+ def display_title
36
+ title
37
+ end
38
+
39
+ private
40
+
41
+ def file_must_be_a_supported_document
42
+ return unless file.attached?
43
+ return if PERMITTED_CONTENT_TYPES.include?(file.blob.content_type)
44
+
45
+ errors.add(:file, I18n.t("cms.errors.document.invalid_file_type", default: "must be a supported document file"))
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class FormField < ApplicationRecord
5
+ self.table_name = "cms_form_fields"
6
+
7
+ KINDS = %w[text email textarea select checkbox].freeze
8
+
9
+ belongs_to :page, class_name: "Cms::Page", inverse_of: :form_fields
10
+
11
+ validates :kind, presence: true, inclusion: { in: KINDS }
12
+ validates :label, :field_name, presence: true
13
+ validates :field_name, uniqueness: { scope: :page_id },
14
+ format: { with: /\A[a-z0-9_]+\z/,
15
+ message: ->(*) { I18n.t("cms.errors.form_field.invalid_field_name") } }
16
+
17
+ scope :ordered, -> { order(:position, :id) }
18
+
19
+ def select?
20
+ kind == "select"
21
+ end
22
+
23
+ def parsed_options
24
+ Array(options)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class FormSubmission < ApplicationRecord
5
+ self.table_name = "cms_form_submissions"
6
+
7
+ belongs_to :page, class_name: "Cms::Page", inverse_of: :form_submissions
8
+
9
+ validates :data, presence: true
10
+ validate :contains_response_data
11
+ validate :required_fields_present
12
+
13
+ scope :recent, -> { order(created_at: :desc) }
14
+
15
+ def self.to_csv(submissions, fields)
16
+ require "csv"
17
+
18
+ CSV.generate(headers: true) do |csv|
19
+ csv << ([I18n.t("cms.admin.form_submissions.index.submitted_at")] + fields.map(&:label))
20
+ submissions.each do |sub|
21
+ csv << ([I18n.l(sub.created_at, format: :cms_csv_datetime)] + fields.map { |f| sub.data[f.field_name] })
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def contains_response_data
29
+ values = data.to_h.values.reject { |value| value.to_s.in?(%w[0]) }
30
+ errors.add(:base, I18n.t("cms.errors.form_submission.blank")) if values.all?(&:blank?)
31
+ end
32
+
33
+ def required_fields_present
34
+ page.form_fields.ordered.select(&:required?).each do |field|
35
+ value = data.to_h[field.field_name]
36
+ next unless value.to_s.blank? || value.to_s == "0"
37
+
38
+ errors.add(field.field_name.to_sym, I18n.t("cms.errors.form_submission.required", label: field.label))
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Image < ApplicationRecord
5
+ self.table_name = "cms_images"
6
+
7
+ PERMITTED_CONTENT_TYPES = [
8
+ "image/jpeg",
9
+ "image/png",
10
+ "image/webp",
11
+ "image/gif",
12
+ "image/svg+xml",
13
+ "image/tiff"
14
+ ].freeze
15
+
16
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :images
17
+ has_one_attached :file
18
+ has_many :section_images,
19
+ class_name: "Cms::SectionImage",
20
+ foreign_key: :image_id,
21
+ inverse_of: :image,
22
+ dependent: :destroy
23
+
24
+ has_many :image_translations,
25
+ class_name: "Cms::ImageTranslation",
26
+ foreign_key: :image_id,
27
+ inverse_of: :image,
28
+ dependent: :destroy
29
+
30
+ accepts_nested_attributes_for :image_translations
31
+
32
+ has_one :localised, -> { where(locale: I18n.locale.to_s) },
33
+ class_name: "Cms::ImageTranslation",
34
+ foreign_key: :image_id,
35
+ inverse_of: :image
36
+
37
+ delegate :alt_text, :caption, to: :localised, allow_nil: true
38
+
39
+ validates :title, :file, presence: true
40
+ validate :file_must_be_an_image
41
+
42
+ scope :ordered, -> { order(created_at: :desc) }
43
+
44
+ def display_title
45
+ title.presence || file.filename.to_s
46
+ end
47
+
48
+ def variant(dimensions)
49
+ file.variant(resize_to_limit: dimensions.split("x").map(&:to_i))
50
+ end
51
+
52
+ private
53
+
54
+ def file_must_be_an_image
55
+ return unless file.attached?
56
+ return if PERMITTED_CONTENT_TYPES.include?(file.blob.content_type)
57
+
58
+ errors.add(:file, I18n.t("cms.errors.image.invalid_file_type", default: "must be a valid image file"))
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class ImageTranslation < ApplicationRecord
5
+ self.table_name = "cms_image_translations"
6
+
7
+ belongs_to :image,
8
+ class_name: "Cms::Image",
9
+ inverse_of: :image_translations
10
+
11
+ validates :locale, :alt_text, presence: true
12
+ validates :locale, uniqueness: { scope: :image_id }
13
+
14
+ before_validation :normalize_locale
15
+
16
+ private
17
+
18
+ def normalize_locale
19
+ self.locale = locale.to_s.downcase.presence
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Page < ApplicationRecord
5
+ include ::Discard::Model
6
+
7
+ self.table_name = "cms_pages"
8
+
9
+ MAX_DEPTH = 10
10
+
11
+ module TemplateRegistry
12
+ class << self
13
+ def register(key)
14
+ normalized = key.to_s.parameterize.underscore
15
+ return if normalized.blank?
16
+
17
+ registered[normalized] = true
18
+ end
19
+
20
+ def registered_keys
21
+ registered.keys
22
+ end
23
+
24
+ private
25
+
26
+ def registered
27
+ @registered ||= begin
28
+ defaults = %w[standard landing form custom]
29
+ defaults.index_with { true }
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ has_one_attached :hero_image
36
+ has_many_attached :media_files
37
+
38
+ belongs_to :site,
39
+ class_name: "Cms::Site",
40
+ inverse_of: :pages
41
+
42
+ belongs_to :parent,
43
+ class_name: "Cms::Page",
44
+ optional: true,
45
+ inverse_of: :subpages
46
+
47
+ has_many :subpages,
48
+ class_name: "Cms::Page",
49
+ foreign_key: :parent_id,
50
+ dependent: :nullify,
51
+ inverse_of: :parent
52
+
53
+ has_many :page_translations,
54
+ class_name: "Cms::PageTranslation",
55
+ foreign_key: :page_id,
56
+ inverse_of: :page,
57
+ dependent: :destroy
58
+
59
+ accepts_nested_attributes_for :page_translations
60
+
61
+ has_one :localised, -> { where(locale: I18n.locale.to_s) },
62
+ class_name: "Cms::PageTranslation",
63
+ foreign_key: :page_id,
64
+ inverse_of: :page
65
+
66
+ delegate :title, :seo_title, :seo_description,
67
+ to: :localised, allow_nil: true
68
+
69
+ has_many :page_sections,
70
+ class_name: "Cms::PageSection",
71
+ foreign_key: :page_id,
72
+ inverse_of: :page,
73
+ dependent: :destroy
74
+
75
+ has_many :sections,
76
+ through: :page_sections,
77
+ source: :section
78
+
79
+ has_many :form_fields,
80
+ class_name: "Cms::FormField",
81
+ foreign_key: :page_id,
82
+ inverse_of: :page,
83
+ dependent: :destroy
84
+
85
+ has_many :form_submissions,
86
+ class_name: "Cms::FormSubmission",
87
+ foreign_key: :page_id,
88
+ inverse_of: :page,
89
+ dependent: :destroy
90
+
91
+ enum :status,
92
+ {
93
+ draft: "draft",
94
+ published: "published",
95
+ archived: "archived"
96
+ },
97
+ prefix: true
98
+
99
+ validates :slug, presence: true
100
+ validates :slug, uniqueness: { scope: :site_id }
101
+ validates :template_key, presence: true, inclusion: { in: ->(_) { template_keys } }
102
+ validates :depth, numericality: { less_than_or_equal_to: MAX_DEPTH }
103
+ validate :published_pages_require_sections
104
+
105
+ before_create :generate_preview_token
106
+ before_validation :set_slug
107
+ before_validation :normalize_home_slug
108
+ before_validation :set_defaults
109
+ before_validation :compute_depth
110
+ before_save :ensure_single_home_page, if: -> { home? && will_save_change_to_home? }
111
+ after_commit :fire_webhooks_on_status_change, on: %i[create update]
112
+ after_save :update_descendant_depths, if: :saved_change_to_parent_id?
113
+
114
+ scope :ordered, -> { order(:position, :id) }
115
+ scope :root, -> { where(parent_id: nil) }
116
+ scope :published, -> { where(status: "published") }
117
+ scope :header_nav, -> { where(show_in_header: true).where.not(nav_group: "none").order(:nav_order, :position, :id) }
118
+ scope :footer_nav, lambda {
119
+ where(show_in_footer: true).where.not(nav_group: "none").order(:footer_order, :position, :id)
120
+ }
121
+ scope :search, lambda { |q|
122
+ joins(:page_translations)
123
+ .where(
124
+ "lower(unaccent(cms_page_translations.title)) ilike lower(unaccent(?)) OR " \
125
+ "lower(unaccent(cms_pages.slug)) ilike lower(unaccent(?))",
126
+ "%#{q}%", "%#{q}%"
127
+ ).distinct
128
+ }
129
+ scope :search_ordered, ->(q) { search(q).ordered }
130
+
131
+ def self.template_keys
132
+ TemplateRegistry.registered_keys
133
+ end
134
+
135
+ def display_title
136
+ title.presence || slug.to_s.humanize
137
+ end
138
+
139
+ def display_meta_title
140
+ seo_title.presence || display_title
141
+ end
142
+
143
+ def ancestors
144
+ parent ? parent.ancestors + [parent] : []
145
+ end
146
+
147
+ def descendants
148
+ subpages.flat_map { |p| [p] + p.descendants }
149
+ end
150
+
151
+ def ordered_page_sections
152
+ page_sections.ordered.includes(:section)
153
+ end
154
+
155
+ def public_path_segments
156
+ return [] if home?
157
+
158
+ (ancestors + [self]).reject(&:home?).map(&:slug)
159
+ end
160
+
161
+ def public_path
162
+ public_path_segments.join("/")
163
+ end
164
+
165
+ private
166
+
167
+ def compute_depth
168
+ self.depth = parent ? (parent.depth + 1) : 0
169
+ end
170
+
171
+ def update_descendant_depths(parent_depth = depth)
172
+ subpages.find_each do |child|
173
+ child.update_column(:depth, parent_depth + 1)
174
+ child.update_descendant_depths(parent_depth + 1)
175
+ end
176
+ end
177
+
178
+ def fire_webhooks_on_status_change
179
+ return unless saved_change_to_status?
180
+
181
+ event = case status
182
+ when "published" then "page.published"
183
+ when "archived" then "page.unpublished"
184
+ end
185
+ return if event.blank?
186
+
187
+ site.webhooks.active.each do |webhook|
188
+ Cms::DeliverWebhookJob.perform_later(webhook.id, event, { "page_id" => id, "slug" => slug })
189
+ end
190
+ rescue StandardError
191
+ # Webhook delivery failures must never break page saves
192
+ end
193
+
194
+ def set_slug
195
+ default_locale = site.default_locale.presence || I18n.default_locale.to_s
196
+ translation_title = page_translations.find { |t| t.locale == default_locale }&.title if page_translations.loaded?
197
+
198
+ self.slug = slug.to_s.parameterize if slug.present?
199
+ self.slug = translation_title.to_s.parameterize if slug.blank? && translation_title.present?
200
+ end
201
+
202
+ def normalize_home_slug
203
+ self.slug = "home" if home?
204
+ end
205
+
206
+ def set_defaults
207
+ self.template_key = "standard" if template_key.blank?
208
+ self.nav_group = "main" if nav_group.blank?
209
+ end
210
+
211
+ def generate_preview_token
212
+ self.preview_token ||= SecureRandom.urlsafe_base64(32)
213
+ end
214
+
215
+ def ensure_single_home_page
216
+ site.pages.where.not(id: id).where(home: true).find_each do |page|
217
+ page.update!(home: false)
218
+ end
219
+ end
220
+
221
+ def published_pages_require_sections
222
+ return unless status_published?
223
+ return if page_sections.loaded? ? page_sections.reject(&:marked_for_destruction?).any? : page_sections.exists?
224
+
225
+ errors.add(:base, I18n.t("cms.errors.page.sections_required"))
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class PageSection < ApplicationRecord
5
+ self.table_name = "cms_page_sections"
6
+
7
+ belongs_to :page, class_name: "Cms::Page", inverse_of: :page_sections
8
+ belongs_to :section, class_name: "Cms::Section", inverse_of: :page_sections
9
+
10
+ validates :section_id, uniqueness: { scope: :page_id }
11
+ validate :section_site_matches_page
12
+ validate :global_sections_cannot_become_orphaned, on: :destroy
13
+
14
+ scope :ordered, -> { order(:position, :id) }
15
+
16
+ after_destroy :destroy_orphaned_section
17
+
18
+ private
19
+
20
+ def section_site_matches_page
21
+ return unless page && section
22
+ return if page.site_id == section.site_id
23
+
24
+ errors.add(:section, :invalid)
25
+ end
26
+
27
+ def global_sections_cannot_become_orphaned
28
+ return unless section&.global?
29
+ return if section.page_sections.where.not(id: id).exists?
30
+
31
+ errors.add(:base, I18n.t("cms.errors.section.global_section_requires_attachment"))
32
+ throw :abort
33
+ end
34
+
35
+ def destroy_orphaned_section
36
+ return unless Cms.config.auto_destroy_orphaned_sections
37
+ return if section.global?
38
+ return if section.page_sections.exists?
39
+
40
+ section.destroy
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class PageTranslation < ApplicationRecord
5
+ self.table_name = "cms_page_translations"
6
+
7
+ belongs_to :page,
8
+ class_name: "Cms::Page",
9
+ inverse_of: :page_translations
10
+
11
+ validates :locale, :title, presence: true
12
+ validates :locale, uniqueness: { scope: :page_id }
13
+
14
+ before_validation :normalize_locale
15
+
16
+ private
17
+
18
+ def normalize_locale
19
+ self.locale = locale.to_s.downcase.presence
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Section
5
+ class BlockBase
6
+ # Each subclass gets its own independent schema array
7
+ def self.inherited(subclass)
8
+ super
9
+ subclass.instance_variable_set(:@_settings_schema, [])
10
+ end
11
+
12
+ def self.settings_field(name, type:, required: false, default: nil, options: nil)
13
+ @_settings_schema ||= []
14
+ @_settings_schema << {
15
+ name: name.to_s,
16
+ type: type,
17
+ required: required,
18
+ default: default,
19
+ options: options
20
+ }.compact
21
+ end
22
+
23
+ def self.settings_schema
24
+ @_settings_schema || []
25
+ end
26
+
27
+ def self.kind
28
+ raise NotImplementedError, "#{self} must define .kind"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Section
5
+ module Blocks
6
+ class CallToActionBlock < BlockBase
7
+ def self.kind = "cta"
8
+
9
+ settings_field :button_text, type: :string, required: true
10
+ settings_field :button_url, type: :url, required: true
11
+ settings_field :alignment, type: :select, default: "center",
12
+ options: %w[left center right]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Section
5
+ module Blocks
6
+ class HeroBlock < BlockBase
7
+ def self.kind = "hero"
8
+
9
+ settings_field :background_color, type: :color, default: "#ffffff"
10
+ settings_field :cta_url, type: :url
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Section
5
+ module Blocks
6
+ class ImageBlock < BlockBase
7
+ def self.kind = "image"
8
+
9
+ settings_field :image_ids, type: :array
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Section
5
+ module Blocks
6
+ class RichTextBlock < BlockBase
7
+ def self.kind = "rich_text"
8
+ # No settings — title and content come from SectionTranslation
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Section
5
+ module KindRegistry
6
+ BUILT_IN_KINDS = %w[rich_text image hero cta].freeze
7
+
8
+ class UnknownKindError < StandardError; end
9
+
10
+ @registry = {}
11
+
12
+ class << self
13
+ # Register a section kind.
14
+ #
15
+ # @param kind [String, Symbol]
16
+ # @param partial [String, nil] override the default partial path
17
+ # @param block_class [Class, nil] block class that defines settings_schema
18
+ def register(kind, partial: nil, block_class: nil)
19
+ @registry[kind.to_s] = {
20
+ partial: partial.presence || "cms/sections/kinds/#{kind}",
21
+ block_class: block_class
22
+ }
23
+ end
24
+
25
+ # @param kind [String]
26
+ # @return [String] partial path
27
+ # @raise [Cms::Section::KindRegistry::UnknownKindError]
28
+ def partial_for(kind)
29
+ entry_for(kind)[:partial]
30
+ end
31
+
32
+ # @param kind [String]
33
+ # @return [Class, nil] block class or nil if not set
34
+ def block_class_for(kind)
35
+ entry_for(kind)[:block_class]
36
+ end
37
+
38
+ # @return [Array<String>]
39
+ def registered_kinds
40
+ @registry.keys
41
+ end
42
+
43
+ # @param kind [String, Symbol]
44
+ # @return [Boolean]
45
+ def registered?(kind)
46
+ @registry.key?(kind.to_s)
47
+ end
48
+
49
+ # For testing — resets to an empty registry
50
+ def reset!
51
+ @registry = {}
52
+ end
53
+
54
+ private
55
+
56
+ def entry_for(kind)
57
+ @registry.fetch(kind.to_s) do
58
+ raise UnknownKindError,
59
+ "No renderer registered for section kind: #{kind.inspect}. " \
60
+ "Registered kinds: #{@registry.keys.inspect}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end