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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e1f04a63886b81a25188c13e92f78957e2433dea300d98d0d35b06e56fcd214f
4
+ data.tar.gz: 79b2e0b306959f37b5bbd96fe50e1ab28c8d842067f8cd7c98dd15d1fed845cb
5
+ SHA512:
6
+ metadata.gz: 1a29897d35a8e3c9663b0b3630070255f470049e14e3908770b13c8e41db465eb7d74c469d4dec80c6db0fe94da951397edeed74d4df74ae2e26745a92cfbd4b
7
+ data.tar.gz: 7fa437f289d48be262d3188ee1d04cedd163e8856a3f19f2963997d7d5edf04e2ac6d8a098cf4796f850a7f0f708029bf3cba6947110fbd557967a5e0114ce94
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shift42
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,299 @@
1
+ # CMS
2
+
3
+ A mountable Rails CMS engine by [Shift42](https://shift42.io).
4
+
5
+ Rails-native, straightforward to extend, and easy for content editors to use.
6
+
7
+ ## Compatibility
8
+
9
+ - Ruby >= 3.1
10
+ - Rails >= 7.1 (tested up to 8.x)
11
+ - PostgreSQL
12
+
13
+ ## Features
14
+
15
+ | Feature | Status |
16
+ | -------------------------------------------------------------- | ------ |
17
+ | Multi-site support | ✅ |
18
+ | Content blocks / StreamField (`Cms::Section`) | ✅ |
19
+ | Multilingual (locale-scoped translations) | ✅ |
20
+ | Publishing workflow (draft / published / archived) | ✅ |
21
+ | Soft-delete for pages and sections (`discard` gem) | ✅ |
22
+ | Draft preview URLs (token-based, shareable without auth) | ✅ |
23
+ | Page tree (parent/child hierarchy) | ✅ |
24
+ | Media management (images + documents) | ✅ |
25
+ | Reusable sections across pages + standalone admin library | ✅ |
26
+ | Form fields + submission capture + email notification | ✅ |
27
+ | Full-text search (title + slug, ilike/unaccent) | ✅ |
28
+ | Headless JSON API v1 (API key auth) | ✅ |
29
+ | Webhooks (HMAC-signed, per status change, delivery log) | ✅ |
30
+ | Engine-owned English I18n defaults | ✅ |
31
+ | Host app extension points (`Cms.setup`) | ✅ |
32
+
33
+ ## Installation
34
+
35
+ Add to your host app's `Gemfile`:
36
+
37
+ ```ruby
38
+ gem "cms42"
39
+ ```
40
+
41
+ The published gem name is `cms42`, but the engine namespace remains `Cms::`.
42
+ If you load gems manually instead of letting Bundler auto-require them, use:
43
+
44
+ ```ruby
45
+ require "cms42"
46
+ ```
47
+
48
+ Install and migrate:
49
+
50
+ ```bash
51
+ bundle install
52
+ bin/rails active_storage:install # skip if already installed
53
+ bin/rails generate cms:install
54
+ bin/rails db:migrate
55
+ ```
56
+
57
+ Mount in `config/routes.rb`:
58
+
59
+ ```ruby
60
+ mount Cms::Engine, at: "/cms"
61
+ ```
62
+
63
+ Copy engine views into your host app (for Hyper/admin customisation, etc):
64
+
65
+ ```bash
66
+ # Copy all CMS views
67
+ bin/rails generate cms:views
68
+
69
+ # Copy only admin/public/mailer views
70
+ bin/rails generate cms:views admin
71
+ bin/rails generate cms:views public
72
+ bin/rails generate cms:views mailer
73
+
74
+ # Or select scopes explicitly
75
+ bin/rails generate cms:views -v admin public
76
+ ```
77
+
78
+ ## Configuration
79
+
80
+ Create `config/initializers/cms.rb`:
81
+
82
+ ```ruby
83
+ Cms.setup do |config|
84
+ # Public/API controllers inherit from this host controller
85
+ config.parent_controller = "ApplicationController"
86
+
87
+ # Required: admin inherits from a host app controller that already handles
88
+ # authentication/authorization for the CMS admin area
89
+ config.admin_parent_controller = "Admin::BaseController"
90
+
91
+ # Optional for public/API if you resolve sites via URL slug, subdomain,
92
+ # or X-CMS-SITE-SLUG. Required for admin once any Cms::Site exists.
93
+ config.current_site_resolver = ->(controller) { controller.current_organization&.cms_site }
94
+
95
+ # Optional: image renditions (used by cms_image_tag helper)
96
+ config.image_renditions = {
97
+ thumb: "300x200",
98
+ hero: "1200x630"
99
+ }
100
+
101
+ # Optional: notification email for form submissions
102
+ config.form_submission_email = ->(page) { "admin@example.com" }
103
+
104
+ # Optional: sender address for outgoing CMS mailers (defaults to "noreply@example.com")
105
+ config.mailer_from = "cms@myapp.com"
106
+
107
+ # Optional: gate admin access — return false/nil to respond with 403
108
+ config.authorize_admin = ->(controller) { controller.current_user&.admin? }
109
+
110
+ # Optional: auto-destroy sections that become orphaned after page removal (default: false)
111
+ config.auto_destroy_orphaned_sections = false
112
+
113
+ # Optional: replace the page resolver used by public/API requests
114
+ config.page_resolver_class = "Cms::PageResolver"
115
+
116
+ # Optional: replace API serializer classes
117
+ config.api_site_serializer_class = "Cms::Api::SiteSerializer"
118
+ config.api_page_serializer_class = "Cms::Api::PageSerializer"
119
+ end
120
+ ```
121
+
122
+ ## Configuration Surface
123
+
124
+ These are the supported host app extension points today:
125
+
126
+ | Config key | Signature | Purpose |
127
+ |---|---|---|
128
+ | `parent_controller` | String class name | Base public/API controller to inherit from |
129
+ | `admin_parent_controller` | String class name | Base admin controller to inherit from |
130
+ | `current_site_resolver` | `->(controller)` | Host-provided current site resolver; required for admin once sites exist |
131
+ | `authorize_admin` | `->(controller)` | Optional RBAC hook; return false/nil to respond 403 |
132
+ | `form_submission_email` | `->(page)` | Notification email recipient address |
133
+ | `mailer_from` | String | Sender address for CMS mailers (default: `"noreply@example.com"`) |
134
+ | `image_renditions` | Hash | Named variant dimensions |
135
+ | `page_templates` | Array of strings/symbols | Registers additional public page template keys |
136
+ | `page_resolver_class` | String class name / Class | Resolver used by public and API page lookup |
137
+ | `api_site_serializer_class` | String class name / Class | Serializer for `GET /api/v1/site` and `GET /api/v1/sites/:site_slug` |
138
+ | `api_page_serializer_class` | String class name / Class | Serializer for `GET /api/v1/pages/:slug` and site-scoped page endpoints |
139
+ | `admin_layout` | String layout name | Admin layout override |
140
+ | `public_layout` | String layout name | Public layout override |
141
+ | `auto_destroy_orphaned_sections` | Boolean | Auto-destroy sections that have no remaining page placements (default: `false`) |
142
+
143
+ Controller class replacement per namespace is not a supported config seam today. Public/API inherit from `parent_controller`, admin inherits from `admin_parent_controller`, and deeper controller replacement should still happen with normal Rails route/controller overrides in the host app.
144
+
145
+ ## Host App Extension
146
+
147
+ The engine is intentionally CMS-only. It manages content structure, publishing, media, reusable sections, forms, and CMS rendering.
148
+
149
+ If the host app needs business-specific public behavior such as ecommerce pages, customer login, account areas, or custom data models, implement that in the host app using standard Rails patterns:
150
+
151
+ - add host app routes before or alongside the mounted CMS engine
152
+ - use host app controllers and views for business-specific pages
153
+ - query host app models directly from the host app
154
+ - override engine views or controllers only when CMS behavior itself needs to change
155
+ - reuse CMS sections or rendered content inside host app pages when helpful
156
+
157
+ The CMS engine should not know about host concepts such as products, carts, customers, orders, or accounts.
158
+
159
+ ## Locales
160
+
161
+ Engine-owned UI strings live in `config/locales/en.yml` under `cms.*`.
162
+
163
+ - Host apps can override or extend them with normal Rails locale files such as `config/locales/el.yml`.
164
+ - Public and API locale resolution is explicit: `Cms::PageResolver` returns the chosen locale, and controllers apply it with `I18n.with_locale`.
165
+ - Public and API page lookup uses `config.page_resolver_class`, which defaults to `Cms::PageResolver`.
166
+ - Public form errors and notices are translated through I18n, so host apps can localize validation and flash output without monkey-patching engine code.
167
+
168
+ ## Content Blocks (StreamField)
169
+
170
+ Register custom block types in your host app:
171
+
172
+ ```ruby
173
+ Cms::Section::KindRegistry.register(
174
+ "my_custom_block",
175
+ partial: "cms/sections/my_custom_block",
176
+ block_class: MyApp::MyCustomBlock
177
+ )
178
+ ```
179
+
180
+ Block classes inherit from `Cms::Section::BlockBase` and declare typed settings:
181
+
182
+ ```ruby
183
+ class MyApp::MyCustomBlock < Cms::Section::BlockBase
184
+ settings_schema do
185
+ field :background_color, type: :string, default: "#ffffff"
186
+ field :columns, type: :integer, default: 3
187
+ end
188
+ end
189
+ ```
190
+
191
+ ## Reusable Sections
192
+
193
+ Sections are site-scoped reusable content blocks that can be attached to multiple pages.
194
+ They can be managed centrally in the admin section library and then attached where needed.
195
+
196
+ Built-in image sections reference `Cms::Image` records from the CMS media library via `settings["image_id"]` instead of storing raw URLs.
197
+
198
+ Use the `cms_sections` helper in views when you need to list reusable sections by kind:
199
+
200
+ ```erb
201
+ <% cms_sections(kind: "cta").each do |section| %>
202
+ <%= render_section(section) %>
203
+ <% end %>
204
+ ```
205
+
206
+ ## Page Templates
207
+
208
+ `Cms::Page#template_key` drives public page rendering as a presentation concern.
209
+
210
+ Public pages render through a shared shell at `cms/public/pages/show`, which then resolves a template partial at:
211
+
212
+ - `cms/public/pages/templates/_<template_key>.html.erb`
213
+ - fallback: `cms/public/pages/templates/_standard.html.erb`
214
+
215
+ The engine ships `standard`, `landing`, `form`, and `custom` template partials. Host apps can override any of them with normal Rails view overrides by placing files at the same paths in `app/views`.
216
+
217
+ This keeps page templates simple and maintainable:
218
+
219
+ - routes stay stable
220
+ - controllers stay shared
221
+ - `template_key` stays presentation-only
222
+ - host apps can customize page types without replacing the full public rendering flow
223
+
224
+ ## Headless API
225
+
226
+ The JSON API is available at `/api/v1/` and requires a Bearer token.
227
+ Create API keys in the admin UI (`/admin/api_keys`).
228
+
229
+ ```bash
230
+ curl -H "Authorization: Bearer YOUR_TOKEN" \
231
+ https://yourapp.com/cms/api/v1/pages/about
232
+ ```
233
+
234
+ Endpoints:
235
+
236
+ - `GET /api/v1/site` — site info + published pages
237
+ - `GET /api/v1/pages/:slug` — single page resource with sections
238
+ - `GET /api/v1/pages/:slug?include_site=true` — page resource plus lightweight site metadata
239
+ - `GET /api/v1/sites/:site_slug` — (multi-site) site info
240
+ - `GET /api/v1/sites/:site_slug/pages/:slug` — (multi-site) page
241
+
242
+ ## Webhooks
243
+
244
+ Configure webhooks in the admin UI. Each webhook receives a HMAC-signed POST:
245
+
246
+ ```
247
+ POST https://yourapp.com/webhook-receiver
248
+ X-CMS-Event: page.published
249
+ X-CMS-Signature: sha256=<hex>
250
+ Content-Type: application/json
251
+ ```
252
+
253
+ Supported events: `page.published`, `page.unpublished`.
254
+
255
+ Verify the signature in your receiver:
256
+
257
+ ```ruby
258
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', YOUR_SECRET, request.body.read)}"
259
+ ActiveSupport::SecurityUtils.secure_compare(expected, request.headers['X-CMS-Signature'])
260
+ ```
261
+
262
+ ## Routes
263
+
264
+ | Path | Description |
265
+ |---|---|
266
+ | `/admin/` | Admin UI |
267
+ | `/api/v1/` | Headless JSON API (requires API key) |
268
+ | `/sites/:site_slug/` | Public SSR (multi-site) |
269
+ | `/` and `/*slug` | Public SSR (single-site) |
270
+
271
+ ## Notes
272
+
273
+ - Admin does not guess a site. If no `Cms::Site` records exist yet it redirects to `new_admin_site_path`; otherwise it expects `config.current_site_resolver` to return a `Cms::Site`.
274
+ - Public/API site resolution supports `config.current_site_resolver`, `params[:site_slug]`, `X-CMS-SITE-SLUG`, or first subdomain.
275
+ - API serialization is handled by `config.api_site_serializer_class` and `config.api_page_serializer_class`, which default to `Cms::Api::SiteSerializer` and `Cms::Api::PageSerializer`.
276
+ - Navigation rendering uses `header_nav` and `footer_nav` page scopes.
277
+ - `template_key` selects `cms/public/pages/templates/<template_key>` with fallback to `standard`, so host apps can override page presentation per template key.
278
+ - Page hierarchy is a simple parent/child adjacency list and is intentionally optimized for modest marketing-style site trees, not large catalog trees.
279
+ - Revisions and snippets are not part of the current engine architecture. Reusable content is centered on `Cms::Section` and `Cms::PageSection`.
280
+
281
+ ## Development
282
+
283
+ ```bash
284
+ # Run tests (uses spec/cms_app dummy app)
285
+ bundle exec rspec
286
+
287
+ # Run linter
288
+ bin/rubocop
289
+
290
+ # Console in dummy app
291
+ cd spec/cms_app && bin/rails console
292
+
293
+ # Run dummy app server
294
+ cd spec/cms_app && bin/rails server
295
+ ```
296
+
297
+ ## License
298
+
299
+ MIT. See [LICENSE](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("spec/cms_app/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+ require "bundler/gem_tasks"
8
+ require "rspec/core/rake_task"
9
+
10
+ RSpec::Core::RakeTask.new(:spec)
@@ -0,0 +1,3 @@
1
+ /*
2
+ *= require_self
3
+ */
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ class ApiKeysController < BaseController
6
+ before_action :set_api_key, only: %i[edit update destroy]
7
+
8
+ def index
9
+ @api_keys = current_site.api_keys.order(created_at: :desc)
10
+ end
11
+
12
+ def new
13
+ @api_key = current_site.api_keys.build
14
+ end
15
+
16
+ def create
17
+ @api_key = current_site.api_keys.build(api_key_params)
18
+ if @api_key.save
19
+ @new_token = @api_key.token
20
+ render :create
21
+ else
22
+ render :new, status: :unprocessable_content
23
+ end
24
+ end
25
+
26
+ def edit; end
27
+
28
+ def update
29
+ if @api_key.update(api_key_params.except(:token))
30
+ redirect_to admin_api_keys_path, notice: t("cms.notices.api_key_updated")
31
+ else
32
+ render :edit, status: :unprocessable_content
33
+ end
34
+ end
35
+
36
+ def destroy
37
+ @api_key.destroy
38
+ redirect_to admin_api_keys_path, notice: t("cms.notices.api_key_deleted")
39
+ end
40
+
41
+ private
42
+
43
+ def set_api_key
44
+ @api_key = current_site.api_keys.find(params[:id])
45
+ end
46
+
47
+ def api_key_params
48
+ params.require(:api_key).permit(:name, :active)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ class BaseController < (Cms.config.admin_parent_controller.presence || Cms.parent_controller).constantize
6
+ include Cms::CurrentSiteResolver
7
+
8
+ layout -> { Cms.config.admin_layout }
9
+ helper Cms::ApplicationHelper
10
+
11
+ rescue_from ActiveRecord::RecordNotFound, with: :render_admin_not_found
12
+
13
+ before_action :authorize_admin_action!
14
+ before_action :ensure_site_access!
15
+
16
+ private
17
+
18
+ def current_site
19
+ @current_site ||= configured_current_site || raise_missing_current_site!
20
+ end
21
+
22
+ def ensure_site_access!
23
+ return if bootstrap_site_request? && Cms::Site.none?
24
+ return if configured_current_site.present?
25
+
26
+ if Cms::Site.none?
27
+ redirect_to new_admin_site_path
28
+ return
29
+ end
30
+
31
+ raise_missing_current_site!
32
+ end
33
+
34
+ def authorize_admin_action!
35
+ return unless Cms.config.authorize_admin.present?
36
+ return if Cms.config.authorize_admin.call(self)
37
+
38
+ head :forbidden
39
+ end
40
+
41
+ def bootstrap_site_request?
42
+ controller_name == "sites" && action_name.in?(%w[new create])
43
+ end
44
+
45
+ def render_admin_not_found
46
+ redirect_to admin_pages_path, alert: t("cms.errors.record_not_found")
47
+ end
48
+
49
+ def raise_missing_current_site!
50
+ raise t("cms.errors.current_site_required")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ class DocumentsController < BaseController
6
+ before_action :set_document, only: %i[edit update destroy]
7
+
8
+ def index
9
+ @documents = current_site.documents.ordered
10
+ end
11
+
12
+ def new
13
+ @document = current_site.documents.build
14
+ end
15
+
16
+ def edit; end
17
+
18
+ def create
19
+ @document = current_site.documents.build(document_params)
20
+
21
+ if @document.save
22
+ redirect_to admin_documents_path, notice: t("cms.notices.document_uploaded")
23
+ else
24
+ render :new, status: :unprocessable_content
25
+ end
26
+ end
27
+
28
+ def update
29
+ @document.assign_attributes(document_params)
30
+
31
+ if @document.save
32
+ redirect_to admin_documents_path, notice: t("cms.notices.document_updated")
33
+ else
34
+ render :edit, status: :unprocessable_content
35
+ end
36
+ end
37
+
38
+ def destroy
39
+ @document.file.purge_later
40
+ @document.destroy
41
+ redirect_to admin_documents_path, notice: t("cms.notices.document_deleted")
42
+ end
43
+
44
+ private
45
+
46
+ def set_document
47
+ @document = current_site.documents.find(params[:id])
48
+ end
49
+
50
+ def document_params
51
+ params.fetch(:cms_document, params.fetch(:document, ActionController::Parameters.new))
52
+ .permit(:title, :description, :file)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ class FormFieldsController < BaseController
6
+ before_action :set_page
7
+ before_action :set_field, only: %i[edit update destroy]
8
+
9
+ def index
10
+ @fields = @page.form_fields.ordered
11
+ end
12
+
13
+ def new
14
+ @field = @page.form_fields.build(position: @page.form_fields.count)
15
+ end
16
+
17
+ def edit; end
18
+
19
+ def create
20
+ @field = @page.form_fields.build(field_params)
21
+
22
+ if @field.save
23
+ redirect_to admin_page_form_fields_path(@page), notice: t("cms.notices.field_added")
24
+ else
25
+ render :new, status: :unprocessable_content
26
+ end
27
+ end
28
+
29
+ def update
30
+ @field.assign_attributes(field_params)
31
+
32
+ if @field.save
33
+ redirect_to admin_page_form_fields_path(@page), notice: t("cms.notices.field_updated")
34
+ else
35
+ render :edit, status: :unprocessable_content
36
+ end
37
+ end
38
+
39
+ def destroy
40
+ @field.destroy
41
+ redirect_to admin_page_form_fields_path(@page), notice: t("cms.notices.field_removed")
42
+ end
43
+
44
+ def sort
45
+ Array(params[:field_ids]).each_with_index do |id, position|
46
+ @page.form_fields.find(id).update!(position: position)
47
+ end
48
+ head :ok
49
+ end
50
+
51
+ private
52
+
53
+ def set_page
54
+ @page = current_site.pages.find(params[:page_id])
55
+ end
56
+
57
+ def set_field
58
+ @field = @page.form_fields.find(params[:id])
59
+ end
60
+
61
+ def field_params
62
+ params.fetch(:cms_form_field, params.fetch(:form_field, ActionController::Parameters.new))
63
+ .permit(
64
+ :kind, :label, :field_name, :placeholder, :hint, :required, :position,
65
+ options: []
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ class FormSubmissionsController < BaseController
6
+ before_action :set_page
7
+
8
+ def index
9
+ @submissions = @page.form_submissions.recent
10
+ @fields = @page.form_fields.ordered
11
+
12
+ respond_to do |format|
13
+ format.html
14
+ format.csv do
15
+ csv = Cms::FormSubmission.to_csv(@submissions, @fields)
16
+ send_data csv,
17
+ filename: "#{@page.slug}-submissions-#{Date.today}.csv",
18
+ type: "text/csv"
19
+ end
20
+ end
21
+ end
22
+
23
+ def destroy
24
+ @submission = @page.form_submissions.find(params[:id])
25
+ @submission.destroy
26
+ redirect_to admin_page_form_submissions_path(@page), notice: t("cms.notices.submission_deleted")
27
+ end
28
+
29
+ private
30
+
31
+ def set_page
32
+ @page = current_site.pages.find(params[:page_id])
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cms
4
+ module Admin
5
+ class ImagesController < BaseController
6
+ before_action :set_image, only: %i[edit update destroy]
7
+
8
+ def index
9
+ @images = current_site.images.includes(:localised).ordered
10
+ end
11
+
12
+ def new
13
+ @image = current_site.images.build
14
+ @translation_locale = requested_locale
15
+ @image.image_translations.build(locale: @translation_locale)
16
+ end
17
+
18
+ def edit
19
+ @translation_locale = requested_locale
20
+ @image.image_translations.find_or_initialize_by(locale: @translation_locale)
21
+ end
22
+
23
+ def create
24
+ @image = current_site.images.build(image_params)
25
+ @translation_locale = requested_locale
26
+
27
+ if @image.save
28
+ redirect_to admin_images_path, notice: t("cms.notices.image_uploaded")
29
+ else
30
+ render :new, status: :unprocessable_content
31
+ end
32
+ end
33
+
34
+ def update
35
+ @translation_locale = requested_locale
36
+ @image.assign_attributes(image_params)
37
+
38
+ if @image.save
39
+ redirect_to admin_images_path, notice: t("cms.notices.image_updated")
40
+ else
41
+ render :edit, status: :unprocessable_content
42
+ end
43
+ end
44
+
45
+ def destroy
46
+ @image.file.purge_later
47
+ @image.destroy
48
+ redirect_to admin_images_path, notice: t("cms.notices.image_deleted")
49
+ end
50
+
51
+ private
52
+
53
+ def set_image
54
+ @image = current_site.images.includes(:image_translations, :localised).find(params[:id])
55
+ end
56
+
57
+ def image_params
58
+ params.fetch(:cms_image, params.fetch(:image, ActionController::Parameters.new))
59
+ .permit(:title, :file, image_translations_attributes: %i[id locale alt_text caption])
60
+ end
61
+
62
+ def requested_locale
63
+ params[:locale].presence || current_site.default_locale.presence || I18n.locale.to_s
64
+ end
65
+ end
66
+ end
67
+ end