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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Public
5
+ class FormSubmissionsController < BaseController
6
+ include Cms::SiteResolvable
7
+ include Cms::Public::PageRendering
8
+ include Cms::Public::PagePaths
9
+
10
+ def create
11
+ @site = find_site!
12
+ @page = @site.published_pages.includes(:form_fields, :page_translations, :localised,
13
+ { page_sections: :section },
14
+ { hero_image_attachment: :blob }).find(params[:page_id])
15
+ @fields = @page.form_fields.ordered
16
+ data = build_submission_data
17
+
18
+ @submission = @page.form_submissions.build(
19
+ data: data,
20
+ ip_address: request.remote_ip
21
+ )
22
+
23
+ if @submission.save
24
+ notify_by_email(@submission)
25
+ redirect_back(
26
+ fallback_location: public_site_page_path_for(@site, @page),
27
+ notice: t("cms.notices.form_submission_sent")
28
+ )
29
+ else
30
+ I18n.with_locale(@site.default_locale) do
31
+ assign_public_page(site: @site, page: @page)
32
+ render template: "cms/public/pages/show", status: :unprocessable_content
33
+ end
34
+ end
35
+ rescue ActiveRecord::RecordNotFound
36
+ render plain: t("cms.errors.page_not_found"), status: :not_found
37
+ end
38
+
39
+ private
40
+
41
+ def build_submission_data
42
+ @fields.to_h do |field|
43
+ [field.field_name, normalized_submission_value(field)]
44
+ end
45
+ end
46
+
47
+ def normalized_submission_value(field)
48
+ params.dig(:submission, field.field_name)
49
+ end
50
+
51
+ def notify_by_email(submission)
52
+ return unless Cms.config.form_submission_email
53
+
54
+ email = Cms.config.form_submission_email.call(@page)
55
+ return if email.blank?
56
+
57
+ Cms::FormSubmissionMailer.notify(submission, email).deliver_later
58
+ rescue StandardError
59
+ # Mailer failures must never break form submission
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Public
5
+ class PreviewsController < BaseController
6
+ include Cms::SiteResolvable
7
+ include Cms::Public::PageRendering
8
+ include Cms::Public::PagePaths
9
+
10
+ def show
11
+ page = Cms::Page.kept.includes(:site).find_by!(preview_token: params[:preview_token])
12
+ site = page.site
13
+
14
+ I18n.with_locale(site.default_locale) do
15
+ assign_public_page(site: site, page: page)
16
+ render template: "cms/public/pages/show"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Public
5
+ class SitesController < BaseController
6
+ include Cms::SiteResolvable
7
+ include Cms::Public::PageRendering
8
+ include Cms::Public::PagePaths
9
+
10
+ def show
11
+ site = find_site!
12
+ result = find_page_for_show!(site)
13
+
14
+ return render_not_found unless result
15
+
16
+ I18n.with_locale(result.locale) do
17
+ assign_public_page(site: site, page: result.page)
18
+ render template: "cms/public/pages/show"
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def find_page_for_show!(site)
25
+ page_resolver_class.resolve(
26
+ site: site,
27
+ slug: params[:slug],
28
+ locale: I18n.locale.to_s
29
+ )
30
+ end
31
+
32
+ def render_not_found
33
+ render plain: t("cms.errors.page_not_found"), status: :not_found
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ module PageScopedSections
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def set_page
11
+ @page = current_site.pages.find(params[:page_id])
12
+ end
13
+
14
+ def page_scoped_request?
15
+ params[:page_id].present?
16
+ end
17
+
18
+ def turbo_page_scoped_request?
19
+ page_scoped_request? && (turbo_frame_request? || request.format.turbo_stream?)
20
+ end
21
+
22
+ def attach_to_page!
23
+ @page.page_sections.create!(section: @section, position: next_position)
24
+ end
25
+
26
+ def next_position
27
+ @page.page_sections.maximum(:position).to_i + 1
28
+ end
29
+
30
+ def load_page_show_context
31
+ @translation_locale = requested_locale
32
+ @subpages = @page.subpages.sort_by { |page| [page.position || 0, page.id] }
33
+ @page_sections = @page.page_sections.ordered.includes(:section)
34
+ @available_sections = current_site.sections
35
+ .kept
36
+ .global
37
+ .where.not(id: @page.section_ids)
38
+ .ordered
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module CurrentSiteResolver
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+
9
+ def configured_current_site
10
+ resolver = Cms.config.current_site_resolver
11
+ site = resolver.call(self) if resolver
12
+ return site if site.is_a?(Cms::Site)
13
+
14
+ nil
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Public
5
+ module PagePaths
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :public_site_path_for, :public_site_page_path_for
10
+ end
11
+
12
+ private
13
+
14
+ def routed_without_site_slug?
15
+ params[:site_slug].blank? && (request.headers["X-CMS-SITE-SLUG"].present? || request.subdomains.first.present?)
16
+ end
17
+
18
+ def public_site_path_for(site)
19
+ routed_without_site_slug? ? current_site_path : site_path(site.slug)
20
+ end
21
+
22
+ def public_site_page_path_for(site, page)
23
+ return public_site_path_for(site) if page.home?
24
+
25
+ page_path = page.public_path
26
+
27
+ routed_without_site_slug? ? current_site_page_path(page_path) : site_page_path(site.slug, page_path)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Public
5
+ module PageRendering
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def assign_public_page(site:, page:)
11
+ ctx = Cms::PublicPageContext.build(site: site, page: page)
12
+
13
+ @site = ctx.site
14
+ @page = ctx.page
15
+ @header_nav_items = ctx.header_nav_items
16
+ @footer_pages = ctx.footer_pages
17
+ @sections = ctx.sections
18
+ @form_fields = ctx.form_fields
19
+ @submission = ctx.submission if @submission.nil?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module SiteResolvable
5
+ extend ActiveSupport::Concern
6
+ include Cms::CurrentSiteResolver
7
+
8
+ private
9
+
10
+ def find_site!
11
+ return @resolved_site if defined?(@resolved_site) && @resolved_site
12
+ return configured_current_site if configured_current_site
13
+
14
+ Cms::Site.live.find_by!(slug: resolved_site_slug!)
15
+ end
16
+
17
+ def resolved_site_slug!
18
+ slug = params[:site_slug].presence ||
19
+ request.headers["X-CMS-SITE-SLUG"].presence ||
20
+ request.subdomains.first.presence
21
+ normalized = slug.to_s.parameterize
22
+ raise ActiveRecord::RecordNotFound, I18n.t("cms.errors.site_slug_not_provided") if normalized.blank?
23
+
24
+ normalized
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ module PagesHelper
6
+ # Flatten a tree of pages (already loaded with subpages) into [page, depth] pairs.
7
+ def flat_page_tree(pages, depth: 0, result: [])
8
+ pages.each do |page|
9
+ result << [page, depth]
10
+ flat_page_tree(Array(page.subpages), depth: depth + 1, result: result) if page.subpages.loaded?
11
+ end
12
+ result
13
+ end
14
+
15
+ # Returns breadcrumb array for a page: [ancestor, ..., page]
16
+ def breadcrumbs_for(page)
17
+ page.ancestors + [page]
18
+ end
19
+
20
+ # Translation completeness: { "en" => :complete, "fr" => :missing }
21
+ def translation_completeness(page)
22
+ I18n.available_locales.each_with_object({}) do |locale, hash|
23
+ translation = page.page_translations.find { |t| t.locale == locale.to_s }
24
+ hash[locale.to_s] = translation&.title.present? ? :complete : :missing
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ module SectionsHelper
6
+ # Renders a form field for a block settings field definition.
7
+ #
8
+ # @param form [ActionView::Helpers::FormBuilder]
9
+ # @param field [Hash] field definition from BlockBase.settings_schema
10
+ def render_settings_field(form, field, site: nil)
11
+ name = field[:name]
12
+
13
+ content_tag(:div, class: "cms-form__field") do
14
+ concat form.label("settings_#{name}", cms_section_setting_label(name), class: "cms-form__label")
15
+ concat settings_input(form, field, site: site)
16
+ if field[:type] == :image
17
+ concat image_library_actions
18
+ concat image_library_hint(site) if site&.images&.none?
19
+ end
20
+ end
21
+ end
22
+
23
+ def cms_section_image_options(site)
24
+ return [] unless site
25
+
26
+ site.images.ordered.map { |image| [image.display_title, image.id] }
27
+ end
28
+
29
+ private
30
+
31
+ def settings_input(form, field, site:)
32
+ name = field[:name]
33
+
34
+ case field[:type]
35
+ when :url
36
+ form.url_field "settings[#{name}]", input_options(form, name)
37
+ when :color
38
+ form.color_field "settings[#{name}]", input_options(form, name, default: field[:default])
39
+ when :boolean
40
+ form.check_box "settings[#{name}]", { class: "cms-checkbox" }, "true", "false"
41
+ when :select
42
+ form.select "settings[#{name}]",
43
+ select_options(field),
44
+ { selected: form.object.settings[name] || field[:default] },
45
+ class: "cms-input"
46
+ when :image
47
+ form.select "settings[#{name}]",
48
+ cms_section_image_options(site),
49
+ {
50
+ include_blank: t("cms.admin.sections.form.select_image"),
51
+ selected: form.object.settings[name]
52
+ },
53
+ class: "cms-input"
54
+ else
55
+ form.text_field "settings[#{name}]", input_options(form, name)
56
+ end
57
+ end
58
+
59
+ def input_options(form, name, default: nil)
60
+ { class: "cms-input", value: form.object.settings[name] || default }
61
+ end
62
+
63
+ def select_options(field)
64
+ field[:options].map { |option| [cms_section_setting_option_label(field[:name], option), option] }
65
+ end
66
+
67
+ def image_library_hint(site)
68
+ return "".html_safe unless site&.images&.none?
69
+
70
+ content_tag(:p, t("cms.admin.sections.form.image_library_empty"), class: "cms-form__hint")
71
+ end
72
+
73
+ def image_library_actions
74
+ content_tag(:p, class: "cms-form__hint") do
75
+ safe_join(
76
+ [
77
+ link_to(t("cms.admin.sections.form.manage_images"), admin_images_path),
78
+ " ",
79
+ link_to(t("cms.admin.sections.form.upload_image"), new_admin_image_path)
80
+ ]
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ module SitesHelper
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module ApplicationHelper
5
+ def cms_attachment_path(attachment)
6
+ return unless attachment.attached?
7
+
8
+ main_app.rails_blob_path(attachment, only_path: true)
9
+ end
10
+
11
+ def cms_yes_no(value)
12
+ t(value ? "cms.shared.yes" : "cms.shared.no")
13
+ end
14
+
15
+ def cms_date(value)
16
+ l(value.to_date, format: :cms_date)
17
+ end
18
+
19
+ def cms_datetime(value)
20
+ l(value, format: :cms_datetime)
21
+ end
22
+
23
+ def cms_page_template_label(key)
24
+ t("cms.page_templates.#{key}")
25
+ end
26
+
27
+ def cms_page_status_label(key)
28
+ t("cms.page_statuses.#{key}")
29
+ end
30
+
31
+ def cms_form_field_kind_label(key)
32
+ t("cms.form_field_kinds.#{key}")
33
+ end
34
+
35
+ def cms_section_kind_label(key)
36
+ t("cms.section_kinds.#{key}")
37
+ end
38
+
39
+ def cms_webhook_event_label(key)
40
+ t("cms.webhook_events.#{key.tr('.', '_')}")
41
+ end
42
+
43
+ def cms_section_setting_label(name)
44
+ t("cms.section_settings.labels.#{name}")
45
+ end
46
+
47
+ def cms_section_setting_option_label(name, option)
48
+ t("cms.section_settings.options.#{name}.#{option}")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module MediaHelper
5
+ def cms_image_tag(image, rendition: nil, **)
6
+ return "" unless image&.file&.attached?
7
+
8
+ if rendition && Cms.config.image_renditions&.key?(rendition)
9
+ dimensions = Cms.config.image_renditions[rendition]
10
+ width, height = dimensions.split("x").map(&:to_i)
11
+ variant = image.file.variant(resize_to_limit: [width, height]).processed
12
+ image_tag(main_app.rails_representation_path(variant, only_path: true),
13
+ alt: image.alt_text.presence || image.display_title,
14
+ **)
15
+ else
16
+ image_tag(main_app.rails_blob_path(image.file, only_path: true),
17
+ alt: image.alt_text.presence || image.display_title,
18
+ **)
19
+ end
20
+ end
21
+
22
+ def cms_document_url(document)
23
+ return nil unless document&.file&.attached?
24
+
25
+ url_for(document.file)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module PagesHelper
5
+ def render_page_template(page)
6
+ render partial: cms_page_template_partial(page)
7
+ end
8
+
9
+ def cms_page_template_partial(page)
10
+ template = "cms/public/pages/templates/#{page.template_key}"
11
+ return template if lookup_context.exists?(template, [], true)
12
+
13
+ "cms/public/pages/templates/standard"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module SectionsHelper
5
+ def cms_sections(kind:, site: nil)
6
+ target_site = site || (defined?(@site) ? @site : nil)
7
+ return Cms::Section.none unless target_site
8
+
9
+ target_site.sections.enabled.by_kind(kind).includes(:translations,
10
+ section_images: { image: { file_attachment: :blob } }).ordered
11
+ end
12
+
13
+ # Renders the partial registered for the section's kind.
14
+ # In non-production, renders an error notice for unknown kinds.
15
+ # In production, raises so the error is surfaced properly.
16
+ def render_section(section)
17
+ partial = Cms::Section::KindRegistry.partial_for(section.kind)
18
+ render partial, section: section
19
+ rescue Cms::Section::KindRegistry::UnknownKindError => e
20
+ raise e if Rails.env.production?
21
+
22
+ content_tag(:div, e.message, class: "cms-section--unknown", style: "color:red;padding:1em;border:1px solid red;")
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module SitesHelper
5
+ def site_favicon_tag(site)
6
+ return unless site.logo.attached?
7
+
8
+ favicon_link_tag(cms_attachment_path(site.logo))
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Sortable from "sortablejs"
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ url: String,
7
+ handle: { type: String, default: ".cms-section-item__handle" }
8
+ }
9
+
10
+ connect() {
11
+ this.sortable = Sortable.create(this.element, {
12
+ handle: this.handleValue,
13
+ animation: 150,
14
+ onEnd: this.persistOrder.bind(this)
15
+ })
16
+ }
17
+
18
+ disconnect() {
19
+ this.sortable?.destroy()
20
+ }
21
+
22
+ persistOrder() {
23
+ const ids = Array.from(this.element.querySelectorAll("[data-page-section-id]"))
24
+ .map(el => el.dataset.pageSectionId)
25
+
26
+ const body = new URLSearchParams()
27
+ ids.forEach(id => body.append("page_section_ids[]", id))
28
+
29
+ fetch(this.urlValue, {
30
+ method: "PATCH",
31
+ headers: {
32
+ "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content,
33
+ "Content-Type": "application/x-www-form-urlencoded"
34
+ },
35
+ body: body.toString()
36
+ })
37
+ }
38
+ }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module Cms
7
+ class DeliverWebhookJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ def perform(webhook_id, event, payload)
11
+ webhook = Cms::Webhook.find_by(id: webhook_id)
12
+ return unless deliverable?(webhook, event)
13
+
14
+ response = build_http_client(webhook.url).request(build_request(webhook, event, payload))
15
+ record_delivery(webhook, event, response: response)
16
+ rescue StandardError => e
17
+ record_delivery(webhook, event, error: e) if webhook
18
+ end
19
+
20
+ private
21
+
22
+ def deliverable?(webhook, event)
23
+ webhook&.active? && webhook.events.include?(event)
24
+ end
25
+
26
+ def build_http_client(url)
27
+ uri = URI.parse(url)
28
+
29
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
30
+ http.use_ssl = uri.scheme == "https"
31
+ http.open_timeout = 5
32
+ http.read_timeout = 10
33
+ end
34
+ end
35
+
36
+ def build_request(webhook, event, payload)
37
+ body = request_body(payload, event)
38
+ uri = URI.parse(webhook.url)
39
+
40
+ Net::HTTP::Post.new(uri.request_uri).tap do |request|
41
+ request["Content-Type"] = "application/json"
42
+ request["X-CMS-Event"] = event
43
+ request["X-CMS-Signature"] = sign(body, webhook.secret) if webhook.secret.present?
44
+ request.body = body
45
+ end
46
+ end
47
+
48
+ def request_body(payload, event)
49
+ JSON.generate(payload.merge("event" => event, "timestamp" => Time.current.iso8601))
50
+ end
51
+
52
+ def record_delivery(webhook, event, response: nil, error: nil)
53
+ webhook.deliveries.create!(
54
+ event: event,
55
+ response_code: response&.code&.to_i,
56
+ response_body: response&.body.to_s.truncate(2000),
57
+ success: error.nil? && response&.code.to_i.between?(200, 299),
58
+ error_message: error&.message&.truncate(500)
59
+ )
60
+ end
61
+
62
+ def sign(body, secret)
63
+ "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: -> { Cms.config.mailer_from.presence || "noreply@example.com" }
6
+ layout "mailer"
7
+ helper Cms::ApplicationHelper
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ class FormSubmissionMailer < ApplicationMailer
5
+ def notify(submission, recipient_email)
6
+ @submission = submission
7
+ @page = submission.page
8
+ @fields = @page.form_fields.ordered
9
+
10
+ mail(
11
+ to: recipient_email,
12
+ subject: I18n.t("cms.mailers.form_submission.subject", page_title: @page.display_title)
13
+ )
14
+ end
15
+ end
16
+ end