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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +299 -0
- data/Rakefile +10 -0
- data/app/assets/stylesheets/cms/application.css +3 -0
- data/app/controllers/cms/admin/api_keys_controller.rb +52 -0
- data/app/controllers/cms/admin/base_controller.rb +54 -0
- data/app/controllers/cms/admin/documents_controller.rb +56 -0
- data/app/controllers/cms/admin/form_fields_controller.rb +70 -0
- data/app/controllers/cms/admin/form_submissions_controller.rb +36 -0
- data/app/controllers/cms/admin/images_controller.rb +67 -0
- data/app/controllers/cms/admin/pages_controller.rb +188 -0
- data/app/controllers/cms/admin/sections_controller.rb +177 -0
- data/app/controllers/cms/admin/sites_controller.rb +60 -0
- data/app/controllers/cms/admin/webhook_deliveries_controller.rb +19 -0
- data/app/controllers/cms/admin/webhooks_controller.rb +51 -0
- data/app/controllers/cms/api/base_controller.rb +45 -0
- data/app/controllers/cms/api/v1/base_controller.rb +29 -0
- data/app/controllers/cms/api/v1/pages_controller.rb +21 -0
- data/app/controllers/cms/api/v1/sites_controller.rb +18 -0
- data/app/controllers/cms/application_controller.rb +11 -0
- data/app/controllers/cms/public/base_controller.rb +23 -0
- data/app/controllers/cms/public/form_submissions_controller.rb +63 -0
- data/app/controllers/cms/public/previews_controller.rb +21 -0
- data/app/controllers/cms/public/sites_controller.rb +37 -0
- data/app/controllers/concerns/cms/admin/page_scoped_sections.rb +42 -0
- data/app/controllers/concerns/cms/current_site_resolver.rb +17 -0
- data/app/controllers/concerns/cms/public/page_paths.rb +31 -0
- data/app/controllers/concerns/cms/public/page_rendering.rb +23 -0
- data/app/controllers/concerns/cms/site_resolvable.rb +27 -0
- data/app/helpers/cms/admin/pages_helper.rb +29 -0
- data/app/helpers/cms/admin/sections_helper.rb +86 -0
- data/app/helpers/cms/admin/sites_helper.rb +8 -0
- data/app/helpers/cms/application_helper.rb +51 -0
- data/app/helpers/cms/media_helper.rb +28 -0
- data/app/helpers/cms/pages_helper.rb +16 -0
- data/app/helpers/cms/sections_helper.rb +25 -0
- data/app/helpers/cms/sites_helper.rb +11 -0
- data/app/javascript/cms/controllers/sortable_controller.js +38 -0
- data/app/jobs/cms/application_job.rb +6 -0
- data/app/jobs/cms/deliver_webhook_job.rb +66 -0
- data/app/mailers/cms/application_mailer.rb +9 -0
- data/app/mailers/cms/form_submission_mailer.rb +16 -0
- data/app/models/cms/api_key.rb +27 -0
- data/app/models/cms/application_record.rb +7 -0
- data/app/models/cms/document.rb +48 -0
- data/app/models/cms/form_field.rb +27 -0
- data/app/models/cms/form_submission.rb +42 -0
- data/app/models/cms/image.rb +61 -0
- data/app/models/cms/image_translation.rb +22 -0
- data/app/models/cms/page.rb +228 -0
- data/app/models/cms/page_section.rb +43 -0
- data/app/models/cms/page_translation.rb +22 -0
- data/app/models/cms/section/block_base.rb +32 -0
- data/app/models/cms/section/blocks/call_to_action_block.rb +16 -0
- data/app/models/cms/section/blocks/hero_block.rb +14 -0
- data/app/models/cms/section/blocks/image_block.rb +13 -0
- data/app/models/cms/section/blocks/rich_text_block.rb +12 -0
- data/app/models/cms/section/kind_registry.rb +66 -0
- data/app/models/cms/section.rb +94 -0
- data/app/models/cms/section_image.rb +10 -0
- data/app/models/cms/section_translation.rb +25 -0
- data/app/models/cms/site.rb +87 -0
- data/app/models/cms/webhook.rb +41 -0
- data/app/models/cms/webhook_delivery.rb +12 -0
- data/app/serializers/cms/api/base_serializer.rb +51 -0
- data/app/serializers/cms/api/page_serializer.rb +145 -0
- data/app/serializers/cms/api/site_serializer.rb +45 -0
- data/app/services/cms/locale_resolver.rb +30 -0
- data/app/services/cms/page_resolver.rb +73 -0
- data/app/services/cms/public_page_context.rb +49 -0
- data/app/views/cms/admin/api_keys/_form.html.erb +23 -0
- data/app/views/cms/admin/api_keys/create.html.erb +9 -0
- data/app/views/cms/admin/api_keys/edit.html.erb +5 -0
- data/app/views/cms/admin/api_keys/index.html.erb +36 -0
- data/app/views/cms/admin/api_keys/new.html.erb +5 -0
- data/app/views/cms/admin/documents/_form.html.erb +24 -0
- data/app/views/cms/admin/documents/edit.html.erb +2 -0
- data/app/views/cms/admin/documents/index.html.erb +37 -0
- data/app/views/cms/admin/documents/new.html.erb +2 -0
- data/app/views/cms/admin/form_fields/_form.html.erb +46 -0
- data/app/views/cms/admin/form_fields/edit.html.erb +2 -0
- data/app/views/cms/admin/form_fields/index.html.erb +41 -0
- data/app/views/cms/admin/form_fields/new.html.erb +2 -0
- data/app/views/cms/admin/form_submissions/index.html.erb +38 -0
- data/app/views/cms/admin/images/_form.html.erb +36 -0
- data/app/views/cms/admin/images/edit.html.erb +2 -0
- data/app/views/cms/admin/images/index.html.erb +25 -0
- data/app/views/cms/admin/images/new.html.erb +2 -0
- data/app/views/cms/admin/pages/_attach_section_panel.html.erb +20 -0
- data/app/views/cms/admin/pages/_form.html.erb +116 -0
- data/app/views/cms/admin/pages/_section_editor_frame.html.erb +3 -0
- data/app/views/cms/admin/pages/_sections_list.html.erb +9 -0
- data/app/views/cms/admin/pages/edit.html.erb +2 -0
- data/app/views/cms/admin/pages/index.html.erb +62 -0
- data/app/views/cms/admin/pages/new.html.erb +2 -0
- data/app/views/cms/admin/pages/show.html.erb +111 -0
- data/app/views/cms/admin/sections/_form.html.erb +128 -0
- data/app/views/cms/admin/sections/_section.html.erb +22 -0
- data/app/views/cms/admin/sections/edit.html.erb +9 -0
- data/app/views/cms/admin/sections/index.html.erb +47 -0
- data/app/views/cms/admin/sections/new.html.erb +9 -0
- data/app/views/cms/admin/sections/page_update.turbo_stream.erb +17 -0
- data/app/views/cms/admin/sections/show.html.erb +97 -0
- data/app/views/cms/admin/sites/_form.html.erb +44 -0
- data/app/views/cms/admin/sites/edit.html.erb +3 -0
- data/app/views/cms/admin/sites/new.html.erb +5 -0
- data/app/views/cms/admin/sites/show.html.erb +22 -0
- data/app/views/cms/admin/webhook_deliveries/index.html.erb +29 -0
- data/app/views/cms/admin/webhooks/_form.html.erb +38 -0
- data/app/views/cms/admin/webhooks/edit.html.erb +5 -0
- data/app/views/cms/admin/webhooks/index.html.erb +34 -0
- data/app/views/cms/admin/webhooks/new.html.erb +5 -0
- data/app/views/cms/form_submission_mailer/notify.html.erb +14 -0
- data/app/views/cms/form_submission_mailer/notify.text.erb +7 -0
- data/app/views/cms/public/pages/_content.html.erb +48 -0
- data/app/views/cms/public/pages/show.html.erb +44 -0
- data/app/views/cms/public/pages/templates/_custom.html.erb +3 -0
- data/app/views/cms/public/pages/templates/_form.html.erb +3 -0
- data/app/views/cms/public/pages/templates/_landing.html.erb +3 -0
- data/app/views/cms/public/pages/templates/_standard.html.erb +3 -0
- data/app/views/cms/sections/kinds/_cta.html.erb +13 -0
- data/app/views/cms/sections/kinds/_hero.html.erb +14 -0
- data/app/views/cms/sections/kinds/_image.html.erb +19 -0
- data/app/views/cms/sections/kinds/_rich_text.html.erb +6 -0
- data/app/views/layouts/cms/application.html.erb +13 -0
- data/app/views/layouts/cms/public.html.erb +14 -0
- data/bin/rails +19 -0
- data/bin/rubocop +9 -0
- data/cms.gemspec +29 -0
- data/config/importmap.rb +4 -0
- data/config/locales/activerecord.cms.en.yml +65 -0
- data/config/locales/en.yml +390 -0
- data/config/routes.rb +56 -0
- data/lib/cms/engine.rb +45 -0
- data/lib/cms/version.rb +5 -0
- data/lib/cms.rb +75 -0
- data/lib/cms42.rb +3 -0
- data/lib/generators/cms/install/install_generator.rb +26 -0
- data/lib/generators/cms/install/templates/create_cms_tables.rb +194 -0
- data/lib/generators/cms/install/templates/initializer.rb +21 -0
- data/lib/generators/cms/views/views_generator.rb +79 -0
- data/lib/tasks/cms_tasks.rake +6 -0
- data/lib/tasks/version.rake +8 -0
- 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 %>
|