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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Section < ApplicationRecord
5
+ include ::Discard::Model
6
+
7
+ self.table_name = "cms_sections"
8
+
9
+ attribute :settings, default: -> { {} }
10
+ store_accessor :settings, :background_color, :cta_url, :button_url, :alignment
11
+
12
+ belongs_to :site,
13
+ class_name: "Cms::Site",
14
+ inverse_of: :sections
15
+
16
+ has_many :page_sections,
17
+ class_name: "Cms::PageSection",
18
+ foreign_key: :section_id,
19
+ inverse_of: :section,
20
+ dependent: :destroy
21
+
22
+ has_many :pages,
23
+ through: :page_sections,
24
+ source: :page
25
+
26
+ has_many :translations,
27
+ class_name: "Cms::SectionTranslation",
28
+ foreign_key: :section_id,
29
+ inverse_of: :section,
30
+ dependent: :destroy
31
+
32
+ has_one :localised, -> { where(locale: I18n.locale.to_s) },
33
+ class_name: "Cms::SectionTranslation",
34
+ foreign_key: :section_id,
35
+ inverse_of: :section
36
+
37
+ has_many :section_images,
38
+ -> { order(:position) },
39
+ class_name: "Cms::SectionImage",
40
+ foreign_key: :section_id,
41
+ inverse_of: :section,
42
+ dependent: :destroy
43
+
44
+ has_many :images,
45
+ through: :section_images,
46
+ source: :image,
47
+ class_name: "Cms::Image"
48
+
49
+ accepts_nested_attributes_for :translations
50
+
51
+ validates :kind, presence: true, inclusion: { in: -> { Cms::Section::KindRegistry.registered_kinds } }
52
+ validates :alignment, inclusion: { in: %w[left center right] }, allow_blank: true
53
+
54
+ scope :ordered, -> { order(:kind, :id) }
55
+ scope :enabled, -> { where(enabled: true) }
56
+ scope :global, -> { where(global: true) }
57
+ scope :local, -> { where(global: false) }
58
+ scope :by_kind, ->(kind) { where(kind: kind) }
59
+
60
+ delegate :title, :subtitle, :content, to: :localised, allow_nil: true
61
+
62
+ def cta_text = localised&.subtitle
63
+ def button_text = localised&.subtitle
64
+
65
+ def build_missing_locale_translations
66
+ present = if translations.loaded?
67
+ translations.target.map(&:locale)
68
+ elsif new_record?
69
+ []
70
+ else
71
+ translations.pluck(:locale)
72
+ end
73
+ I18n.available_locales.map(&:to_s).each do |locale|
74
+ translations.build(locale: locale) unless present.include?(locale)
75
+ end
76
+ end
77
+
78
+ def image_assets
79
+ images.includes(file_attachment: :blob)
80
+ end
81
+
82
+ def available_locales
83
+ translations.map(&:locale)
84
+ end
85
+
86
+ def translation_for(locale)
87
+ translations.find { |t| t.locale == locale.to_s }
88
+ end
89
+
90
+ def settings
91
+ super || {}
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class SectionImage < ApplicationRecord
5
+ self.table_name = "cms_section_images"
6
+
7
+ belongs_to :section, class_name: "Cms::Section", inverse_of: :section_images
8
+ belongs_to :image, class_name: "Cms::Image"
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class SectionTranslation < ApplicationRecord
5
+ self.table_name = "cms_section_translations"
6
+
7
+ has_rich_text :content
8
+
9
+ belongs_to :section,
10
+ class_name: "Cms::Section",
11
+ inverse_of: :translations
12
+
13
+ validates :locale, :title, presence: true
14
+ validates :locale, uniqueness: { scope: :section_id }
15
+ validates :content, presence: true, if: -> { section&.kind.in?(%w[rich_text hero cta]) }
16
+
17
+ before_validation :normalize_locale
18
+
19
+ private
20
+
21
+ def normalize_locale
22
+ self.locale = locale.to_s.downcase.presence
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Site < ApplicationRecord
5
+ self.table_name = "cms_sites"
6
+
7
+ has_one_attached :logo
8
+
9
+ has_many :pages,
10
+ class_name: "Cms::Page",
11
+ foreign_key: :site_id,
12
+ inverse_of: :site,
13
+ dependent: :destroy
14
+
15
+ has_many :images,
16
+ class_name: "Cms::Image",
17
+ foreign_key: :site_id,
18
+ inverse_of: :site,
19
+ dependent: :destroy
20
+
21
+ has_many :documents,
22
+ class_name: "Cms::Document",
23
+ foreign_key: :site_id,
24
+ inverse_of: :site,
25
+ dependent: :destroy
26
+
27
+ has_many :sections,
28
+ class_name: "Cms::Section",
29
+ foreign_key: :site_id,
30
+ inverse_of: :site,
31
+ dependent: :destroy
32
+
33
+ has_many :api_keys,
34
+ class_name: "Cms::ApiKey",
35
+ foreign_key: :site_id,
36
+ inverse_of: :site,
37
+ dependent: :destroy
38
+
39
+ has_many :webhooks,
40
+ class_name: "Cms::Webhook",
41
+ foreign_key: :site_id,
42
+ inverse_of: :site,
43
+ dependent: :destroy
44
+
45
+ validates :name, :slug, :default_locale, presence: true
46
+ validates :slug, uniqueness: true
47
+ validate :default_locale_is_available
48
+
49
+ before_validation :parameterize_slug
50
+ before_validation :normalize_locales
51
+
52
+ scope :live, -> { where(published: true) }
53
+
54
+ def published_pages
55
+ pages.kept.published.ordered
56
+ end
57
+
58
+ def header_pages
59
+ published_pages.header_nav
60
+ end
61
+
62
+ def footer_pages
63
+ published_pages.footer_nav
64
+ end
65
+
66
+ def home_page
67
+ published_pages.find_by(home: true) || published_pages.first
68
+ end
69
+
70
+ private
71
+
72
+ def parameterize_slug
73
+ self.slug = slug.to_s.parameterize if slug.present?
74
+ end
75
+
76
+ def normalize_locales
77
+ self[:default_locale] = self[:default_locale].to_s.presence || I18n.default_locale.to_s
78
+ end
79
+
80
+ def default_locale_is_available
81
+ supported = I18n.available_locales.map(&:to_s)
82
+ return if supported.include?(self[:default_locale].to_s)
83
+
84
+ errors.add(:default_locale, I18n.t("cms.errors.site.default_locale_unavailable"))
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class Webhook < ApplicationRecord
5
+ self.table_name = "cms_webhooks"
6
+
7
+ EVENTS = %w[page.published page.unpublished].freeze
8
+
9
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :webhooks
10
+
11
+ has_many :deliveries,
12
+ class_name: "Cms::WebhookDelivery",
13
+ foreign_key: :webhook_id,
14
+ inverse_of: :webhook,
15
+ dependent: :destroy
16
+
17
+ validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
18
+ validates :events, presence: true
19
+ validate :events_must_be_supported
20
+
21
+ scope :active, -> { where(active: true) }
22
+ scope :ordered, -> { order(:url) }
23
+
24
+ before_validation :normalize_events
25
+
26
+ private
27
+
28
+ def normalize_events
29
+ self.events = Array(events).map(&:to_s).reject(&:blank?).uniq
30
+ end
31
+
32
+ def events_must_be_supported
33
+ return if events.blank?
34
+
35
+ invalid_events = events - EVENTS
36
+ return if invalid_events.empty?
37
+
38
+ errors.add(:events, I18n.t("cms.errors.webhook.unsupported_events", events: invalid_events.join(", ")))
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class WebhookDelivery < ApplicationRecord
5
+ self.table_name = "cms_webhook_deliveries"
6
+
7
+ belongs_to :webhook, class_name: "Cms::Webhook", inverse_of: :deliveries
8
+
9
+ scope :ordered, -> { order(delivered_at: :desc) }
10
+ scope :recent, -> { ordered.limit(50) }
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Api
5
+ class BaseSerializer
6
+ def initialize(site:, requested_locale:, main_app:)
7
+ @site = site
8
+ @requested_locale = requested_locale
9
+ @main_app = main_app
10
+ end
11
+
12
+ private
13
+
14
+ attr_reader :site, :requested_locale, :main_app
15
+
16
+ def resolved_content_locale(available_locales)
17
+ Cms::LocaleResolver.resolve(
18
+ requested: requested_locale,
19
+ site: site,
20
+ available: available_locales
21
+ )
22
+ end
23
+
24
+ def attachment_path(attachment)
25
+ return nil unless attachment.attached?
26
+
27
+ main_app.rails_blob_path(attachment, only_path: true)
28
+ end
29
+
30
+ def serialize_media(file)
31
+ {
32
+ filename: file.filename.to_s,
33
+ url: attachment_path(file)
34
+ }
35
+ end
36
+
37
+ def site_attributes(resolved_locale:)
38
+ {
39
+ id: site.id,
40
+ name: site.name,
41
+ slug: site.slug,
42
+ default_locale: site.default_locale,
43
+ resolved_locale: resolved_locale,
44
+ available_locales: I18n.available_locales.map(&:to_s),
45
+ logo_url: attachment_path(site.logo),
46
+ favicon_url: attachment_path(site.logo)
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Api
5
+ class PageSerializer < BaseSerializer
6
+ def initialize(site:, page:, requested_locale:, main_app:)
7
+ super(site: site, requested_locale: requested_locale, main_app: main_app)
8
+ @page = page
9
+ end
10
+
11
+ def as_json(include_site: false)
12
+ payload = serialize_page
13
+ return payload unless include_site
14
+
15
+ payload.merge(
16
+ site: Cms.api_site_serializer_class.new(
17
+ site: site,
18
+ requested_locale: payload[:resolved_locale],
19
+ main_app: main_app
20
+ ).as_json(include_pages: false)
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :page
27
+
28
+ def serialize_page
29
+ locale = resolved_content_locale(page.page_translations.map(&:locale))
30
+ translation = page.page_translations.find { |record| record.locale == locale }
31
+
32
+ base_page_attributes(locale, translation).merge(
33
+ meta_title: page_meta_title(translation),
34
+ meta_description: translation&.seo_description,
35
+ media_files: page.media_files.map { |file| serialize_media(file) },
36
+ sections: serialize_page_sections(locale)
37
+ )
38
+ end
39
+
40
+ def serialize_page_sections(locale)
41
+ page.page_sections.ordered.includes(:section).filter_map do |page_section|
42
+ section = page_section.section
43
+ next unless section.enabled?
44
+
45
+ serialize_section(section, position: page_section.position, requested_locale: locale)
46
+ end
47
+ end
48
+
49
+ def serialize_section(section, position:, requested_locale:)
50
+ locale = resolved_content_locale_for_section(section, requested_locale)
51
+ translation = section.translation_for(locale)
52
+
53
+ {
54
+ id: section.id,
55
+ kind: section.kind,
56
+ position: position,
57
+ resolved_locale: locale,
58
+ available_locales: section.available_locales,
59
+ title: translation.respond_to?(:title) ? translation.title : nil,
60
+ body: translation.respond_to?(:content) ? translation.content.to_s : "",
61
+ settings: {},
62
+ data: serialize_section_data(section, requested_locale: locale)
63
+ }
64
+ end
65
+
66
+ def resolved_content_locale_for_section(section, requested_locale)
67
+ available_locales = section.available_locales
68
+ if available_locales.empty? && section.kind == "image"
69
+ available_locales = section.images.flat_map { |image| image.image_translations.map(&:locale) }.uniq
70
+ end
71
+
72
+ Cms::LocaleResolver.resolve(
73
+ requested: requested_locale,
74
+ site: site,
75
+ available: available_locales
76
+ )
77
+ end
78
+
79
+ def serialize_section_data(section, requested_locale:)
80
+ case section.kind
81
+ when "hero"
82
+ {
83
+ background_color: section.background_color,
84
+ cta_text: section.translation_for(requested_locale)&.subtitle,
85
+ cta_url: section.cta_url
86
+ }
87
+ when "cta"
88
+ {
89
+ button_text: section.translation_for(requested_locale)&.subtitle,
90
+ button_url: section.button_url,
91
+ alignment: section.alignment.presence || "center"
92
+ }
93
+ when "image"
94
+ image_payloads = serialize_section_images(section, requested_locale)
95
+ {
96
+ images: image_payloads
97
+ }
98
+ else
99
+ {}
100
+ end
101
+ end
102
+
103
+ def serialize_section_images(section, requested_locale)
104
+ section.image_assets.filter_map do |image|
105
+ locale = Cms::LocaleResolver.resolve(
106
+ requested: requested_locale,
107
+ site: site,
108
+ available: image.image_translations.map(&:locale)
109
+ )
110
+ translation = image.image_translations.find { |record| record.locale == locale }
111
+
112
+ {
113
+ id: image.id,
114
+ title: image.title,
115
+ alt_text: translation&.alt_text,
116
+ caption: translation&.caption,
117
+ url: attachment_path(image.file)
118
+ }
119
+ end
120
+ end
121
+
122
+ def base_page_attributes(locale, translation)
123
+ {
124
+ id: page.id,
125
+ template_key: page.template_key,
126
+ status: page.status,
127
+ resolved_locale: locale,
128
+ available_locales: page.page_translations.map(&:locale),
129
+ title: translation_title(translation),
130
+ slug: page.slug,
131
+ path: page.public_path,
132
+ hero_image_url: attachment_path(page.hero_image)
133
+ }
134
+ end
135
+
136
+ def translation_title(translation)
137
+ translation&.title.presence || page.slug.to_s.humanize
138
+ end
139
+
140
+ def page_meta_title(translation)
141
+ translation&.seo_title.presence || translation_title(translation)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Api
5
+ class SiteSerializer < BaseSerializer
6
+ def as_json(include_pages: true)
7
+ payload = site_attributes(resolved_locale: requested_locale)
8
+ return payload unless include_pages
9
+
10
+ payload.merge(
11
+ pages: published_pages.map do |page|
12
+ serialize_site_page(page)
13
+ end
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ def published_pages
20
+ site.published_pages.includes(:page_translations, { hero_image_attachment: :blob })
21
+ end
22
+
23
+ def serialize_site_page(page)
24
+ locale = resolved_content_locale(page.page_translations.map(&:locale))
25
+ translation = page.page_translations.find { |record| record.locale == locale }
26
+
27
+ {
28
+ id: page.id,
29
+ template_key: page.template_key,
30
+ status: page.status,
31
+ resolved_locale: locale,
32
+ available_locales: page.page_translations.map(&:locale),
33
+ title: translation&.title.presence || page.slug.to_s.humanize,
34
+ slug: page.slug,
35
+ path: page.public_path,
36
+ home: page.home,
37
+ nav_group: page.nav_group,
38
+ show_in_header: page.show_in_header,
39
+ show_in_footer: page.show_in_footer,
40
+ hero_image_url: attachment_path(page.hero_image)
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ # Resolves the best available locale given a preference and a set of available locales.
5
+ #
6
+ # Fallback chain: requested → site default → I18n default → first available
7
+ class LocaleResolver
8
+ def self.resolve(requested:, site:, available:)
9
+ new(requested: requested, site: site, available: available).resolve
10
+ end
11
+
12
+ def initialize(requested:, site:, available:)
13
+ @requested = requested.to_s.presence
14
+ @site = site
15
+ @available = Array(available).map(&:to_s)
16
+ end
17
+
18
+ def resolve
19
+ return fallback_chain.first if @available.empty?
20
+
21
+ fallback_chain.find { |locale| @available.include?(locale) } || @available.first
22
+ end
23
+
24
+ private
25
+
26
+ def fallback_chain
27
+ [@requested, @site.default_locale, I18n.default_locale.to_s].compact.uniq
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class PageResolver
5
+ Result = Struct.new(:page, :locale)
6
+
7
+ # Finds a published page by slug within a site and resolves the best
8
+ # available locale using the fallback chain:
9
+ # requested locale → site.default_locale → I18n.default_locale → any
10
+ #
11
+ # @param site [Cms::Site]
12
+ # @param slug [String, nil] nil or blank resolves the home page; nested
13
+ # paths such as "about/history" resolve by ancestor chain
14
+ # @param locale [String, nil] the caller's preferred locale
15
+ # @return [Result, nil]
16
+ def self.resolve(site:, slug: nil, locale: nil)
17
+ new(site: site, slug: slug, locale: locale).resolve
18
+ end
19
+
20
+ def initialize(site:, slug:, locale:)
21
+ @site = site
22
+ @slug = slug.to_s
23
+ @locale = locale.to_s.presence
24
+ end
25
+
26
+ def resolve
27
+ page = find_page
28
+ return nil unless page
29
+
30
+ Result.new(page: page, locale: resolved_locale_for(page))
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :site, :slug, :locale
36
+
37
+ def normalized_segments
38
+ @normalized_segments ||= slug.to_s.split("/").filter_map do |segment|
39
+ normalized = segment.to_s.parameterize
40
+ normalized if normalized.present?
41
+ end
42
+ end
43
+
44
+ def find_page
45
+ scope = site.published_pages
46
+ .includes(
47
+ :page_translations,
48
+ :localised,
49
+ { page_sections: :section },
50
+ :form_fields,
51
+ { hero_image_attachment: :blob }
52
+ )
53
+
54
+ if normalized_segments.empty?
55
+ scope.find_by(home: true) || scope.first
56
+ else
57
+ page = scope.find_by(slug: normalized_segments.last)
58
+ return unless page
59
+ return page if page.public_path_segments == normalized_segments
60
+
61
+ nil
62
+ end
63
+ end
64
+
65
+ def resolved_locale_for(page)
66
+ Cms::LocaleResolver.resolve(
67
+ requested: @locale,
68
+ site: @site,
69
+ available: page.page_translations.map(&:locale)
70
+ )
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class PublicPageContext
5
+ attr_reader :site, :page, :header_nav_items, :footer_pages, :sections, :form_fields, :submission
6
+
7
+ def self.build(site:, page:)
8
+ new(site: site, page: page).tap(&:load)
9
+ end
10
+
11
+ def initialize(site:, page:)
12
+ @site = site
13
+ @page = page
14
+ end
15
+
16
+ def load
17
+ @header_nav_items = load_header_nav
18
+ @footer_pages = site.footer_pages.includes(:localised)
19
+ @sections = load_sections
20
+ @form_fields = page.form_fields.ordered.to_a
21
+ @submission = page.form_submissions.build
22
+ end
23
+
24
+ private
25
+
26
+ def load_sections
27
+ page.page_sections
28
+ .ordered
29
+ .includes(:section)
30
+ .filter_map do |page_section|
31
+ section = page_section.section
32
+ section if section.enabled? && section.kept?
33
+ end
34
+ end
35
+
36
+ def load_header_nav
37
+ site.published_pages
38
+ .root
39
+ .header_nav
40
+ .includes(:localised, subpages: :localised)
41
+ .map do |p|
42
+ {
43
+ page: p,
44
+ children: p.subpages.select { |subpage| subpage.status_published? && subpage.show_in_header? }
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ <%= form_with model: [:admin, api_key] do |f| %>
2
+ <% if api_key.errors.any? %>
3
+ <div>
4
+ <% api_key.errors.full_messages.each do |msg| %>
5
+ <p><%= msg %></p>
6
+ <% end %>
7
+ </div>
8
+ <% end %>
9
+
10
+ <div>
11
+ <%= f.label :name %>
12
+ <%= f.text_field :name %>
13
+ </div>
14
+
15
+ <div>
16
+ <%= f.label :active %>
17
+ <%= f.check_box :active %>
18
+ </div>
19
+
20
+ <div>
21
+ <%= f.submit %>
22
+ </div>
23
+ <% end %>