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
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,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
|