diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2bd93d9..2dd1de3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,3 +34,49 @@ jobs:
- name: Lint code for consistent style
run: bin/rubocop -f github
+ test:
+ name: RSpec (Ruby ${{ matrix.ruby }})
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby: ["3.3", "4.0.1"]
+
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: cms_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ env:
+ RAILS_ENV: test
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Ruby ${{ matrix.ruby }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby }}
+ bundler-cache: true
+
+ - name: Set up test database
+ working-directory: spec/cms_app
+ run: |
+ bundle exec rails db:drop db:create
+ bundle exec rails db:schema:load
+
+ - name: Run RSpec
+ run: bundle exec rspec
diff --git a/.gitignore b/.gitignore
index 4d5b25d..97151d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,8 @@
/log/*.log
/pkg/
/tmp/
+/spec/cms_app/log/*.log
+/spec/cms_app/storage/
+/spec/cms_app/tmp/
+/rdoc/
+/db/
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..c99d2e7
--- /dev/null
+++ b/.rspec
@@ -0,0 +1 @@
+--require spec_helper
diff --git a/.rubocop.yml b/.rubocop.yml
index f9d86d4..50bcb02 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,8 +1,54 @@
-# Omakase Ruby styling for Rails
-inherit_gem: { rubocop-rails-omakase: rubocop.yml }
-
-# Overwrite or add rules to create your own house style
-#
-# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
-# Layout/SpaceInsideArrayLiteralBrackets:
-# Enabled: false
+AllCops:
+ NewCops: enable
+ SuggestExtensions: false
+ TargetRubyVersion: 3.2
+ Exclude:
+ - "bin/**/*"
+ - "vendor/**/*"
+ - "**/Rakefile"
+ - "spec/cms_app/db/schema.rb"
+ - "spec/cms_app/db/migrate/**/*.rb"
+
+Style/NumericLiterals:
+ Enabled: false
+
+Style/StringLiterals:
+ EnforcedStyle: double_quotes
+
+# Rails engines / controllers don't need YARD-style class docs
+Style/Documentation:
+ Enabled: false
+
+# Migrations, specs, serializers, HTTP jobs and helpers are legitimately verbose
+Metrics/MethodLength:
+ Max: 30
+ Exclude:
+ - "spec/cms_app/db/migrate/**/*.rb"
+ - "lib/generators/**/*.rb"
+
+Metrics/AbcSize:
+ Max: 35
+ Exclude:
+ - "spec/cms_app/db/migrate/**/*.rb"
+ - "lib/generators/**/*.rb"
+
+Metrics/ClassLength:
+ Max: 160
+ Exclude:
+ - "lib/generators/**/*.rb"
+
+Metrics/CyclomaticComplexity:
+ Max: 12
+ Exclude:
+ - "lib/generators/**/*.rb"
+
+Metrics/PerceivedComplexity:
+ Max: 12
+ Exclude:
+ - "lib/generators/**/*.rb"
+
+Metrics/BlockLength:
+ Exclude:
+ - "spec/**/*.rb"
+ - "spec/cms_app/db/migrate/**/*.rb"
+ - "config/routes.rb"
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..c44df30
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,108 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Changed
+
+- Public rendering and API locale resolution now return explicit locale data from `Cms::PageResolver`, and controllers apply it with `I18n.with_locale` instead of mutating `I18n.locale` inside services.
+- User-facing engine strings now live in `config/locales/en.yml`, allowing host apps to override notices, validation copy, and admin UI text through standard Rails locale files.
+- Admin site resolution is now explicit: if no `Cms::Site` records exist, admin redirects to the singular bootstrap flow; if sites exist and no site resolves, the engine raises a configuration error instead of guessing.
+- Documentation and install guidance now describe the current engine seams: `Cms::Admin`, `Cms::Public`, `Cms::Api`, locale-aware pages/sections, and `Cms.setup` configuration focused on CMS concerns only.
+
+### Removed
+
+- Revision history and rollback are no longer part of the engine architecture.
+- Snippets are no longer part of the engine architecture; reusable content is centered on `Cms::Section` and `Cms::PageSection`.
+- Old adapter-style integration guidance for host business domains has been dropped in favor of Rails-native host app routes, controllers, views, and queries.
+
+## [0.1.0] - 2026-03-06
+
+### Added
+
+**Phase 1 — Core Foundation**
+- `Cms::Site` model with multi-site support, logo/favicon via ActiveStorage, `default_locale`
+- `Cms::Page` model with `page_type` enum (content/landing/product_list/product_show/enquiry), `status` enum (draft/published/archived), nav placement flags
+- `Cms::PageTranslation` model with locale-specific title, excerpt, body_rich (ActionText), SEO fields
+- Admin CRUD for sites and pages
+- Public SSR rendering with `Cms::SiteResolvable` and `Cms::PageResolver`
+- JSON API (`/api/`) for headless usage
+- Install generator with migration template
+
+**Phase 2 — Content Blocks (StreamField)**
+- `Cms::Section` model with `kind` (block type), `position`, `enabled`, `settings` (JSONB)
+- `Cms::SectionTranslation` with locale-specific title and body_rich
+- `Cms::Section::BlockBase` — typed settings DSL
+- `Cms::Section::KindRegistry` — maps kind string to block class + partial
+- Built-in blocks: `RichTextBlock`, `ImageBlock`, `HeroBlock`, `CallToActionBlock`
+- Admin section CRUD with Turbo Frames + Stimulus drag-sort
+- Hotwire: turbo-rails, stimulus-rails, importmap-rails
+
+**Phase 3 — Publishing Workflow**
+- `publish_at` scheduled publishing on pages
+- `Cms::Revision` — polymorphic page snapshots (JSONB) with restore
+- `Cms::PublishScheduledPagesJob` — auto-publishes due pages
+- Admin revisions index with one-click restore
+- Page preview action (renders public view without layout)
+
+**Phase 4 — Media Management**
+- `Cms::Image` model — site-scoped, `has_one_attached :file`, variant helper
+- `Cms::Document` model — site-scoped, `has_one_attached :file`
+- Admin CRUD for images and documents
+- `cms_image_tag` and `cms_document_url` view helpers
+- Configurable image renditions via `Cms.config.image_renditions`
+
+**Phase 5 — Snippets**
+- `Cms::Snippet` model — site-scoped reusable content blocks with kind registry
+- `Cms::SnippetTranslation` with locale-specific title and body_rich
+- Built-in snippet kinds: `navigation_item`, `banner`, `testimonial`
+- `Cms::Snippet::BlockBase` and `Cms::Snippet::KindRegistry` (same pattern as sections)
+- Admin CRUD for snippets
+- `cms_snippets(kind:)` view helper
+
+**Phase 6 — Page Tree**
+- Self-referential `parent_id` on `cms_pages` (no gem required)
+- `ancestors`, `depth`, `descendants` methods on `Cms::Page`
+- `scope :root` — pages without a parent
+- Admin parent select (excludes self and descendants)
+- Indented page tree in admin index
+
+**Phase 7 — Forms**
+- `Cms::FormField` model — typed fields (text/email/textarea/select/checkbox/file), positioned, per page
+- `Cms::FormSubmission` model — JSONB data capture, CSV export
+- Admin form field builder with drag-sort
+- Admin form submissions index with CSV download
+- Public form submission endpoint
+- `Cms::FormSubmissionMailer` notification emails
+
+**Phase 8 — Search**
+- `scope :search` on `Cms::Page` — ilike/unaccent on title and slug
+- Admin pages search bar
+
+**Phase 10 — Host Adapter API**
+- Full `Cms::Configuration` class with adapter lambdas: `current_actor`, `can_manage_cms`, `current_tenant`, `products_scope_for`, `find_product`, `build_enquiry`, `submit_enquiry`, `form_submission_email`, `image_renditions`, `locale_in_path`
+- `check_cms_access` before_action in admin — no-op if adapter not configured
+
+**Phase 11 — Internationalisation**
+- `Cms::LocaleResolver` service — extracted fallback chain logic
+- Translation completeness badges in admin page show view
+
+**Phase 12 — Headless API**
+- `Cms::ApiKey` model — token generation (SecureRandom), `active` scope, `touch_last_used!`
+- `Cms::Webhook` model — URL validation, JSONB events array
+- `Cms::DeliverWebhookJob` — HMAC-signed POST delivery (best-effort)
+- `fire_webhooks_on_status_change` after_commit on page status changes
+- Versioned API namespace: `/api/v1/` with Bearer token authentication
+- Admin CRUD for API keys (token shown once on create) and webhooks
+
+**Phase 14 — OSS Packaging**
+- GitHub Actions CI: RuboCop + RSpec across Ruby 3.1/3.2/3.3
+- Full README with quickstart, adapter API docs, block/snippet extension guide
+- This CHANGELOG
+
+[Unreleased]: https://github.com/shift42/cms/compare/v0.1.0...HEAD
+[0.1.0]: https://github.com/shift42/cms/releases/tag/v0.1.0
diff --git a/Gemfile b/Gemfile
index 608c9e2..4c000be 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,14 +1,20 @@
+# frozen_string_literal: true
+
source "https://rubygems.org"
-# Specify your gem's dependencies in cms.gemspec.
gemspec
-gem "puma"
-
-gem "sqlite3"
+group :development, :test do
+ gem "rspec-rails", "~> 7.1"
+ gem "rubocop", require: false
+end
-# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
-gem "rubocop-rails-omakase", require: false
+group :test do
+ gem "factory_bot_rails"
+end
-# Start debugger with binding.b [https://github.com/ruby/debug]
-# gem "debug", ">= 1.0.0"
+gem "importmap-rails"
+gem "pg"
+gem "propshaft"
+gem "stimulus-rails"
+gem "turbo-rails"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..1050703
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,434 @@
+PATH
+ remote: .
+ specs:
+ cms (0.1.0)
+ actiontext (>= 7.1, < 9.0)
+ discard (~> 1.4)
+ importmap-rails (>= 1.2)
+ rails (>= 7.1, < 9.0)
+ stimulus-rails (>= 1.3)
+ turbo-rails (>= 1.5)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ action_text-trix (2.1.16)
+ railties
+ actioncable (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
+ nio4r (~> 2.0)
+ websocket-driver (>= 0.6.1)
+ zeitwerk (~> 2.6)
+ actionmailbox (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
+ mail (>= 2.8.0)
+ actionmailer (8.1.2)
+ actionpack (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activesupport (= 8.1.2)
+ mail (>= 2.8.0)
+ rails-dom-testing (~> 2.2)
+ actionpack (8.1.2)
+ actionview (= 8.1.2)
+ activesupport (= 8.1.2)
+ nokogiri (>= 1.8.5)
+ rack (>= 2.2.4)
+ rack-session (>= 1.0.1)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ useragent (~> 0.16)
+ actiontext (8.1.2)
+ action_text-trix (~> 2.1.15)
+ actionpack (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
+ globalid (>= 0.6.0)
+ nokogiri (>= 1.8.5)
+ actionview (8.1.2)
+ activesupport (= 8.1.2)
+ builder (~> 3.1)
+ erubi (~> 1.11)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ activejob (8.1.2)
+ activesupport (= 8.1.2)
+ globalid (>= 0.3.6)
+ activemodel (8.1.2)
+ activesupport (= 8.1.2)
+ activerecord (8.1.2)
+ activemodel (= 8.1.2)
+ activesupport (= 8.1.2)
+ timeout (>= 0.4.0)
+ activestorage (8.1.2)
+ actionpack (= 8.1.2)
+ activejob (= 8.1.2)
+ activerecord (= 8.1.2)
+ activesupport (= 8.1.2)
+ marcel (~> 1.0)
+ activesupport (8.1.2)
+ base64
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.3.1)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ json
+ logger (>= 1.4.2)
+ minitest (>= 5.1)
+ securerandom (>= 0.3)
+ tzinfo (~> 2.0, >= 2.0.5)
+ uri (>= 0.13.1)
+ addressable (2.8.9)
+ public_suffix (>= 2.0.2, < 8.0)
+ ast (2.4.3)
+ base64 (0.3.0)
+ bigdecimal (4.0.1)
+ builder (3.3.0)
+ concurrent-ruby (1.3.6)
+ connection_pool (3.0.2)
+ crass (1.0.6)
+ date (3.5.1)
+ diff-lcs (1.6.2)
+ discard (1.4.0)
+ activerecord (>= 4.2, < 9.0)
+ drb (2.2.3)
+ erb (6.0.2)
+ erubi (1.13.1)
+ factory_bot (6.5.6)
+ activesupport (>= 6.1.0)
+ factory_bot_rails (6.5.1)
+ factory_bot (~> 6.5)
+ railties (>= 6.1.0)
+ globalid (1.3.0)
+ activesupport (>= 6.1)
+ i18n (1.14.8)
+ concurrent-ruby (~> 1.0)
+ importmap-rails (2.2.3)
+ actionpack (>= 6.0.0)
+ activesupport (>= 6.0.0)
+ railties (>= 6.0.0)
+ io-console (0.8.2)
+ irb (1.17.0)
+ pp (>= 0.6.0)
+ prism (>= 1.3.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ json (2.18.1)
+ json-schema (6.2.0)
+ addressable (~> 2.8)
+ bigdecimal (>= 3.1, < 5)
+ language_server-protocol (3.17.0.5)
+ lint_roller (1.1.0)
+ logger (1.7.0)
+ loofah (2.25.0)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ mail (2.9.0)
+ logger
+ mini_mime (>= 0.1.1)
+ net-imap
+ net-pop
+ net-smtp
+ marcel (1.1.0)
+ mcp (0.8.0)
+ json-schema (>= 4.1)
+ mini_mime (1.1.5)
+ minitest (6.0.2)
+ drb (~> 2.0)
+ prism (~> 1.5)
+ net-imap (0.6.3)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-smtp (0.5.1)
+ net-protocol
+ nio4r (2.7.5)
+ nokogiri (1.19.1-aarch64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.1-aarch64-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.1-arm-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.1-arm-linux-musl)
+ racc (~> 1.4)
+ nokogiri (1.19.1-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.1-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.19.1-x86_64-linux-gnu)
+ racc (~> 1.4)
+ nokogiri (1.19.1-x86_64-linux-musl)
+ racc (~> 1.4)
+ parallel (1.27.0)
+ parser (3.3.10.2)
+ ast (~> 2.4.1)
+ racc
+ pg (1.6.3)
+ pg (1.6.3-aarch64-linux)
+ pg (1.6.3-aarch64-linux-musl)
+ pg (1.6.3-arm64-darwin)
+ pg (1.6.3-x86_64-darwin)
+ pg (1.6.3-x86_64-linux)
+ pg (1.6.3-x86_64-linux-musl)
+ pp (0.6.3)
+ prettyprint
+ prettyprint (0.2.0)
+ prism (1.9.0)
+ propshaft (1.3.1)
+ actionpack (>= 7.0.0)
+ activesupport (>= 7.0.0)
+ rack
+ psych (5.3.1)
+ date
+ stringio
+ public_suffix (7.0.5)
+ racc (1.8.1)
+ rack (3.2.5)
+ rack-session (2.1.1)
+ base64 (>= 0.1.0)
+ rack (>= 3.0.0)
+ rack-test (2.2.0)
+ rack (>= 1.3)
+ rackup (2.3.1)
+ rack (>= 3)
+ rails (8.1.2)
+ actioncable (= 8.1.2)
+ actionmailbox (= 8.1.2)
+ actionmailer (= 8.1.2)
+ actionpack (= 8.1.2)
+ actiontext (= 8.1.2)
+ actionview (= 8.1.2)
+ activejob (= 8.1.2)
+ activemodel (= 8.1.2)
+ activerecord (= 8.1.2)
+ activestorage (= 8.1.2)
+ activesupport (= 8.1.2)
+ bundler (>= 1.15.0)
+ railties (= 8.1.2)
+ rails-dom-testing (2.3.0)
+ activesupport (>= 5.0.0)
+ minitest
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.7.0)
+ loofah (~> 2.25)
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ railties (8.1.2)
+ actionpack (= 8.1.2)
+ activesupport (= 8.1.2)
+ irb (~> 1.13)
+ rackup (>= 1.0.0)
+ rake (>= 12.2)
+ thor (~> 1.0, >= 1.2.2)
+ tsort (>= 0.2)
+ zeitwerk (~> 2.6)
+ rainbow (3.1.1)
+ rake (13.3.1)
+ rdoc (7.2.0)
+ erb
+ psych (>= 4.0.0)
+ tsort
+ regexp_parser (2.11.3)
+ reline (0.6.3)
+ io-console (~> 0.5)
+ rspec-core (3.13.6)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.7)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-rails (7.1.1)
+ actionpack (>= 7.0)
+ activesupport (>= 7.0)
+ railties (>= 7.0)
+ rspec-core (~> 3.13)
+ rspec-expectations (~> 3.13)
+ rspec-mocks (~> 3.13)
+ rspec-support (~> 3.13)
+ rspec-support (3.13.7)
+ rubocop (1.85.1)
+ json (~> 2.3)
+ language_server-protocol (~> 3.17.0.2)
+ lint_roller (~> 1.1.0)
+ mcp (~> 0.6)
+ parallel (~> 1.10)
+ parser (>= 3.3.0.2)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 2.9.3, < 3.0)
+ rubocop-ast (>= 1.49.0, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 4.0)
+ rubocop-ast (1.49.0)
+ parser (>= 3.3.7.2)
+ prism (~> 1.7)
+ ruby-progressbar (1.13.0)
+ securerandom (0.4.1)
+ stimulus-rails (1.3.4)
+ railties (>= 6.0.0)
+ stringio (3.2.0)
+ thor (1.5.0)
+ timeout (0.6.0)
+ tsort (0.2.0)
+ turbo-rails (2.0.23)
+ actionpack (>= 7.1.0)
+ railties (>= 7.1.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
+ uri (1.1.1)
+ useragent (0.16.11)
+ websocket-driver (0.8.0)
+ base64
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ zeitwerk (2.7.5)
+
+PLATFORMS
+ aarch64-linux-gnu
+ aarch64-linux-musl
+ arm-linux-gnu
+ arm-linux-musl
+ arm64-darwin
+ x86_64-darwin
+ x86_64-linux-gnu
+ x86_64-linux-musl
+
+DEPENDENCIES
+ cms!
+ factory_bot_rails
+ importmap-rails
+ pg
+ propshaft
+ rspec-rails (~> 7.1)
+ rubocop
+ stimulus-rails
+ turbo-rails
+
+CHECKSUMS
+ action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a
+ actioncable (8.1.2) sha256=dc31efc34cca9cdefc5c691ddb8b4b214c0ea5cd1372108cbc1377767fb91969
+ actionmailbox (8.1.2) sha256=058b2fb1980e5d5a894f675475fcfa45c62631103d5a2596d9610ec81581889b
+ actionmailer (8.1.2) sha256=f4c1d2060f653bfe908aa7fdc5a61c0e5279670de992146582f2e36f8b9175e9
+ actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423
+ actiontext (8.1.2) sha256=0bf57da22a9c19d970779c3ce24a56be31b51c7640f2763ec64aa72e358d2d2d
+ actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b
+ activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825
+ activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e
+ activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44
+ activestorage (8.1.2) sha256=8a63a48c3999caeee26a59441f813f94681fc35cc41aba7ce1f836add04fba76
+ activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae
+ addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485
+ ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
+ builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
+ cms (0.1.0)
+ concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
+ crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
+ date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
+ diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
+ discard (1.4.0) sha256=6efcd2a53ddf96781f81b825d398f1c88ab88c0faa84e131bea6e16ef95d65d0
+ drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
+ erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
+ erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
+ factory_bot (6.5.6) sha256=12beb373214dccc086a7a63763d6718c49769d5606f0501e0a4442676917e077
+ factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68
+ globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
+ i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
+ importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
+ io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
+ irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
+ json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
+ json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666
+ language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
+ lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6
+ mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
+ marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
+ mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb
+ mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
+ minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d
+ net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad
+ net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
+ net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
+ net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
+ nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
+ nokogiri (1.19.1-aarch64-linux-gnu) sha256=cfdb0eafd9a554a88f12ebcc688d2b9005f9fce42b00b970e3dc199587b27f32
+ nokogiri (1.19.1-aarch64-linux-musl) sha256=1e2150ab43c3b373aba76cd1190af7b9e92103564063e48c474f7600923620b5
+ nokogiri (1.19.1-arm-linux-gnu) sha256=0a39ed59abe3bf279fab9dd4c6db6fe8af01af0608f6e1f08b8ffa4e5d407fa3
+ nokogiri (1.19.1-arm-linux-musl) sha256=3a18e559ee499b064aac6562d98daab3d39ba6cbb4074a1542781b2f556db47d
+ nokogiri (1.19.1-arm64-darwin) sha256=dfe2d337e6700eac47290407c289d56bcf85805d128c1b5a6434ddb79731cb9e
+ nokogiri (1.19.1-x86_64-darwin) sha256=7093896778cc03efb74b85f915a775862730e887f2e58d6921e3fa3d981e68bf
+ nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a
+ nokogiri (1.19.1-x86_64-linux-musl) sha256=4267f38ad4fc7e52a2e7ee28ed494e8f9d8eb4f4b3320901d55981c7b995fc23
+ parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
+ parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
+ pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99
+ pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea
+ pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c
+ pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f
+ pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6
+ pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d
+ pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009
+ pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
+ prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+ propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392
+ psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
+ racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
+ rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
+ rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
+ rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
+ rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
+ rails (8.1.2) sha256=5069061b23dfa8706b9f0159ae8b9d35727359103178a26962b868a680ba7d95
+ rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d
+ rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89
+ railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055
+ rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+ rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
+ regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
+ reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
+ rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+ rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+ rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c
+ rspec-rails (7.1.1) sha256=e15dccabed211e2fd92f21330c819adcbeb1591c1d66c580d8f2d8288557e331
+ rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
+ rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77
+ rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
+ ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
+ stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
+ stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+ thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
+ timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+ turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
+ tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
+ unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
+ unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+ useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
+ websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
+ websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
+ zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
+
+BUNDLED WITH
+ 4.0.3
diff --git a/MIT-LICENSE b/MIT-LICENSE
index 0cc83b8..5421bea 100644
--- a/MIT-LICENSE
+++ b/MIT-LICENSE
@@ -1,20 +1,21 @@
-Copyright Evangelos Giataganas
+MIT License
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
+Copyright (c) 2026 shift42
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index e662336..e5171c3 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,292 @@
-# Cms
-Short description and motivation.
+# CMS
-## Usage
-How to use my plugin.
+A mountable Rails CMS engine by [Shift42](https://shift42.io).
+
+Rails-native, straightforward to extend, and easy for content editors to use.
+
+## Compatibility
+
+- Ruby >= 3.1
+- Rails >= 7.1 (tested up to 8.x)
+- PostgreSQL
+
+## Features
+
+| Feature | Status |
+| -------------------------------------------------------------- | ------ |
+| Multi-site support | ✅ |
+| Content blocks / StreamField (`Cms::Section`) | ✅ |
+| Multilingual (locale-scoped translations) | ✅ |
+| Publishing workflow (draft / published / archived) | ✅ |
+| Soft-delete for pages and sections (`discard` gem) | ✅ |
+| Draft preview URLs (token-based, shareable without auth) | ✅ |
+| Page tree (parent/child hierarchy) | ✅ |
+| Media management (images + documents) | ✅ |
+| Reusable sections across pages + standalone admin library | ✅ |
+| Form fields + submission capture + email notification | ✅ |
+| Full-text search (title + slug, ilike/unaccent) | ✅ |
+| Headless JSON API v1 (API key auth) | ✅ |
+| Webhooks (HMAC-signed, per status change, delivery log) | ✅ |
+| Engine-owned English I18n defaults | ✅ |
+| Host app extension points (`Cms.setup`) | ✅ |
## Installation
-Add this line to your application's Gemfile:
+
+Add to your host app's `Gemfile`:
```ruby
-gem "cms"
+gem "cms", git: "https://github.com/shift42/cms"
```
-And then execute:
+Install and migrate:
+
```bash
-$ bundle
+bundle install
+bin/rails active_storage:install # skip if already installed
+bin/rails generate cms:install
+bin/rails db:migrate
+```
+
+Mount in `config/routes.rb`:
+
+```ruby
+mount Cms::Engine, at: "/cms"
+```
+
+Copy engine views into your host app (for Hyper/admin customisation, etc):
+
+```bash
+# Copy all CMS views
+bin/rails generate cms:views
+
+# Copy only admin/public/mailer views
+bin/rails generate cms:views admin
+bin/rails generate cms:views public
+bin/rails generate cms:views mailer
+
+# Or select scopes explicitly
+bin/rails generate cms:views -v admin public
+```
+
+## Configuration
+
+Create `config/initializers/cms.rb`:
+
+```ruby
+Cms.setup do |config|
+ # Public/API controllers inherit from this host controller
+ config.parent_controller = "ApplicationController"
+
+ # Required: admin inherits from a host app controller that already handles
+ # authentication/authorization for the CMS admin area
+ config.admin_parent_controller = "Admin::BaseController"
+
+ # Optional for public/API if you resolve sites via URL slug, subdomain,
+ # or X-CMS-SITE-SLUG. Required for admin once any Cms::Site exists.
+ config.current_site_resolver = ->(controller) { controller.current_organization&.cms_site }
+
+ # Optional: image renditions (used by cms_image_tag helper)
+ config.image_renditions = {
+ thumb: "300x200",
+ hero: "1200x630"
+ }
+
+ # Optional: notification email for form submissions
+ config.form_submission_email = ->(page) { "admin@example.com" }
+
+ # Optional: sender address for outgoing CMS mailers (defaults to "noreply@example.com")
+ config.mailer_from = "cms@myapp.com"
+
+ # Optional: gate admin access — return false/nil to respond with 403
+ config.authorize_admin = ->(controller) { controller.current_user&.admin? }
+
+ # Optional: auto-destroy sections that become orphaned after page removal (default: false)
+ config.auto_destroy_orphaned_sections = false
+
+ # Optional: replace the page resolver used by public/API requests
+ config.page_resolver_class = "Cms::PageResolver"
+
+ # Optional: replace API serializer classes
+ config.api_site_serializer_class = "Cms::Api::SiteSerializer"
+ config.api_page_serializer_class = "Cms::Api::PageSerializer"
+end
+```
+
+## Configuration Surface
+
+These are the supported host app extension points today:
+
+| Config key | Signature | Purpose |
+|---|---|---|
+| `parent_controller` | String class name | Base public/API controller to inherit from |
+| `admin_parent_controller` | String class name | Base admin controller to inherit from |
+| `current_site_resolver` | `->(controller)` | Host-provided current site resolver; required for admin once sites exist |
+| `authorize_admin` | `->(controller)` | Optional RBAC hook; return false/nil to respond 403 |
+| `form_submission_email` | `->(page)` | Notification email recipient address |
+| `mailer_from` | String | Sender address for CMS mailers (default: `"noreply@example.com"`) |
+| `image_renditions` | Hash | Named variant dimensions |
+| `page_templates` | Array of strings/symbols | Registers additional public page template keys |
+| `page_resolver_class` | String class name / Class | Resolver used by public and API page lookup |
+| `api_site_serializer_class` | String class name / Class | Serializer for `GET /api/v1/site` and `GET /api/v1/sites/:site_slug` |
+| `api_page_serializer_class` | String class name / Class | Serializer for `GET /api/v1/pages/:slug` and site-scoped page endpoints |
+| `admin_layout` | String layout name | Admin layout override |
+| `public_layout` | String layout name | Public layout override |
+| `auto_destroy_orphaned_sections` | Boolean | Auto-destroy sections that have no remaining page placements (default: `false`) |
+
+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.
+
+## Host App Extension
+
+The engine is intentionally CMS-only. It manages content structure, publishing, media, reusable sections, forms, and CMS rendering.
+
+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:
+
+- add host app routes before or alongside the mounted CMS engine
+- use host app controllers and views for business-specific pages
+- query host app models directly from the host app
+- override engine views or controllers only when CMS behavior itself needs to change
+- reuse CMS sections or rendered content inside host app pages when helpful
+
+The CMS engine should not know about host concepts such as products, carts, customers, orders, or accounts.
+
+## Locales
+
+Engine-owned UI strings live in `config/locales/en.yml` under `cms.*`.
+
+- Host apps can override or extend them with normal Rails locale files such as `config/locales/el.yml`.
+- Public and API locale resolution is explicit: `Cms::PageResolver` returns the chosen locale, and controllers apply it with `I18n.with_locale`.
+- Public and API page lookup uses `config.page_resolver_class`, which defaults to `Cms::PageResolver`.
+- Public form errors and notices are translated through I18n, so host apps can localize validation and flash output without monkey-patching engine code.
+
+## Content Blocks (StreamField)
+
+Register custom block types in your host app:
+
+```ruby
+Cms::Section::KindRegistry.register(
+ "my_custom_block",
+ partial: "cms/sections/my_custom_block",
+ block_class: MyApp::MyCustomBlock
+)
+```
+
+Block classes inherit from `Cms::Section::BlockBase` and declare typed settings:
+
+```ruby
+class MyApp::MyCustomBlock < Cms::Section::BlockBase
+ settings_schema do
+ field :background_color, type: :string, default: "#ffffff"
+ field :columns, type: :integer, default: 3
+ end
+end
```
-Or install it yourself as:
+## Reusable Sections
+
+Sections are site-scoped reusable content blocks that can be attached to multiple pages.
+They can be managed centrally in the admin section library and then attached where needed.
+
+Built-in image sections reference `Cms::Image` records from the CMS media library via `settings["image_id"]` instead of storing raw URLs.
+
+Use the `cms_sections` helper in views when you need to list reusable sections by kind:
+
+```erb
+<% cms_sections(kind: "cta").each do |section| %>
+ <%= render_section(section) %>
+<% end %>
+```
+
+## Page Templates
+
+`Cms::Page#template_key` drives public page rendering as a presentation concern.
+
+Public pages render through a shared shell at `cms/public/pages/show`, which then resolves a template partial at:
+
+- `cms/public/pages/templates/_.html.erb`
+- fallback: `cms/public/pages/templates/_standard.html.erb`
+
+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`.
+
+This keeps page templates simple and maintainable:
+
+- routes stay stable
+- controllers stay shared
+- `template_key` stays presentation-only
+- host apps can customize page types without replacing the full public rendering flow
+
+## Headless API
+
+The JSON API is available at `/api/v1/` and requires a Bearer token.
+Create API keys in the admin UI (`/admin/api_keys`).
+
```bash
-$ gem install cms
+curl -H "Authorization: Bearer YOUR_TOKEN" \
+ https://yourapp.com/cms/api/v1/pages/about
```
-## Contributing
-Contribution directions go here.
+Endpoints:
+
+- `GET /api/v1/site` — site info + published pages
+- `GET /api/v1/pages/:slug` — single page resource with sections
+- `GET /api/v1/pages/:slug?include_site=true` — page resource plus lightweight site metadata
+- `GET /api/v1/sites/:site_slug` — (multi-site) site info
+- `GET /api/v1/sites/:site_slug/pages/:slug` — (multi-site) page
+
+## Webhooks
+
+Configure webhooks in the admin UI. Each webhook receives a HMAC-signed POST:
+
+```
+POST https://yourapp.com/webhook-receiver
+X-CMS-Event: page.published
+X-CMS-Signature: sha256=
+Content-Type: application/json
+```
+
+Supported events: `page.published`, `page.unpublished`.
+
+Verify the signature in your receiver:
+
+```ruby
+expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', YOUR_SECRET, request.body.read)}"
+ActiveSupport::SecurityUtils.secure_compare(expected, request.headers['X-CMS-Signature'])
+```
+
+## Routes
+
+| Path | Description |
+|---|---|
+| `/admin/` | Admin UI |
+| `/api/v1/` | Headless JSON API (requires API key) |
+| `/sites/:site_slug/` | Public SSR (multi-site) |
+| `/` and `/*slug` | Public SSR (single-site) |
+
+## Notes
+
+- 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`.
+- Public/API site resolution supports `config.current_site_resolver`, `params[:site_slug]`, `X-CMS-SITE-SLUG`, or first subdomain.
+- 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`.
+- Navigation rendering uses `header_nav` and `footer_nav` page scopes.
+- `template_key` selects `cms/public/pages/templates/` with fallback to `standard`, so host apps can override page presentation per template key.
+- Page hierarchy is a simple parent/child adjacency list and is intentionally optimized for modest marketing-style site trees, not large catalog trees.
+- Revisions and snippets are not part of the current engine architecture. Reusable content is centered on `Cms::Section` and `Cms::PageSection`.
+
+## Development
+
+```bash
+# Run tests (uses spec/cms_app dummy app)
+bundle exec rspec
+
+# Run linter
+bin/rubocop
+
+# Console in dummy app
+cd spec/cms_app && bin/rails console
+
+# Run dummy app server
+cd spec/cms_app && bin/rails server
+```
## License
-The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
+
+MIT. See [LICENSE](MIT-LICENSE).
diff --git a/Rakefile b/Rakefile
index 7ca8948..8a69d5b 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,3 +1,10 @@
+# frozen_string_literal: true
+
require "bundler/setup"
+APP_RAKEFILE = File.expand_path("spec/cms_app/Rakefile", __dir__)
+load "rails/tasks/engine.rake"
require "bundler/gem_tasks"
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
diff --git a/app/assets/stylesheets/cms/application.css b/app/assets/stylesheets/cms/application.css
index 0ebd7fe..3a76c14 100644
--- a/app/assets/stylesheets/cms/application.css
+++ b/app/assets/stylesheets/cms/application.css
@@ -1,15 +1,3 @@
/*
- * This is a manifest file that'll be compiled into application.css, which will include all the files
- * listed below.
- *
- * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
- * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
- *
- * You're free to add application-wide styles to this file and they'll appear at the bottom of the
- * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
- * files in this directory. Styles in this file should be added after the last require_* statement.
- * It is generally better to create a new file per style scope.
- *
- *= require_tree .
*= require_self
*/
diff --git a/app/controllers/cms/admin/api_keys_controller.rb b/app/controllers/cms/admin/api_keys_controller.rb
new file mode 100644
index 0000000..87b9ed1
--- /dev/null
+++ b/app/controllers/cms/admin/api_keys_controller.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class ApiKeysController < BaseController
+ before_action :set_api_key, only: %i[edit update destroy]
+
+ def index
+ @api_keys = current_site.api_keys.order(created_at: :desc)
+ end
+
+ def new
+ @api_key = current_site.api_keys.build
+ end
+
+ def create
+ @api_key = current_site.api_keys.build(api_key_params)
+ if @api_key.save
+ @new_token = @api_key.token
+ render :create
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def edit; end
+
+ def update
+ if @api_key.update(api_key_params.except(:token))
+ redirect_to admin_api_keys_path, notice: t("cms.notices.api_key_updated")
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @api_key.destroy
+ redirect_to admin_api_keys_path, notice: t("cms.notices.api_key_deleted")
+ end
+
+ private
+
+ def set_api_key
+ @api_key = current_site.api_keys.find(params[:id])
+ end
+
+ def api_key_params
+ params.require(:api_key).permit(:name, :active)
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/base_controller.rb b/app/controllers/cms/admin/base_controller.rb
new file mode 100644
index 0000000..7cd78ff
--- /dev/null
+++ b/app/controllers/cms/admin/base_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class BaseController < (Cms.config.admin_parent_controller.presence || Cms.parent_controller).constantize
+ include Cms::CurrentSiteResolver
+
+ layout -> { Cms.config.admin_layout }
+ helper Cms::ApplicationHelper
+
+ rescue_from ActiveRecord::RecordNotFound, with: :render_admin_not_found
+
+ before_action :authorize_admin_action!
+ before_action :ensure_site_access!
+
+ private
+
+ def current_site
+ @current_site ||= configured_current_site || raise_missing_current_site!
+ end
+
+ def ensure_site_access!
+ return if bootstrap_site_request? && Cms::Site.none?
+ return if configured_current_site.present?
+
+ if Cms::Site.none?
+ redirect_to new_admin_site_path
+ return
+ end
+
+ raise_missing_current_site!
+ end
+
+ def authorize_admin_action!
+ return unless Cms.config.authorize_admin.present?
+ return if Cms.config.authorize_admin.call(self)
+
+ head :forbidden
+ end
+
+ def bootstrap_site_request?
+ controller_name == "sites" && action_name.in?(%w[new create])
+ end
+
+ def render_admin_not_found
+ redirect_to admin_pages_path, alert: t("cms.errors.record_not_found")
+ end
+
+ def raise_missing_current_site!
+ raise t("cms.errors.current_site_required")
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/documents_controller.rb b/app/controllers/cms/admin/documents_controller.rb
new file mode 100644
index 0000000..8df6343
--- /dev/null
+++ b/app/controllers/cms/admin/documents_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class DocumentsController < BaseController
+ before_action :set_document, only: %i[edit update destroy]
+
+ def index
+ @documents = current_site.documents.ordered
+ end
+
+ def new
+ @document = current_site.documents.build
+ end
+
+ def edit; end
+
+ def create
+ @document = current_site.documents.build(document_params)
+
+ if @document.save
+ redirect_to admin_documents_path, notice: t("cms.notices.document_uploaded")
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def update
+ @document.assign_attributes(document_params)
+
+ if @document.save
+ redirect_to admin_documents_path, notice: t("cms.notices.document_updated")
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @document.file.purge_later
+ @document.destroy
+ redirect_to admin_documents_path, notice: t("cms.notices.document_deleted")
+ end
+
+ private
+
+ def set_document
+ @document = current_site.documents.find(params[:id])
+ end
+
+ def document_params
+ params.fetch(:cms_document, params.fetch(:document, ActionController::Parameters.new))
+ .permit(:title, :description, :file)
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/form_fields_controller.rb b/app/controllers/cms/admin/form_fields_controller.rb
new file mode 100644
index 0000000..56d49b3
--- /dev/null
+++ b/app/controllers/cms/admin/form_fields_controller.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class FormFieldsController < BaseController
+ before_action :set_page
+ before_action :set_field, only: %i[edit update destroy]
+
+ def index
+ @fields = @page.form_fields.ordered
+ end
+
+ def new
+ @field = @page.form_fields.build(position: @page.form_fields.count)
+ end
+
+ def edit; end
+
+ def create
+ @field = @page.form_fields.build(field_params)
+
+ if @field.save
+ redirect_to admin_page_form_fields_path(@page), notice: t("cms.notices.field_added")
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def update
+ @field.assign_attributes(field_params)
+
+ if @field.save
+ redirect_to admin_page_form_fields_path(@page), notice: t("cms.notices.field_updated")
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @field.destroy
+ redirect_to admin_page_form_fields_path(@page), notice: t("cms.notices.field_removed")
+ end
+
+ def sort
+ Array(params[:field_ids]).each_with_index do |id, position|
+ @page.form_fields.find(id).update!(position: position)
+ end
+ head :ok
+ end
+
+ private
+
+ def set_page
+ @page = current_site.pages.find(params[:page_id])
+ end
+
+ def set_field
+ @field = @page.form_fields.find(params[:id])
+ end
+
+ def field_params
+ params.fetch(:cms_form_field, params.fetch(:form_field, ActionController::Parameters.new))
+ .permit(
+ :kind, :label, :field_name, :placeholder, :hint, :required, :position,
+ options: []
+ )
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/form_submissions_controller.rb b/app/controllers/cms/admin/form_submissions_controller.rb
new file mode 100644
index 0000000..08a0543
--- /dev/null
+++ b/app/controllers/cms/admin/form_submissions_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class FormSubmissionsController < BaseController
+ before_action :set_page
+
+ def index
+ @submissions = @page.form_submissions.recent
+ @fields = @page.form_fields.ordered
+
+ respond_to do |format|
+ format.html
+ format.csv do
+ csv = Cms::FormSubmission.to_csv(@submissions, @fields)
+ send_data csv,
+ filename: "#{@page.slug}-submissions-#{Date.today}.csv",
+ type: "text/csv"
+ end
+ end
+ end
+
+ def destroy
+ @submission = @page.form_submissions.find(params[:id])
+ @submission.destroy
+ redirect_to admin_page_form_submissions_path(@page), notice: t("cms.notices.submission_deleted")
+ end
+
+ private
+
+ def set_page
+ @page = current_site.pages.find(params[:page_id])
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/images_controller.rb b/app/controllers/cms/admin/images_controller.rb
new file mode 100644
index 0000000..bf63fcb
--- /dev/null
+++ b/app/controllers/cms/admin/images_controller.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class ImagesController < BaseController
+ before_action :set_image, only: %i[edit update destroy]
+
+ def index
+ @images = current_site.images.includes(:localised).ordered
+ end
+
+ def new
+ @image = current_site.images.build
+ @translation_locale = requested_locale
+ @image.image_translations.build(locale: @translation_locale)
+ end
+
+ def edit
+ @translation_locale = requested_locale
+ @image.image_translations.find_or_initialize_by(locale: @translation_locale)
+ end
+
+ def create
+ @image = current_site.images.build(image_params)
+ @translation_locale = requested_locale
+
+ if @image.save
+ redirect_to admin_images_path, notice: t("cms.notices.image_uploaded")
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def update
+ @translation_locale = requested_locale
+ @image.assign_attributes(image_params)
+
+ if @image.save
+ redirect_to admin_images_path, notice: t("cms.notices.image_updated")
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @image.file.purge_later
+ @image.destroy
+ redirect_to admin_images_path, notice: t("cms.notices.image_deleted")
+ end
+
+ private
+
+ def set_image
+ @image = current_site.images.includes(:image_translations, :localised).find(params[:id])
+ end
+
+ def image_params
+ params.fetch(:cms_image, params.fetch(:image, ActionController::Parameters.new))
+ .permit(:title, :file, image_translations_attributes: %i[id locale alt_text caption])
+ end
+
+ def requested_locale
+ params[:locale].presence || current_site.default_locale.presence || I18n.locale.to_s
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/pages_controller.rb b/app/controllers/cms/admin/pages_controller.rb
new file mode 100644
index 0000000..bc107dd
--- /dev/null
+++ b/app/controllers/cms/admin/pages_controller.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class PagesController < BaseController
+ include Cms::Public::PageRendering
+ include Cms::Public::PagePaths
+
+ helper Cms::ApplicationHelper
+ helper Cms::MediaHelper
+ helper Cms::SitesHelper
+ helper Cms::SectionsHelper
+ helper Cms::PagesHelper
+
+ before_action :set_page, only: %i[show edit update destroy preview]
+
+ def index
+ pages = current_site.pages.kept.includes(:localised).ordered.to_a
+ depths = page_depths(pages)
+
+ @page_rows = if params[:q].present?
+ matching_page_rows(pages, depths)
+ else
+ flattened_page_rows(pages)
+ end
+ end
+
+ def show
+ @translation_locale = requested_locale
+ @subpages = @page.subpages.sort_by { |page| [page.position || 0, page.id] }
+ @page_sections = @page.page_sections.ordered.includes(:section)
+ @available_sections = current_site.sections
+ .kept
+ .global
+ .where.not(id: @page.section_ids)
+ .ordered
+ end
+
+ def preview
+ @translation_locale = requested_locale
+ @page.page_translations.find_or_initialize_by(locale: @translation_locale)
+ I18n.with_locale(@translation_locale) do
+ assign_public_page(site: current_site, page: @page)
+ render template: "cms/public/pages/show", layout: Cms.config.public_layout
+ end
+ end
+
+ def new
+ @page = current_site.pages.build
+ @translation_locale = requested_locale
+ @page.page_translations.build(locale: @translation_locale)
+ @parent_options = parent_options_for(nil)
+ end
+
+ def edit
+ @translation_locale = requested_locale
+ @page.page_translations.find_or_initialize_by(locale: @translation_locale)
+ @parent_options = parent_options_for(@page)
+ end
+
+ def create
+ @page = current_site.pages.build(page_params)
+ @translation_locale = requested_locale
+ purge_media_if_requested(@page)
+
+ if @page.save
+ redirect_to admin_pages_path, notice: t("cms.notices.page_created")
+ else
+ @parent_options = parent_options_for(nil)
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def update
+ @translation_locale = requested_locale
+ @page.assign_attributes(page_params)
+ purge_media_if_requested(@page)
+
+ if @page.save
+ redirect_to admin_pages_path, notice: t("cms.notices.page_updated")
+ else
+ @parent_options = parent_options_for(@page)
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @page.discard!
+ redirect_to admin_pages_path, notice: t("cms.notices.page_deleted")
+ end
+
+ private
+
+ def set_page
+ @page = current_site.pages.kept.includes(
+ :parent,
+ :page_translations,
+ { subpages: :localised },
+ { page_sections: :section },
+ { hero_image_attachment: :blob },
+ { media_files_attachments: :blob }
+ ).find(params[:id])
+ end
+
+ def page_params
+ params.require(:page).permit(
+ :slug,
+ :parent_id,
+ :position,
+ :home,
+ :template_key,
+ :status,
+ :show_in_header,
+ :show_in_footer,
+ :nav_group,
+ :nav_order,
+ :footer_order,
+ :hero_image,
+ media_files: [],
+ page_translations_attributes: %i[id locale title seo_title seo_description]
+ )
+ end
+
+ def requested_locale
+ params[:locale].presence || current_site.default_locale.presence || I18n.locale.to_s
+ end
+
+ def purge_media_if_requested(page)
+ page.hero_image.purge_later if params.dig(:page, :remove_hero_image) == "1"
+ page.media_files.purge if params.dig(:page, :remove_media_files) == "1"
+ end
+
+ def parent_options_for(current_page)
+ pages = current_site.pages.kept.ordered.includes(:localised).to_a
+ depths = page_depths(pages)
+ excluded_ids = current_page ? [current_page.id] + descendant_ids_for(pages, current_page.id) : []
+
+ pages.reject { |page| excluded_ids.include?(page.id) }.map do |p|
+ ["#{'— ' * depths.fetch(p.id, 0)}#{p.display_title}", p.id]
+ end
+ end
+
+ def descendant_ids_for(pages, page_id)
+ children_by_parent = pages.group_by(&:parent_id)
+ queue = Array(children_by_parent[page_id])
+ ids = []
+
+ until queue.empty?
+ page = queue.shift
+ ids << page.id
+ queue.concat(Array(children_by_parent[page.id]))
+ end
+
+ ids
+ end
+
+ def flattened_page_rows(pages)
+ children_by_parent = pages.group_by(&:parent_id)
+ rows = []
+
+ append_page_rows(rows, children_by_parent, children_by_parent[nil], 0)
+ rows
+ end
+
+ def append_page_rows(rows, children_by_parent, pages, depth)
+ Array(pages).each do |page|
+ rows << [page, depth]
+ append_page_rows(rows, children_by_parent, children_by_parent[page.id], depth + 1)
+ end
+ end
+
+ def page_depths(pages)
+ pages.to_h { |page| [page.id, page.depth] }
+ end
+
+ def matching_page_rows(pages, depths)
+ pages_by_id = pages.index_by(&:id)
+
+ current_site.pages.kept.search(params[:q]).pluck(:id).filter_map do |page_id|
+ page = pages_by_id[page_id]
+ next unless page
+
+ [page, depths.fetch(page.id, 0)]
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/sections_controller.rb b/app/controllers/cms/admin/sections_controller.rb
new file mode 100644
index 0000000..d46fbf6
--- /dev/null
+++ b/app/controllers/cms/admin/sections_controller.rb
@@ -0,0 +1,177 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class SectionsController < BaseController
+ include PageScopedSections
+
+ before_action :set_page, if: :page_scoped_request?
+ before_action :set_section, only: %i[show edit update destroy]
+
+ def index
+ @sections = current_site.sections.kept.global.includes(:translations, :section_images).ordered
+ end
+
+ def show
+ @translation_locale = requested_locale
+ end
+
+ def new
+ @section = current_site.sections.build(kind: params[:kind].presence || "rich_text", global: !@page)
+ @translation_locale = requested_locale
+ @section.build_missing_locale_translations
+ end
+
+ def edit
+ @translation_locale = requested_locale
+ @section.build_missing_locale_translations
+ end
+
+ def create
+ @section = current_site.sections.build(section_params)
+ @section.global = !@page
+ @translation_locale = requested_locale
+
+ if @section.save
+ sync_section_images(@section)
+ if turbo_page_scoped_request?
+ attach_to_page!
+ flash.now[:notice] = success_notice_for(:create)
+ load_page_show_context
+ render :page_update
+ else
+ attach_to_page! if @page
+ redirect_to after_save_path, notice: success_notice_for(:create)
+ end
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def update
+ @translation_locale = requested_locale
+ @section.assign_attributes(section_params)
+
+ if @section.save
+ sync_section_images(@section)
+ if turbo_page_scoped_request?
+ flash.now[:notice] = success_notice_for(:update)
+ load_page_show_context
+ render :page_update
+ else
+ redirect_to after_save_path, notice: success_notice_for(:update)
+ end
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ if turbo_page_scoped_request?
+ @page.page_sections.find_by!(section_id: @section.id).destroy
+ flash.now[:notice] = success_notice_for(:destroy)
+ load_page_show_context
+ render :page_update
+ elsif @page
+ @page.page_sections.find_by!(section_id: @section.id).destroy
+ else
+ @section.discard!
+ end
+
+ redirect_to after_destroy_path, notice: success_notice_for(:destroy) unless performed?
+ end
+
+ def sort
+ ids = params[:page_section_ids].to_a.map(&:to_i)
+ ids.each_with_index do |id, index|
+ @page.page_sections.find(id).update!(position: index)
+ end
+ head :ok
+ end
+
+ def attach
+ section = current_site.sections.global.find(params[:section_id])
+ @page.page_sections.find_or_create_by!(section: section) do |page_section|
+ page_section.position = next_position
+ end
+
+ if turbo_page_scoped_request?
+ flash.now[:notice] = t("cms.notices.section_attached")
+ load_page_show_context
+ render :page_update
+ else
+ redirect_to admin_page_path(@page), notice: t("cms.notices.section_attached")
+ end
+ end
+
+ private
+
+ def set_section
+ @section = current_site.sections.kept.includes(:translations).find(params[:id])
+ end
+
+ def requested_locale
+ params[:locale].presence ||
+ section_locale.presence ||
+ current_site.default_locale.presence ||
+ I18n.locale.to_s
+ end
+
+ def section_locale
+ return unless @section
+
+ @section.available_locales.first
+ end
+
+ def section_kind
+ params.dig(:section, :kind).presence || @section&.kind || params[:kind].presence || "rich_text"
+ end
+
+ def section_params
+ permitted = %i[global enabled]
+ permitted.unshift(:kind) unless @section&.persisted?
+ permitted += permitted_settings_keys
+ permitted << { translations_attributes: %i[id locale title subtitle content] }
+
+ params.require(:section).permit(*permitted)
+ end
+
+ def permitted_settings_keys
+ case section_kind
+ when "hero" then %i[background_color cta_url]
+ when "cta" then %i[button_url alignment]
+ else []
+ end
+ end
+
+ def sync_section_images(section)
+ return unless section_kind == "image"
+
+ ids = params.dig(:section, :image_ids).to_a.compact_blank.map(&:to_i)
+ section.section_images.destroy_all
+ ids.each_with_index do |image_id, index|
+ section.section_images.create!(image_id: image_id, position: index)
+ end
+ end
+
+ def after_save_path
+ @page ? admin_page_path(@page, locale: @translation_locale) : admin_sections_path(locale: @translation_locale)
+ end
+
+ def after_destroy_path
+ @page ? admin_page_path(@page, locale: params[:locale]) : admin_sections_path(locale: params[:locale])
+ end
+
+ def success_notice_for(action)
+ return t("cms.notices.section_added") if action == :create && @page
+ return t("cms.notices.section_removed") if action == :destroy && @page
+
+ {
+ create: t("cms.notices.section_created"),
+ update: t("cms.notices.section_updated"),
+ destroy: t("cms.notices.section_deleted")
+ }.fetch(action)
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/sites_controller.rb b/app/controllers/cms/admin/sites_controller.rb
new file mode 100644
index 0000000..f880796
--- /dev/null
+++ b/app/controllers/cms/admin/sites_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class SitesController < BaseController
+ before_action :redirect_bootstrap_requests_when_site_available, only: %i[new create]
+
+ def new
+ @site = Cms::Site.new(default_locale: I18n.default_locale.to_s, published: true)
+ end
+
+ def create
+ @site = Cms::Site.new(site_params)
+ purge_media_if_requested(@site)
+
+ if @site.save
+ redirect_to admin_site_path, notice: t("cms.notices.site_created")
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def show
+ @site = current_site
+ end
+
+ def edit
+ @site = current_site
+ end
+
+ def update
+ @site = current_site
+ purge_media_if_requested(@site)
+
+ if @site.update(site_params)
+ redirect_to admin_site_path, notice: t("cms.notices.site_updated")
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ private
+
+ def site_params
+ params.require(:site).permit(:name, :slug, :published, :default_locale, :logo)
+ end
+
+ def purge_media_if_requested(site)
+ site.logo.purge_later if params.dig(:site, :remove_logo) == "1"
+ end
+
+ def redirect_bootstrap_requests_when_site_available
+ return unless Cms::Site.exists?
+ return redirect_to(edit_admin_site_path) if configured_current_site.present?
+
+ raise_missing_current_site!
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/webhook_deliveries_controller.rb b/app/controllers/cms/admin/webhook_deliveries_controller.rb
new file mode 100644
index 0000000..9338211
--- /dev/null
+++ b/app/controllers/cms/admin/webhook_deliveries_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class WebhookDeliveriesController < BaseController
+ before_action :set_webhook
+
+ def index
+ @deliveries = @webhook.deliveries.recent
+ end
+
+ private
+
+ def set_webhook
+ @webhook = current_site.webhooks.find(params[:webhook_id])
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/admin/webhooks_controller.rb b/app/controllers/cms/admin/webhooks_controller.rb
new file mode 100644
index 0000000..c7f893b
--- /dev/null
+++ b/app/controllers/cms/admin/webhooks_controller.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ class WebhooksController < BaseController
+ before_action :set_webhook, only: %i[edit update destroy]
+
+ def index
+ @webhooks = current_site.webhooks.order(created_at: :desc)
+ end
+
+ def new
+ @webhook = current_site.webhooks.build
+ end
+
+ def create
+ @webhook = current_site.webhooks.build(webhook_params)
+ if @webhook.save
+ redirect_to admin_webhooks_path, notice: t("cms.notices.webhook_created")
+ else
+ render :new, status: :unprocessable_content
+ end
+ end
+
+ def edit; end
+
+ def update
+ if @webhook.update(webhook_params)
+ redirect_to admin_webhooks_path, notice: t("cms.notices.webhook_updated")
+ else
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ @webhook.destroy
+ redirect_to admin_webhooks_path, notice: t("cms.notices.webhook_deleted")
+ end
+
+ private
+
+ def set_webhook
+ @webhook = current_site.webhooks.find(params[:id])
+ end
+
+ def webhook_params
+ params.require(:webhook).permit(:url, :secret, :active, events: [])
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/api/base_controller.rb b/app/controllers/cms/api/base_controller.rb
new file mode 100644
index 0000000..70c46e9
--- /dev/null
+++ b/app/controllers/cms/api/base_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Cms
+ module Api
+ class BaseController < ApplicationController
+ include Cms::SiteResolvable
+
+ layout false
+
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_json
+
+ private
+
+ def render_not_found_json
+ render json: { error: t("cms.errors.api.page_not_found") }, status: :not_found
+ end
+
+ def api_site_serializer(site, resolved_locale:)
+ Cms.api_site_serializer_class.new(
+ site: site,
+ requested_locale: resolved_locale,
+ main_app: main_app
+ )
+ end
+
+ def api_page_serializer(site, page, resolved_locale:)
+ Cms.api_page_serializer_class.new(
+ site: site,
+ page: page,
+ requested_locale: resolved_locale,
+ main_app: main_app
+ )
+ end
+
+ def truthy_param?(value)
+ ActiveModel::Type::Boolean.new.cast(value)
+ end
+
+ def request_locale
+ params[:locale].presence ||
+ request.headers["Accept-Language"].to_s.split(",").first&.strip
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/api/v1/base_controller.rb b/app/controllers/cms/api/v1/base_controller.rb
new file mode 100644
index 0000000..9c24674
--- /dev/null
+++ b/app/controllers/cms/api/v1/base_controller.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Cms
+ module Api
+ module V1
+ class BaseController < Cms::Api::BaseController
+ before_action :authenticate_api_key!
+
+ private
+
+ def authenticate_api_key!
+ site = configured_current_site || Cms::Site.live.find_by(slug: resolved_site_slug!)
+ return render json: { error: t("cms.errors.api.site_not_found") }, status: :not_found unless site
+
+ token = request.headers["Authorization"].to_s.sub(/\ABearer\s+/, "")
+ @api_key = Cms::ApiKey.active.find_by(token: token, site: site)
+
+ unless @api_key
+ render json: { error: t("cms.errors.api.unauthorized") }, status: :unauthorized
+ return
+ end
+
+ @resolved_site = site
+ @api_key.touch_last_used!
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/api/v1/pages_controller.rb b/app/controllers/cms/api/v1/pages_controller.rb
new file mode 100644
index 0000000..e123e5f
--- /dev/null
+++ b/app/controllers/cms/api/v1/pages_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Cms
+ module Api
+ module V1
+ class PagesController < BaseController
+ def show
+ site = find_site!
+ result = page_resolver_class.resolve(site: site, slug: params[:slug], locale: request_locale)
+
+ return render json: { error: t("cms.errors.api.page_not_found") }, status: :not_found unless result
+
+ I18n.with_locale(result.locale) do
+ serializer = api_page_serializer(site, result.page, resolved_locale: result.locale)
+ render json: serializer.as_json(include_site: truthy_param?(params[:include_site]))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/api/v1/sites_controller.rb b/app/controllers/cms/api/v1/sites_controller.rb
new file mode 100644
index 0000000..de121a4
--- /dev/null
+++ b/app/controllers/cms/api/v1/sites_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Cms
+ module Api
+ module V1
+ class SitesController < BaseController
+ def show
+ site = find_site!
+ resolved_locale = request_locale.presence || site.default_locale
+
+ I18n.with_locale(resolved_locale) do
+ render json: api_site_serializer(site, resolved_locale: resolved_locale).as_json
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/application_controller.rb b/app/controllers/cms/application_controller.rb
index 738a3ae..7837a3f 100644
--- a/app/controllers/cms/application_controller.rb
+++ b/app/controllers/cms/application_controller.rb
@@ -1,4 +1,11 @@
+# frozen_string_literal: true
+
module Cms
- class ApplicationController < ActionController::Base
+ class ApplicationController < Cms.parent_controller.constantize
+ private
+
+ def page_resolver_class
+ Cms.page_resolver_class
+ end
end
end
diff --git a/app/controllers/cms/public/base_controller.rb b/app/controllers/cms/public/base_controller.rb
new file mode 100644
index 0000000..9bad4ce
--- /dev/null
+++ b/app/controllers/cms/public/base_controller.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Cms
+ module Public
+ class BaseController < ApplicationController
+ layout -> { Cms.config.public_layout }
+
+ helper Cms::ApplicationHelper
+ helper Cms::MediaHelper
+ helper Cms::SitesHelper
+ helper Cms::SectionsHelper
+ helper Cms::PagesHelper
+
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
+
+ private
+
+ def render_not_found
+ render plain: t("cms.errors.page_not_found"), status: :not_found
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/public/form_submissions_controller.rb b/app/controllers/cms/public/form_submissions_controller.rb
new file mode 100644
index 0000000..2fff283
--- /dev/null
+++ b/app/controllers/cms/public/form_submissions_controller.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Cms
+ module Public
+ class FormSubmissionsController < BaseController
+ include Cms::SiteResolvable
+ include Cms::Public::PageRendering
+ include Cms::Public::PagePaths
+
+ def create
+ @site = find_site!
+ @page = @site.published_pages.includes(:form_fields, :page_translations, :localised,
+ { page_sections: :section },
+ { hero_image_attachment: :blob }).find(params[:page_id])
+ @fields = @page.form_fields.ordered
+ data = build_submission_data
+
+ @submission = @page.form_submissions.build(
+ data: data,
+ ip_address: request.remote_ip
+ )
+
+ if @submission.save
+ notify_by_email(@submission)
+ redirect_back(
+ fallback_location: public_site_page_path_for(@site, @page),
+ notice: t("cms.notices.form_submission_sent")
+ )
+ else
+ I18n.with_locale(@site.default_locale) do
+ assign_public_page(site: @site, page: @page)
+ render template: "cms/public/pages/show", status: :unprocessable_content
+ end
+ end
+ rescue ActiveRecord::RecordNotFound
+ render plain: t("cms.errors.page_not_found"), status: :not_found
+ end
+
+ private
+
+ def build_submission_data
+ @fields.to_h do |field|
+ [field.field_name, normalized_submission_value(field)]
+ end
+ end
+
+ def normalized_submission_value(field)
+ params.dig(:submission, field.field_name)
+ end
+
+ def notify_by_email(submission)
+ return unless Cms.config.form_submission_email
+
+ email = Cms.config.form_submission_email.call(@page)
+ return if email.blank?
+
+ Cms::FormSubmissionMailer.notify(submission, email).deliver_later
+ rescue StandardError
+ # Mailer failures must never break form submission
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/public/previews_controller.rb b/app/controllers/cms/public/previews_controller.rb
new file mode 100644
index 0000000..277568c
--- /dev/null
+++ b/app/controllers/cms/public/previews_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Cms
+ module Public
+ class PreviewsController < BaseController
+ include Cms::SiteResolvable
+ include Cms::Public::PageRendering
+ include Cms::Public::PagePaths
+
+ def show
+ page = Cms::Page.kept.includes(:site).find_by!(preview_token: params[:preview_token])
+ site = page.site
+
+ I18n.with_locale(site.default_locale) do
+ assign_public_page(site: site, page: page)
+ render template: "cms/public/pages/show"
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/cms/public/sites_controller.rb b/app/controllers/cms/public/sites_controller.rb
new file mode 100644
index 0000000..a4f0512
--- /dev/null
+++ b/app/controllers/cms/public/sites_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Cms
+ module Public
+ class SitesController < BaseController
+ include Cms::SiteResolvable
+ include Cms::Public::PageRendering
+ include Cms::Public::PagePaths
+
+ def show
+ site = find_site!
+ result = find_page_for_show!(site)
+
+ return render_not_found unless result
+
+ I18n.with_locale(result.locale) do
+ assign_public_page(site: site, page: result.page)
+ render template: "cms/public/pages/show"
+ end
+ end
+
+ private
+
+ def find_page_for_show!(site)
+ page_resolver_class.resolve(
+ site: site,
+ slug: params[:slug],
+ locale: I18n.locale.to_s
+ )
+ end
+
+ def render_not_found
+ render plain: t("cms.errors.page_not_found"), status: :not_found
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/cms/admin/page_scoped_sections.rb b/app/controllers/concerns/cms/admin/page_scoped_sections.rb
new file mode 100644
index 0000000..46b0cdb
--- /dev/null
+++ b/app/controllers/concerns/cms/admin/page_scoped_sections.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ module PageScopedSections
+ extend ActiveSupport::Concern
+
+ private
+
+ def set_page
+ @page = current_site.pages.find(params[:page_id])
+ end
+
+ def page_scoped_request?
+ params[:page_id].present?
+ end
+
+ def turbo_page_scoped_request?
+ page_scoped_request? && (turbo_frame_request? || request.format.turbo_stream?)
+ end
+
+ def attach_to_page!
+ @page.page_sections.create!(section: @section, position: next_position)
+ end
+
+ def next_position
+ @page.page_sections.maximum(:position).to_i + 1
+ end
+
+ def load_page_show_context
+ @translation_locale = requested_locale
+ @subpages = @page.subpages.sort_by { |page| [page.position || 0, page.id] }
+ @page_sections = @page.page_sections.ordered.includes(:section)
+ @available_sections = current_site.sections
+ .kept
+ .global
+ .where.not(id: @page.section_ids)
+ .ordered
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/cms/current_site_resolver.rb b/app/controllers/concerns/cms/current_site_resolver.rb
new file mode 100644
index 0000000..ee3e6b6
--- /dev/null
+++ b/app/controllers/concerns/cms/current_site_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Cms
+ module CurrentSiteResolver
+ extend ActiveSupport::Concern
+
+ private
+
+ def configured_current_site
+ resolver = Cms.config.current_site_resolver
+ site = resolver.call(self) if resolver
+ return site if site.is_a?(Cms::Site)
+
+ nil
+ end
+ end
+end
diff --git a/app/controllers/concerns/cms/public/page_paths.rb b/app/controllers/concerns/cms/public/page_paths.rb
new file mode 100644
index 0000000..653ee06
--- /dev/null
+++ b/app/controllers/concerns/cms/public/page_paths.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Cms
+ module Public
+ module PagePaths
+ extend ActiveSupport::Concern
+
+ included do
+ helper_method :public_site_path_for, :public_site_page_path_for
+ end
+
+ private
+
+ def routed_without_site_slug?
+ params[:site_slug].blank? && (request.headers["X-CMS-SITE-SLUG"].present? || request.subdomains.first.present?)
+ end
+
+ def public_site_path_for(site)
+ routed_without_site_slug? ? current_site_path : site_path(site.slug)
+ end
+
+ def public_site_page_path_for(site, page)
+ return public_site_path_for(site) if page.home?
+
+ page_path = page.public_path
+
+ routed_without_site_slug? ? current_site_page_path(page_path) : site_page_path(site.slug, page_path)
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/cms/public/page_rendering.rb b/app/controllers/concerns/cms/public/page_rendering.rb
new file mode 100644
index 0000000..5ed79d1
--- /dev/null
+++ b/app/controllers/concerns/cms/public/page_rendering.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Cms
+ module Public
+ module PageRendering
+ extend ActiveSupport::Concern
+
+ private
+
+ def assign_public_page(site:, page:)
+ ctx = Cms::PublicPageContext.build(site: site, page: page)
+
+ @site = ctx.site
+ @page = ctx.page
+ @header_nav_items = ctx.header_nav_items
+ @footer_pages = ctx.footer_pages
+ @sections = ctx.sections
+ @form_fields = ctx.form_fields
+ @submission = ctx.submission if @submission.nil?
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/cms/site_resolvable.rb b/app/controllers/concerns/cms/site_resolvable.rb
new file mode 100644
index 0000000..4a12f19
--- /dev/null
+++ b/app/controllers/concerns/cms/site_resolvable.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Cms
+ module SiteResolvable
+ extend ActiveSupport::Concern
+ include Cms::CurrentSiteResolver
+
+ private
+
+ def find_site!
+ return @resolved_site if defined?(@resolved_site) && @resolved_site
+ return configured_current_site if configured_current_site
+
+ Cms::Site.live.find_by!(slug: resolved_site_slug!)
+ end
+
+ def resolved_site_slug!
+ slug = params[:site_slug].presence ||
+ request.headers["X-CMS-SITE-SLUG"].presence ||
+ request.subdomains.first.presence
+ normalized = slug.to_s.parameterize
+ raise ActiveRecord::RecordNotFound, I18n.t("cms.errors.site_slug_not_provided") if normalized.blank?
+
+ normalized
+ end
+ end
+end
diff --git a/app/helpers/cms/admin/pages_helper.rb b/app/helpers/cms/admin/pages_helper.rb
new file mode 100644
index 0000000..78b205a
--- /dev/null
+++ b/app/helpers/cms/admin/pages_helper.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ module PagesHelper
+ # Flatten a tree of pages (already loaded with subpages) into [page, depth] pairs.
+ def flat_page_tree(pages, depth: 0, result: [])
+ pages.each do |page|
+ result << [page, depth]
+ flat_page_tree(Array(page.subpages), depth: depth + 1, result: result) if page.subpages.loaded?
+ end
+ result
+ end
+
+ # Returns breadcrumb array for a page: [ancestor, ..., page]
+ def breadcrumbs_for(page)
+ page.ancestors + [page]
+ end
+
+ # Translation completeness: { "en" => :complete, "fr" => :missing }
+ def translation_completeness(page)
+ I18n.available_locales.each_with_object({}) do |locale, hash|
+ translation = page.page_translations.find { |t| t.locale == locale.to_s }
+ hash[locale.to_s] = translation&.title.present? ? :complete : :missing
+ end
+ end
+ end
+ end
+end
diff --git a/app/helpers/cms/admin/sections_helper.rb b/app/helpers/cms/admin/sections_helper.rb
new file mode 100644
index 0000000..aad6869
--- /dev/null
+++ b/app/helpers/cms/admin/sections_helper.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ module SectionsHelper
+ # Renders a form field for a block settings field definition.
+ #
+ # @param form [ActionView::Helpers::FormBuilder]
+ # @param field [Hash] field definition from BlockBase.settings_schema
+ def render_settings_field(form, field, site: nil)
+ name = field[:name]
+
+ content_tag(:div, class: "cms-form__field") do
+ concat form.label("settings_#{name}", cms_section_setting_label(name), class: "cms-form__label")
+ concat settings_input(form, field, site: site)
+ if field[:type] == :image
+ concat image_library_actions
+ concat image_library_hint(site) if site&.images&.none?
+ end
+ end
+ end
+
+ def cms_section_image_options(site)
+ return [] unless site
+
+ site.images.ordered.map { |image| [image.display_title, image.id] }
+ end
+
+ private
+
+ def settings_input(form, field, site:)
+ name = field[:name]
+
+ case field[:type]
+ when :url
+ form.url_field "settings[#{name}]", input_options(form, name)
+ when :color
+ form.color_field "settings[#{name}]", input_options(form, name, default: field[:default])
+ when :boolean
+ form.check_box "settings[#{name}]", { class: "cms-checkbox" }, "true", "false"
+ when :select
+ form.select "settings[#{name}]",
+ select_options(field),
+ { selected: form.object.settings[name] || field[:default] },
+ class: "cms-input"
+ when :image
+ form.select "settings[#{name}]",
+ cms_section_image_options(site),
+ {
+ include_blank: t("cms.admin.sections.form.select_image"),
+ selected: form.object.settings[name]
+ },
+ class: "cms-input"
+ else
+ form.text_field "settings[#{name}]", input_options(form, name)
+ end
+ end
+
+ def input_options(form, name, default: nil)
+ { class: "cms-input", value: form.object.settings[name] || default }
+ end
+
+ def select_options(field)
+ field[:options].map { |option| [cms_section_setting_option_label(field[:name], option), option] }
+ end
+
+ def image_library_hint(site)
+ return "".html_safe unless site&.images&.none?
+
+ content_tag(:p, t("cms.admin.sections.form.image_library_empty"), class: "cms-form__hint")
+ end
+
+ def image_library_actions
+ content_tag(:p, class: "cms-form__hint") do
+ safe_join(
+ [
+ link_to(t("cms.admin.sections.form.manage_images"), admin_images_path),
+ " ",
+ link_to(t("cms.admin.sections.form.upload_image"), new_admin_image_path)
+ ]
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/helpers/cms/admin/sites_helper.rb b/app/helpers/cms/admin/sites_helper.rb
new file mode 100644
index 0000000..1eaa8ed
--- /dev/null
+++ b/app/helpers/cms/admin/sites_helper.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Cms
+ module Admin
+ module SitesHelper
+ end
+ end
+end
diff --git a/app/helpers/cms/application_helper.rb b/app/helpers/cms/application_helper.rb
index f87e55a..bc6ca92 100644
--- a/app/helpers/cms/application_helper.rb
+++ b/app/helpers/cms/application_helper.rb
@@ -1,4 +1,51 @@
+# frozen_string_literal: true
+
module Cms
module ApplicationHelper
+ def cms_attachment_path(attachment)
+ return unless attachment.attached?
+
+ main_app.rails_blob_path(attachment, only_path: true)
+ end
+
+ def cms_yes_no(value)
+ t(value ? "cms.shared.yes" : "cms.shared.no")
+ end
+
+ def cms_date(value)
+ l(value.to_date, format: :cms_date)
+ end
+
+ def cms_datetime(value)
+ l(value, format: :cms_datetime)
+ end
+
+ def cms_page_template_label(key)
+ t("cms.page_templates.#{key}")
+ end
+
+ def cms_page_status_label(key)
+ t("cms.page_statuses.#{key}")
+ end
+
+ def cms_form_field_kind_label(key)
+ t("cms.form_field_kinds.#{key}")
+ end
+
+ def cms_section_kind_label(key)
+ t("cms.section_kinds.#{key}")
+ end
+
+ def cms_webhook_event_label(key)
+ t("cms.webhook_events.#{key.tr('.', '_')}")
+ end
+
+ def cms_section_setting_label(name)
+ t("cms.section_settings.labels.#{name}")
+ end
+
+ def cms_section_setting_option_label(name, option)
+ t("cms.section_settings.options.#{name}.#{option}")
+ end
end
end
diff --git a/app/helpers/cms/media_helper.rb b/app/helpers/cms/media_helper.rb
new file mode 100644
index 0000000..e7a12d9
--- /dev/null
+++ b/app/helpers/cms/media_helper.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Cms
+ module MediaHelper
+ def cms_image_tag(image, rendition: nil, **)
+ return "" unless image&.file&.attached?
+
+ if rendition && Cms.config.image_renditions&.key?(rendition)
+ dimensions = Cms.config.image_renditions[rendition]
+ width, height = dimensions.split("x").map(&:to_i)
+ variant = image.file.variant(resize_to_limit: [width, height]).processed
+ image_tag(main_app.rails_representation_path(variant, only_path: true),
+ alt: image.alt_text.presence || image.display_title,
+ **)
+ else
+ image_tag(main_app.rails_blob_path(image.file, only_path: true),
+ alt: image.alt_text.presence || image.display_title,
+ **)
+ end
+ end
+
+ def cms_document_url(document)
+ return nil unless document&.file&.attached?
+
+ url_for(document.file)
+ end
+ end
+end
diff --git a/app/helpers/cms/pages_helper.rb b/app/helpers/cms/pages_helper.rb
new file mode 100644
index 0000000..cd43786
--- /dev/null
+++ b/app/helpers/cms/pages_helper.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Cms
+ module PagesHelper
+ def render_page_template(page)
+ render partial: cms_page_template_partial(page)
+ end
+
+ def cms_page_template_partial(page)
+ template = "cms/public/pages/templates/#{page.template_key}"
+ return template if lookup_context.exists?(template, [], true)
+
+ "cms/public/pages/templates/standard"
+ end
+ end
+end
diff --git a/app/helpers/cms/sections_helper.rb b/app/helpers/cms/sections_helper.rb
new file mode 100644
index 0000000..13271f0
--- /dev/null
+++ b/app/helpers/cms/sections_helper.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Cms
+ module SectionsHelper
+ def cms_sections(kind:, site: nil)
+ target_site = site || (defined?(@site) ? @site : nil)
+ return Cms::Section.none unless target_site
+
+ target_site.sections.enabled.by_kind(kind).includes(:translations,
+ section_images: { image: { file_attachment: :blob } }).ordered
+ end
+
+ # Renders the partial registered for the section's kind.
+ # In non-production, renders an error notice for unknown kinds.
+ # In production, raises so the error is surfaced properly.
+ def render_section(section)
+ partial = Cms::Section::KindRegistry.partial_for(section.kind)
+ render partial, section: section
+ rescue Cms::Section::KindRegistry::UnknownKindError => e
+ raise e if Rails.env.production?
+
+ content_tag(:div, e.message, class: "cms-section--unknown", style: "color:red;padding:1em;border:1px solid red;")
+ end
+ end
+end
diff --git a/app/helpers/cms/sites_helper.rb b/app/helpers/cms/sites_helper.rb
new file mode 100644
index 0000000..e69efbf
--- /dev/null
+++ b/app/helpers/cms/sites_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Cms
+ module SitesHelper
+ def site_favicon_tag(site)
+ return unless site.logo.attached?
+
+ favicon_link_tag(cms_attachment_path(site.logo))
+ end
+ end
+end
diff --git a/app/javascript/cms/controllers/sortable_controller.js b/app/javascript/cms/controllers/sortable_controller.js
new file mode 100644
index 0000000..f4df8cc
--- /dev/null
+++ b/app/javascript/cms/controllers/sortable_controller.js
@@ -0,0 +1,38 @@
+import { Controller } from "@hotwired/stimulus"
+import Sortable from "sortablejs"
+
+export default class extends Controller {
+ static values = {
+ url: String,
+ handle: { type: String, default: ".cms-section-item__handle" }
+ }
+
+ connect() {
+ this.sortable = Sortable.create(this.element, {
+ handle: this.handleValue,
+ animation: 150,
+ onEnd: this.persistOrder.bind(this)
+ })
+ }
+
+ disconnect() {
+ this.sortable?.destroy()
+ }
+
+ persistOrder() {
+ const ids = Array.from(this.element.querySelectorAll("[data-page-section-id]"))
+ .map(el => el.dataset.pageSectionId)
+
+ const body = new URLSearchParams()
+ ids.forEach(id => body.append("page_section_ids[]", id))
+
+ fetch(this.urlValue, {
+ method: "PATCH",
+ headers: {
+ "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content,
+ "Content-Type": "application/x-www-form-urlencoded"
+ },
+ body: body.toString()
+ })
+ }
+}
diff --git a/app/jobs/cms/application_job.rb b/app/jobs/cms/application_job.rb
index 75c15bd..4b30a29 100644
--- a/app/jobs/cms/application_job.rb
+++ b/app/jobs/cms/application_job.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
module Cms
class ApplicationJob < ActiveJob::Base
end
diff --git a/app/jobs/cms/deliver_webhook_job.rb b/app/jobs/cms/deliver_webhook_job.rb
new file mode 100644
index 0000000..9580137
--- /dev/null
+++ b/app/jobs/cms/deliver_webhook_job.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require "net/http"
+require "openssl"
+
+module Cms
+ class DeliverWebhookJob < ApplicationJob
+ queue_as :default
+
+ def perform(webhook_id, event, payload)
+ webhook = Cms::Webhook.find_by(id: webhook_id)
+ return unless deliverable?(webhook, event)
+
+ response = build_http_client(webhook.url).request(build_request(webhook, event, payload))
+ record_delivery(webhook, event, response: response)
+ rescue StandardError => e
+ record_delivery(webhook, event, error: e) if webhook
+ end
+
+ private
+
+ def deliverable?(webhook, event)
+ webhook&.active? && webhook.events.include?(event)
+ end
+
+ def build_http_client(url)
+ uri = URI.parse(url)
+
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
+ http.use_ssl = uri.scheme == "https"
+ http.open_timeout = 5
+ http.read_timeout = 10
+ end
+ end
+
+ def build_request(webhook, event, payload)
+ body = request_body(payload, event)
+ uri = URI.parse(webhook.url)
+
+ Net::HTTP::Post.new(uri.request_uri).tap do |request|
+ request["Content-Type"] = "application/json"
+ request["X-CMS-Event"] = event
+ request["X-CMS-Signature"] = sign(body, webhook.secret) if webhook.secret.present?
+ request.body = body
+ end
+ end
+
+ def request_body(payload, event)
+ JSON.generate(payload.merge("event" => event, "timestamp" => Time.current.iso8601))
+ end
+
+ def record_delivery(webhook, event, response: nil, error: nil)
+ webhook.deliveries.create!(
+ event: event,
+ response_code: response&.code&.to_i,
+ response_body: response&.body.to_s.truncate(2000),
+ success: error.nil? && response&.code.to_i.between?(200, 299),
+ error_message: error&.message&.truncate(500)
+ )
+ end
+
+ def sign(body, secret)
+ "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
+ end
+ end
+end
diff --git a/app/mailers/cms/application_mailer.rb b/app/mailers/cms/application_mailer.rb
index 5e49e9a..9f074f5 100644
--- a/app/mailers/cms/application_mailer.rb
+++ b/app/mailers/cms/application_mailer.rb
@@ -1,6 +1,9 @@
+# frozen_string_literal: true
+
module Cms
class ApplicationMailer < ActionMailer::Base
- default from: "from@example.com"
+ default from: -> { Cms.config.mailer_from.presence || "noreply@example.com" }
layout "mailer"
+ helper Cms::ApplicationHelper
end
end
diff --git a/app/mailers/cms/form_submission_mailer.rb b/app/mailers/cms/form_submission_mailer.rb
new file mode 100644
index 0000000..95a6fbc
--- /dev/null
+++ b/app/mailers/cms/form_submission_mailer.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Cms
+ class FormSubmissionMailer < ApplicationMailer
+ def notify(submission, recipient_email)
+ @submission = submission
+ @page = submission.page
+ @fields = @page.form_fields.ordered
+
+ mail(
+ to: recipient_email,
+ subject: I18n.t("cms.mailers.form_submission.subject", page_title: @page.display_title)
+ )
+ end
+ end
+end
diff --git a/app/models/cms/api_key.rb b/app/models/cms/api_key.rb
new file mode 100644
index 0000000..5c729c5
--- /dev/null
+++ b/app/models/cms/api_key.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Cms
+ class ApiKey < ApplicationRecord
+ self.table_name = "cms_api_keys"
+
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :api_keys
+
+ validates :name, presence: true
+ validates :token, presence: true, uniqueness: true
+
+ before_validation :generate_token, on: :create
+
+ scope :active, -> { where(active: true) }
+ scope :ordered, -> { order(:name) }
+
+ def touch_last_used!
+ update_column(:last_used_at, Time.current)
+ end
+
+ private
+
+ def generate_token
+ self.token = SecureRandom.hex(32)
+ end
+ end
+end
diff --git a/app/models/cms/application_record.rb b/app/models/cms/application_record.rb
index 11b28f5..8aa9197 100644
--- a/app/models/cms/application_record.rb
+++ b/app/models/cms/application_record.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
module Cms
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
diff --git a/app/models/cms/document.rb b/app/models/cms/document.rb
new file mode 100644
index 0000000..206fde4
--- /dev/null
+++ b/app/models/cms/document.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Cms
+ class Document < ApplicationRecord
+ self.table_name = "cms_documents"
+
+ WORD_CONTENT_TYPES = [
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation"
+ ].freeze
+
+ PERMITTED_CONTENT_TYPES = [
+ "application/pdf",
+ "image/png",
+ "image/jpeg",
+ "image/tiff",
+ "text/plain",
+ "text/csv",
+ "application/zip",
+ "application/x-zip-compressed"
+ ] + WORD_CONTENT_TYPES
+
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :documents
+ has_one_attached :file
+
+ validates :title, :file, presence: true
+ validate :file_must_be_a_supported_document
+
+ scope :ordered, -> { order(:title) }
+
+ def display_title
+ title
+ end
+
+ private
+
+ def file_must_be_a_supported_document
+ return unless file.attached?
+ return if PERMITTED_CONTENT_TYPES.include?(file.blob.content_type)
+
+ errors.add(:file, I18n.t("cms.errors.document.invalid_file_type", default: "must be a supported document file"))
+ end
+ end
+end
diff --git a/app/models/cms/form_field.rb b/app/models/cms/form_field.rb
new file mode 100644
index 0000000..5f55fa4
--- /dev/null
+++ b/app/models/cms/form_field.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Cms
+ class FormField < ApplicationRecord
+ self.table_name = "cms_form_fields"
+
+ KINDS = %w[text email textarea select checkbox].freeze
+
+ belongs_to :page, class_name: "Cms::Page", inverse_of: :form_fields
+
+ validates :kind, presence: true, inclusion: { in: KINDS }
+ validates :label, :field_name, presence: true
+ validates :field_name, uniqueness: { scope: :page_id },
+ format: { with: /\A[a-z0-9_]+\z/,
+ message: ->(*) { I18n.t("cms.errors.form_field.invalid_field_name") } }
+
+ scope :ordered, -> { order(:position, :id) }
+
+ def select?
+ kind == "select"
+ end
+
+ def parsed_options
+ Array(options)
+ end
+ end
+end
diff --git a/app/models/cms/form_submission.rb b/app/models/cms/form_submission.rb
new file mode 100644
index 0000000..70a6f2d
--- /dev/null
+++ b/app/models/cms/form_submission.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Cms
+ class FormSubmission < ApplicationRecord
+ self.table_name = "cms_form_submissions"
+
+ belongs_to :page, class_name: "Cms::Page", inverse_of: :form_submissions
+
+ validates :data, presence: true
+ validate :contains_response_data
+ validate :required_fields_present
+
+ scope :recent, -> { order(created_at: :desc) }
+
+ def self.to_csv(submissions, fields)
+ require "csv"
+
+ CSV.generate(headers: true) do |csv|
+ csv << ([I18n.t("cms.admin.form_submissions.index.submitted_at")] + fields.map(&:label))
+ submissions.each do |sub|
+ csv << ([I18n.l(sub.created_at, format: :cms_csv_datetime)] + fields.map { |f| sub.data[f.field_name] })
+ end
+ end
+ end
+
+ private
+
+ def contains_response_data
+ values = data.to_h.values.reject { |value| value.to_s.in?(%w[0]) }
+ errors.add(:base, I18n.t("cms.errors.form_submission.blank")) if values.all?(&:blank?)
+ end
+
+ def required_fields_present
+ page.form_fields.ordered.select(&:required?).each do |field|
+ value = data.to_h[field.field_name]
+ next unless value.to_s.blank? || value.to_s == "0"
+
+ errors.add(field.field_name.to_sym, I18n.t("cms.errors.form_submission.required", label: field.label))
+ end
+ end
+ end
+end
diff --git a/app/models/cms/image.rb b/app/models/cms/image.rb
new file mode 100644
index 0000000..6695cd3
--- /dev/null
+++ b/app/models/cms/image.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Cms
+ class Image < ApplicationRecord
+ self.table_name = "cms_images"
+
+ PERMITTED_CONTENT_TYPES = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+ "image/svg+xml",
+ "image/tiff"
+ ].freeze
+
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :images
+ has_one_attached :file
+ has_many :section_images,
+ class_name: "Cms::SectionImage",
+ foreign_key: :image_id,
+ inverse_of: :image,
+ dependent: :destroy
+
+ has_many :image_translations,
+ class_name: "Cms::ImageTranslation",
+ foreign_key: :image_id,
+ inverse_of: :image,
+ dependent: :destroy
+
+ accepts_nested_attributes_for :image_translations
+
+ has_one :localised, -> { where(locale: I18n.locale.to_s) },
+ class_name: "Cms::ImageTranslation",
+ foreign_key: :image_id,
+ inverse_of: :image
+
+ delegate :alt_text, :caption, to: :localised, allow_nil: true
+
+ validates :title, :file, presence: true
+ validate :file_must_be_an_image
+
+ scope :ordered, -> { order(created_at: :desc) }
+
+ def display_title
+ title.presence || file.filename.to_s
+ end
+
+ def variant(dimensions)
+ file.variant(resize_to_limit: dimensions.split("x").map(&:to_i))
+ end
+
+ private
+
+ def file_must_be_an_image
+ return unless file.attached?
+ return if PERMITTED_CONTENT_TYPES.include?(file.blob.content_type)
+
+ errors.add(:file, I18n.t("cms.errors.image.invalid_file_type", default: "must be a valid image file"))
+ end
+ end
+end
diff --git a/app/models/cms/image_translation.rb b/app/models/cms/image_translation.rb
new file mode 100644
index 0000000..2af5da7
--- /dev/null
+++ b/app/models/cms/image_translation.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Cms
+ class ImageTranslation < ApplicationRecord
+ self.table_name = "cms_image_translations"
+
+ belongs_to :image,
+ class_name: "Cms::Image",
+ inverse_of: :image_translations
+
+ validates :locale, :alt_text, presence: true
+ validates :locale, uniqueness: { scope: :image_id }
+
+ before_validation :normalize_locale
+
+ private
+
+ def normalize_locale
+ self.locale = locale.to_s.downcase.presence
+ end
+ end
+end
diff --git a/app/models/cms/page.rb b/app/models/cms/page.rb
new file mode 100644
index 0000000..89cde67
--- /dev/null
+++ b/app/models/cms/page.rb
@@ -0,0 +1,228 @@
+# frozen_string_literal: true
+
+module Cms
+ class Page < ApplicationRecord
+ include ::Discard::Model
+
+ self.table_name = "cms_pages"
+
+ MAX_DEPTH = 10
+
+ module TemplateRegistry
+ class << self
+ def register(key)
+ normalized = key.to_s.parameterize.underscore
+ return if normalized.blank?
+
+ registered[normalized] = true
+ end
+
+ def registered_keys
+ registered.keys
+ end
+
+ private
+
+ def registered
+ @registered ||= begin
+ defaults = %w[standard landing form custom]
+ defaults.index_with { true }
+ end
+ end
+ end
+ end
+
+ has_one_attached :hero_image
+ has_many_attached :media_files
+
+ belongs_to :site,
+ class_name: "Cms::Site",
+ inverse_of: :pages
+
+ belongs_to :parent,
+ class_name: "Cms::Page",
+ optional: true,
+ inverse_of: :subpages
+
+ has_many :subpages,
+ class_name: "Cms::Page",
+ foreign_key: :parent_id,
+ dependent: :nullify,
+ inverse_of: :parent
+
+ has_many :page_translations,
+ class_name: "Cms::PageTranslation",
+ foreign_key: :page_id,
+ inverse_of: :page,
+ dependent: :destroy
+
+ accepts_nested_attributes_for :page_translations
+
+ has_one :localised, -> { where(locale: I18n.locale.to_s) },
+ class_name: "Cms::PageTranslation",
+ foreign_key: :page_id,
+ inverse_of: :page
+
+ delegate :title, :seo_title, :seo_description,
+ to: :localised, allow_nil: true
+
+ has_many :page_sections,
+ class_name: "Cms::PageSection",
+ foreign_key: :page_id,
+ inverse_of: :page,
+ dependent: :destroy
+
+ has_many :sections,
+ through: :page_sections,
+ source: :section
+
+ has_many :form_fields,
+ class_name: "Cms::FormField",
+ foreign_key: :page_id,
+ inverse_of: :page,
+ dependent: :destroy
+
+ has_many :form_submissions,
+ class_name: "Cms::FormSubmission",
+ foreign_key: :page_id,
+ inverse_of: :page,
+ dependent: :destroy
+
+ enum :status,
+ {
+ draft: "draft",
+ published: "published",
+ archived: "archived"
+ },
+ prefix: true
+
+ validates :slug, presence: true
+ validates :slug, uniqueness: { scope: :site_id }
+ validates :template_key, presence: true, inclusion: { in: ->(_) { template_keys } }
+ validates :depth, numericality: { less_than_or_equal_to: MAX_DEPTH }
+ validate :published_pages_require_sections
+
+ before_create :generate_preview_token
+ before_validation :set_slug
+ before_validation :normalize_home_slug
+ before_validation :set_defaults
+ before_validation :compute_depth
+ before_save :ensure_single_home_page, if: -> { home? && will_save_change_to_home? }
+ after_commit :fire_webhooks_on_status_change, on: %i[create update]
+ after_save :update_descendant_depths, if: :saved_change_to_parent_id?
+
+ scope :ordered, -> { order(:position, :id) }
+ scope :root, -> { where(parent_id: nil) }
+ scope :published, -> { where(status: "published") }
+ scope :header_nav, -> { where(show_in_header: true).where.not(nav_group: "none").order(:nav_order, :position, :id) }
+ scope :footer_nav, lambda {
+ where(show_in_footer: true).where.not(nav_group: "none").order(:footer_order, :position, :id)
+ }
+ scope :search, lambda { |q|
+ joins(:page_translations)
+ .where(
+ "lower(unaccent(cms_page_translations.title)) ilike lower(unaccent(?)) OR " \
+ "lower(unaccent(cms_pages.slug)) ilike lower(unaccent(?))",
+ "%#{q}%", "%#{q}%"
+ ).distinct
+ }
+ scope :search_ordered, ->(q) { search(q).ordered }
+
+ def self.template_keys
+ TemplateRegistry.registered_keys
+ end
+
+ def display_title
+ title.presence || slug.to_s.humanize
+ end
+
+ def display_meta_title
+ seo_title.presence || display_title
+ end
+
+ def ancestors
+ parent ? parent.ancestors + [parent] : []
+ end
+
+ def descendants
+ subpages.flat_map { |p| [p] + p.descendants }
+ end
+
+ def ordered_page_sections
+ page_sections.ordered.includes(:section)
+ end
+
+ def public_path_segments
+ return [] if home?
+
+ (ancestors + [self]).reject(&:home?).map(&:slug)
+ end
+
+ def public_path
+ public_path_segments.join("/")
+ end
+
+ private
+
+ def compute_depth
+ self.depth = parent ? (parent.depth + 1) : 0
+ end
+
+ def update_descendant_depths(parent_depth = depth)
+ subpages.find_each do |child|
+ child.update_column(:depth, parent_depth + 1)
+ child.update_descendant_depths(parent_depth + 1)
+ end
+ end
+
+ def fire_webhooks_on_status_change
+ return unless saved_change_to_status?
+
+ event = case status
+ when "published" then "page.published"
+ when "archived" then "page.unpublished"
+ end
+ return if event.blank?
+
+ site.webhooks.active.each do |webhook|
+ Cms::DeliverWebhookJob.perform_later(webhook.id, event, { "page_id" => id, "slug" => slug })
+ end
+ rescue StandardError
+ # Webhook delivery failures must never break page saves
+ end
+
+ def set_slug
+ default_locale = site.default_locale.presence || I18n.default_locale.to_s
+ translation_title = page_translations.find { |t| t.locale == default_locale }&.title if page_translations.loaded?
+
+ self.slug = slug.to_s.parameterize if slug.present?
+ self.slug = translation_title.to_s.parameterize if slug.blank? && translation_title.present?
+ end
+
+ def normalize_home_slug
+ self.slug = "home" if home?
+ end
+
+ def set_defaults
+ self.template_key = "standard" if template_key.blank?
+ self.nav_group = "main" if nav_group.blank?
+ end
+
+ def generate_preview_token
+ self.preview_token ||= SecureRandom.urlsafe_base64(32)
+ end
+
+ def ensure_single_home_page
+ site.pages.where.not(id: id).where(home: true).find_each do |page|
+ page.update!(home: false)
+ end
+ end
+
+ def published_pages_require_sections
+ return unless status_published?
+ return if page_sections.loaded? ? page_sections.reject(&:marked_for_destruction?).any? : page_sections.exists?
+
+ errors.add(:base, I18n.t("cms.errors.page.sections_required"))
+ end
+ end
+end
diff --git a/app/models/cms/page_section.rb b/app/models/cms/page_section.rb
new file mode 100644
index 0000000..5eee826
--- /dev/null
+++ b/app/models/cms/page_section.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Cms
+ class PageSection < ApplicationRecord
+ self.table_name = "cms_page_sections"
+
+ belongs_to :page, class_name: "Cms::Page", inverse_of: :page_sections
+ belongs_to :section, class_name: "Cms::Section", inverse_of: :page_sections
+
+ validates :section_id, uniqueness: { scope: :page_id }
+ validate :section_site_matches_page
+ validate :global_sections_cannot_become_orphaned, on: :destroy
+
+ scope :ordered, -> { order(:position, :id) }
+
+ after_destroy :destroy_orphaned_section
+
+ private
+
+ def section_site_matches_page
+ return unless page && section
+ return if page.site_id == section.site_id
+
+ errors.add(:section, :invalid)
+ end
+
+ def global_sections_cannot_become_orphaned
+ return unless section&.global?
+ return if section.page_sections.where.not(id: id).exists?
+
+ errors.add(:base, I18n.t("cms.errors.section.global_section_requires_attachment"))
+ throw :abort
+ end
+
+ def destroy_orphaned_section
+ return unless Cms.config.auto_destroy_orphaned_sections
+ return if section.global?
+ return if section.page_sections.exists?
+
+ section.destroy
+ end
+ end
+end
diff --git a/app/models/cms/page_translation.rb b/app/models/cms/page_translation.rb
new file mode 100644
index 0000000..03ba087
--- /dev/null
+++ b/app/models/cms/page_translation.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Cms
+ class PageTranslation < ApplicationRecord
+ self.table_name = "cms_page_translations"
+
+ belongs_to :page,
+ class_name: "Cms::Page",
+ inverse_of: :page_translations
+
+ validates :locale, :title, presence: true
+ validates :locale, uniqueness: { scope: :page_id }
+
+ before_validation :normalize_locale
+
+ private
+
+ def normalize_locale
+ self.locale = locale.to_s.downcase.presence
+ end
+ end
+end
diff --git a/app/models/cms/section.rb b/app/models/cms/section.rb
new file mode 100644
index 0000000..7065078
--- /dev/null
+++ b/app/models/cms/section.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Cms
+ class Section < ApplicationRecord
+ include ::Discard::Model
+
+ self.table_name = "cms_sections"
+
+ attribute :settings, default: -> { {} }
+ store_accessor :settings, :background_color, :cta_url, :button_url, :alignment
+
+ belongs_to :site,
+ class_name: "Cms::Site",
+ inverse_of: :sections
+
+ has_many :page_sections,
+ class_name: "Cms::PageSection",
+ foreign_key: :section_id,
+ inverse_of: :section,
+ dependent: :destroy
+
+ has_many :pages,
+ through: :page_sections,
+ source: :page
+
+ has_many :translations,
+ class_name: "Cms::SectionTranslation",
+ foreign_key: :section_id,
+ inverse_of: :section,
+ dependent: :destroy
+
+ has_one :localised, -> { where(locale: I18n.locale.to_s) },
+ class_name: "Cms::SectionTranslation",
+ foreign_key: :section_id,
+ inverse_of: :section
+
+ has_many :section_images,
+ -> { order(:position) },
+ class_name: "Cms::SectionImage",
+ foreign_key: :section_id,
+ inverse_of: :section,
+ dependent: :destroy
+
+ has_many :images,
+ through: :section_images,
+ source: :image,
+ class_name: "Cms::Image"
+
+ accepts_nested_attributes_for :translations
+
+ validates :kind, presence: true, inclusion: { in: -> { Cms::Section::KindRegistry.registered_kinds } }
+ validates :alignment, inclusion: { in: %w[left center right] }, allow_blank: true
+
+ scope :ordered, -> { order(:kind, :id) }
+ scope :enabled, -> { where(enabled: true) }
+ scope :global, -> { where(global: true) }
+ scope :local, -> { where(global: false) }
+ scope :by_kind, ->(kind) { where(kind: kind) }
+
+ delegate :title, :subtitle, :content, to: :localised, allow_nil: true
+
+ def cta_text = localised&.subtitle
+ def button_text = localised&.subtitle
+
+ def build_missing_locale_translations
+ present = if translations.loaded?
+ translations.target.map(&:locale)
+ elsif new_record?
+ []
+ else
+ translations.pluck(:locale)
+ end
+ I18n.available_locales.map(&:to_s).each do |locale|
+ translations.build(locale: locale) unless present.include?(locale)
+ end
+ end
+
+ def image_assets
+ images.includes(file_attachment: :blob)
+ end
+
+ def available_locales
+ translations.map(&:locale)
+ end
+
+ def translation_for(locale)
+ translations.find { |t| t.locale == locale.to_s }
+ end
+
+ def settings
+ super || {}
+ end
+ end
+end
diff --git a/app/models/cms/section/block_base.rb b/app/models/cms/section/block_base.rb
new file mode 100644
index 0000000..0295fc4
--- /dev/null
+++ b/app/models/cms/section/block_base.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Cms
+ class Section
+ class BlockBase
+ # Each subclass gets its own independent schema array
+ def self.inherited(subclass)
+ super
+ subclass.instance_variable_set(:@_settings_schema, [])
+ end
+
+ def self.settings_field(name, type:, required: false, default: nil, options: nil)
+ @_settings_schema ||= []
+ @_settings_schema << {
+ name: name.to_s,
+ type: type,
+ required: required,
+ default: default,
+ options: options
+ }.compact
+ end
+
+ def self.settings_schema
+ @_settings_schema || []
+ end
+
+ def self.kind
+ raise NotImplementedError, "#{self} must define .kind"
+ end
+ end
+ end
+end
diff --git a/app/models/cms/section/blocks/call_to_action_block.rb b/app/models/cms/section/blocks/call_to_action_block.rb
new file mode 100644
index 0000000..e70f283
--- /dev/null
+++ b/app/models/cms/section/blocks/call_to_action_block.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Cms
+ class Section
+ module Blocks
+ class CallToActionBlock < BlockBase
+ def self.kind = "cta"
+
+ settings_field :button_text, type: :string, required: true
+ settings_field :button_url, type: :url, required: true
+ settings_field :alignment, type: :select, default: "center",
+ options: %w[left center right]
+ end
+ end
+ end
+end
diff --git a/app/models/cms/section/blocks/hero_block.rb b/app/models/cms/section/blocks/hero_block.rb
new file mode 100644
index 0000000..39d0596
--- /dev/null
+++ b/app/models/cms/section/blocks/hero_block.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Cms
+ class Section
+ module Blocks
+ class HeroBlock < BlockBase
+ def self.kind = "hero"
+
+ settings_field :background_color, type: :color, default: "#ffffff"
+ settings_field :cta_url, type: :url
+ end
+ end
+ end
+end
diff --git a/app/models/cms/section/blocks/image_block.rb b/app/models/cms/section/blocks/image_block.rb
new file mode 100644
index 0000000..376209b
--- /dev/null
+++ b/app/models/cms/section/blocks/image_block.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Cms
+ class Section
+ module Blocks
+ class ImageBlock < BlockBase
+ def self.kind = "image"
+
+ settings_field :image_ids, type: :array
+ end
+ end
+ end
+end
diff --git a/app/models/cms/section/blocks/rich_text_block.rb b/app/models/cms/section/blocks/rich_text_block.rb
new file mode 100644
index 0000000..63a709f
--- /dev/null
+++ b/app/models/cms/section/blocks/rich_text_block.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Cms
+ class Section
+ module Blocks
+ class RichTextBlock < BlockBase
+ def self.kind = "rich_text"
+ # No settings — title and content come from SectionTranslation
+ end
+ end
+ end
+end
diff --git a/app/models/cms/section/kind_registry.rb b/app/models/cms/section/kind_registry.rb
new file mode 100644
index 0000000..150c7d3
--- /dev/null
+++ b/app/models/cms/section/kind_registry.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module Cms
+ class Section
+ module KindRegistry
+ BUILT_IN_KINDS = %w[rich_text image hero cta].freeze
+
+ class UnknownKindError < StandardError; end
+
+ @registry = {}
+
+ class << self
+ # Register a section kind.
+ #
+ # @param kind [String, Symbol]
+ # @param partial [String, nil] override the default partial path
+ # @param block_class [Class, nil] block class that defines settings_schema
+ def register(kind, partial: nil, block_class: nil)
+ @registry[kind.to_s] = {
+ partial: partial.presence || "cms/sections/kinds/#{kind}",
+ block_class: block_class
+ }
+ end
+
+ # @param kind [String]
+ # @return [String] partial path
+ # @raise [Cms::Section::KindRegistry::UnknownKindError]
+ def partial_for(kind)
+ entry_for(kind)[:partial]
+ end
+
+ # @param kind [String]
+ # @return [Class, nil] block class or nil if not set
+ def block_class_for(kind)
+ entry_for(kind)[:block_class]
+ end
+
+ # @return [Array]
+ def registered_kinds
+ @registry.keys
+ end
+
+ # @param kind [String, Symbol]
+ # @return [Boolean]
+ def registered?(kind)
+ @registry.key?(kind.to_s)
+ end
+
+ # For testing — resets to an empty registry
+ def reset!
+ @registry = {}
+ end
+
+ private
+
+ def entry_for(kind)
+ @registry.fetch(kind.to_s) do
+ raise UnknownKindError,
+ "No renderer registered for section kind: #{kind.inspect}. " \
+ "Registered kinds: #{@registry.keys.inspect}"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/cms/section_image.rb b/app/models/cms/section_image.rb
new file mode 100644
index 0000000..4f05c91
--- /dev/null
+++ b/app/models/cms/section_image.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Cms
+ class SectionImage < ApplicationRecord
+ self.table_name = "cms_section_images"
+
+ belongs_to :section, class_name: "Cms::Section", inverse_of: :section_images
+ belongs_to :image, class_name: "Cms::Image"
+ end
+end
diff --git a/app/models/cms/section_translation.rb b/app/models/cms/section_translation.rb
new file mode 100644
index 0000000..dcaf5f7
--- /dev/null
+++ b/app/models/cms/section_translation.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Cms
+ class SectionTranslation < ApplicationRecord
+ self.table_name = "cms_section_translations"
+
+ has_rich_text :content
+
+ belongs_to :section,
+ class_name: "Cms::Section",
+ inverse_of: :translations
+
+ validates :locale, :title, presence: true
+ validates :locale, uniqueness: { scope: :section_id }
+ validates :content, presence: true, if: -> { section&.kind.in?(%w[rich_text hero cta]) }
+
+ before_validation :normalize_locale
+
+ private
+
+ def normalize_locale
+ self.locale = locale.to_s.downcase.presence
+ end
+ end
+end
diff --git a/app/models/cms/site.rb b/app/models/cms/site.rb
new file mode 100644
index 0000000..84cd4f6
--- /dev/null
+++ b/app/models/cms/site.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Cms
+ class Site < ApplicationRecord
+ self.table_name = "cms_sites"
+
+ has_one_attached :logo
+
+ has_many :pages,
+ class_name: "Cms::Page",
+ foreign_key: :site_id,
+ inverse_of: :site,
+ dependent: :destroy
+
+ has_many :images,
+ class_name: "Cms::Image",
+ foreign_key: :site_id,
+ inverse_of: :site,
+ dependent: :destroy
+
+ has_many :documents,
+ class_name: "Cms::Document",
+ foreign_key: :site_id,
+ inverse_of: :site,
+ dependent: :destroy
+
+ has_many :sections,
+ class_name: "Cms::Section",
+ foreign_key: :site_id,
+ inverse_of: :site,
+ dependent: :destroy
+
+ has_many :api_keys,
+ class_name: "Cms::ApiKey",
+ foreign_key: :site_id,
+ inverse_of: :site,
+ dependent: :destroy
+
+ has_many :webhooks,
+ class_name: "Cms::Webhook",
+ foreign_key: :site_id,
+ inverse_of: :site,
+ dependent: :destroy
+
+ validates :name, :slug, :default_locale, presence: true
+ validates :slug, uniqueness: true
+ validate :default_locale_is_available
+
+ before_validation :parameterize_slug
+ before_validation :normalize_locales
+
+ scope :live, -> { where(published: true) }
+
+ def published_pages
+ pages.kept.published.ordered
+ end
+
+ def header_pages
+ published_pages.header_nav
+ end
+
+ def footer_pages
+ published_pages.footer_nav
+ end
+
+ def home_page
+ published_pages.find_by(home: true) || published_pages.first
+ end
+
+ private
+
+ def parameterize_slug
+ self.slug = slug.to_s.parameterize if slug.present?
+ end
+
+ def normalize_locales
+ self[:default_locale] = self[:default_locale].to_s.presence || I18n.default_locale.to_s
+ end
+
+ def default_locale_is_available
+ supported = I18n.available_locales.map(&:to_s)
+ return if supported.include?(self[:default_locale].to_s)
+
+ errors.add(:default_locale, I18n.t("cms.errors.site.default_locale_unavailable"))
+ end
+ end
+end
diff --git a/app/models/cms/webhook.rb b/app/models/cms/webhook.rb
new file mode 100644
index 0000000..eb12990
--- /dev/null
+++ b/app/models/cms/webhook.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Cms
+ class Webhook < ApplicationRecord
+ self.table_name = "cms_webhooks"
+
+ EVENTS = %w[page.published page.unpublished].freeze
+
+ belongs_to :site, class_name: "Cms::Site", inverse_of: :webhooks
+
+ has_many :deliveries,
+ class_name: "Cms::WebhookDelivery",
+ foreign_key: :webhook_id,
+ inverse_of: :webhook,
+ dependent: :destroy
+
+ validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
+ validates :events, presence: true
+ validate :events_must_be_supported
+
+ scope :active, -> { where(active: true) }
+ scope :ordered, -> { order(:url) }
+
+ before_validation :normalize_events
+
+ private
+
+ def normalize_events
+ self.events = Array(events).map(&:to_s).reject(&:blank?).uniq
+ end
+
+ def events_must_be_supported
+ return if events.blank?
+
+ invalid_events = events - EVENTS
+ return if invalid_events.empty?
+
+ errors.add(:events, I18n.t("cms.errors.webhook.unsupported_events", events: invalid_events.join(", ")))
+ end
+ end
+end
diff --git a/app/models/cms/webhook_delivery.rb b/app/models/cms/webhook_delivery.rb
new file mode 100644
index 0000000..c4b92b1
--- /dev/null
+++ b/app/models/cms/webhook_delivery.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Cms
+ class WebhookDelivery < ApplicationRecord
+ self.table_name = "cms_webhook_deliveries"
+
+ belongs_to :webhook, class_name: "Cms::Webhook", inverse_of: :deliveries
+
+ scope :ordered, -> { order(delivered_at: :desc) }
+ scope :recent, -> { ordered.limit(50) }
+ end
+end
diff --git a/app/serializers/cms/api/base_serializer.rb b/app/serializers/cms/api/base_serializer.rb
new file mode 100644
index 0000000..a1841ca
--- /dev/null
+++ b/app/serializers/cms/api/base_serializer.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Cms
+ module Api
+ class BaseSerializer
+ def initialize(site:, requested_locale:, main_app:)
+ @site = site
+ @requested_locale = requested_locale
+ @main_app = main_app
+ end
+
+ private
+
+ attr_reader :site, :requested_locale, :main_app
+
+ def resolved_content_locale(available_locales)
+ Cms::LocaleResolver.resolve(
+ requested: requested_locale,
+ site: site,
+ available: available_locales
+ )
+ end
+
+ def attachment_path(attachment)
+ return nil unless attachment.attached?
+
+ main_app.rails_blob_path(attachment, only_path: true)
+ end
+
+ def serialize_media(file)
+ {
+ filename: file.filename.to_s,
+ url: attachment_path(file)
+ }
+ end
+
+ def site_attributes(resolved_locale:)
+ {
+ id: site.id,
+ name: site.name,
+ slug: site.slug,
+ default_locale: site.default_locale,
+ resolved_locale: resolved_locale,
+ available_locales: I18n.available_locales.map(&:to_s),
+ logo_url: attachment_path(site.logo),
+ favicon_url: attachment_path(site.logo)
+ }
+ end
+ end
+ end
+end
diff --git a/app/serializers/cms/api/page_serializer.rb b/app/serializers/cms/api/page_serializer.rb
new file mode 100644
index 0000000..c0200ee
--- /dev/null
+++ b/app/serializers/cms/api/page_serializer.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+module Cms
+ module Api
+ class PageSerializer < BaseSerializer
+ def initialize(site:, page:, requested_locale:, main_app:)
+ super(site: site, requested_locale: requested_locale, main_app: main_app)
+ @page = page
+ end
+
+ def as_json(include_site: false)
+ payload = serialize_page
+ return payload unless include_site
+
+ payload.merge(
+ site: Cms.api_site_serializer_class.new(
+ site: site,
+ requested_locale: payload[:resolved_locale],
+ main_app: main_app
+ ).as_json(include_pages: false)
+ )
+ end
+
+ private
+
+ attr_reader :page
+
+ def serialize_page
+ locale = resolved_content_locale(page.page_translations.map(&:locale))
+ translation = page.page_translations.find { |record| record.locale == locale }
+
+ base_page_attributes(locale, translation).merge(
+ meta_title: page_meta_title(translation),
+ meta_description: translation&.seo_description,
+ media_files: page.media_files.map { |file| serialize_media(file) },
+ sections: serialize_page_sections(locale)
+ )
+ end
+
+ def serialize_page_sections(locale)
+ page.page_sections.ordered.includes(:section).filter_map do |page_section|
+ section = page_section.section
+ next unless section.enabled?
+
+ serialize_section(section, position: page_section.position, requested_locale: locale)
+ end
+ end
+
+ def serialize_section(section, position:, requested_locale:)
+ locale = resolved_content_locale_for_section(section, requested_locale)
+ translation = section.translation_for(locale)
+
+ {
+ id: section.id,
+ kind: section.kind,
+ position: position,
+ resolved_locale: locale,
+ available_locales: section.available_locales,
+ title: translation.respond_to?(:title) ? translation.title : nil,
+ body: translation.respond_to?(:content) ? translation.content.to_s : "",
+ settings: {},
+ data: serialize_section_data(section, requested_locale: locale)
+ }
+ end
+
+ def resolved_content_locale_for_section(section, requested_locale)
+ available_locales = section.available_locales
+ if available_locales.empty? && section.kind == "image"
+ available_locales = section.images.flat_map { |image| image.image_translations.map(&:locale) }.uniq
+ end
+
+ Cms::LocaleResolver.resolve(
+ requested: requested_locale,
+ site: site,
+ available: available_locales
+ )
+ end
+
+ def serialize_section_data(section, requested_locale:)
+ case section.kind
+ when "hero"
+ {
+ background_color: section.background_color,
+ cta_text: section.translation_for(requested_locale)&.subtitle,
+ cta_url: section.cta_url
+ }
+ when "cta"
+ {
+ button_text: section.translation_for(requested_locale)&.subtitle,
+ button_url: section.button_url,
+ alignment: section.alignment.presence || "center"
+ }
+ when "image"
+ image_payloads = serialize_section_images(section, requested_locale)
+ {
+ images: image_payloads
+ }
+ else
+ {}
+ end
+ end
+
+ def serialize_section_images(section, requested_locale)
+ section.image_assets.filter_map do |image|
+ locale = Cms::LocaleResolver.resolve(
+ requested: requested_locale,
+ site: site,
+ available: image.image_translations.map(&:locale)
+ )
+ translation = image.image_translations.find { |record| record.locale == locale }
+
+ {
+ id: image.id,
+ title: image.title,
+ alt_text: translation&.alt_text,
+ caption: translation&.caption,
+ url: attachment_path(image.file)
+ }
+ end
+ end
+
+ def base_page_attributes(locale, translation)
+ {
+ id: page.id,
+ template_key: page.template_key,
+ status: page.status,
+ resolved_locale: locale,
+ available_locales: page.page_translations.map(&:locale),
+ title: translation_title(translation),
+ slug: page.slug,
+ path: page.public_path,
+ hero_image_url: attachment_path(page.hero_image)
+ }
+ end
+
+ def translation_title(translation)
+ translation&.title.presence || page.slug.to_s.humanize
+ end
+
+ def page_meta_title(translation)
+ translation&.seo_title.presence || translation_title(translation)
+ end
+ end
+ end
+end
diff --git a/app/serializers/cms/api/site_serializer.rb b/app/serializers/cms/api/site_serializer.rb
new file mode 100644
index 0000000..f9be791
--- /dev/null
+++ b/app/serializers/cms/api/site_serializer.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Cms
+ module Api
+ class SiteSerializer < BaseSerializer
+ def as_json(include_pages: true)
+ payload = site_attributes(resolved_locale: requested_locale)
+ return payload unless include_pages
+
+ payload.merge(
+ pages: published_pages.map do |page|
+ serialize_site_page(page)
+ end
+ )
+ end
+
+ private
+
+ def published_pages
+ site.published_pages.includes(:page_translations, { hero_image_attachment: :blob })
+ end
+
+ def serialize_site_page(page)
+ locale = resolved_content_locale(page.page_translations.map(&:locale))
+ translation = page.page_translations.find { |record| record.locale == locale }
+
+ {
+ id: page.id,
+ template_key: page.template_key,
+ status: page.status,
+ resolved_locale: locale,
+ available_locales: page.page_translations.map(&:locale),
+ title: translation&.title.presence || page.slug.to_s.humanize,
+ slug: page.slug,
+ path: page.public_path,
+ home: page.home,
+ nav_group: page.nav_group,
+ show_in_header: page.show_in_header,
+ show_in_footer: page.show_in_footer,
+ hero_image_url: attachment_path(page.hero_image)
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/cms/locale_resolver.rb b/app/services/cms/locale_resolver.rb
new file mode 100644
index 0000000..d281dc3
--- /dev/null
+++ b/app/services/cms/locale_resolver.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Cms
+ # Resolves the best available locale given a preference and a set of available locales.
+ #
+ # Fallback chain: requested → site default → I18n default → first available
+ class LocaleResolver
+ def self.resolve(requested:, site:, available:)
+ new(requested: requested, site: site, available: available).resolve
+ end
+
+ def initialize(requested:, site:, available:)
+ @requested = requested.to_s.presence
+ @site = site
+ @available = Array(available).map(&:to_s)
+ end
+
+ def resolve
+ return fallback_chain.first if @available.empty?
+
+ fallback_chain.find { |locale| @available.include?(locale) } || @available.first
+ end
+
+ private
+
+ def fallback_chain
+ [@requested, @site.default_locale, I18n.default_locale.to_s].compact.uniq
+ end
+ end
+end
diff --git a/app/services/cms/page_resolver.rb b/app/services/cms/page_resolver.rb
new file mode 100644
index 0000000..1be9339
--- /dev/null
+++ b/app/services/cms/page_resolver.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module Cms
+ class PageResolver
+ Result = Struct.new(:page, :locale)
+
+ # Finds a published page by slug within a site and resolves the best
+ # available locale using the fallback chain:
+ # requested locale → site.default_locale → I18n.default_locale → any
+ #
+ # @param site [Cms::Site]
+ # @param slug [String, nil] nil or blank resolves the home page; nested
+ # paths such as "about/history" resolve by ancestor chain
+ # @param locale [String, nil] the caller's preferred locale
+ # @return [Result, nil]
+ def self.resolve(site:, slug: nil, locale: nil)
+ new(site: site, slug: slug, locale: locale).resolve
+ end
+
+ def initialize(site:, slug:, locale:)
+ @site = site
+ @slug = slug.to_s
+ @locale = locale.to_s.presence
+ end
+
+ def resolve
+ page = find_page
+ return nil unless page
+
+ Result.new(page: page, locale: resolved_locale_for(page))
+ end
+
+ private
+
+ attr_reader :site, :slug, :locale
+
+ def normalized_segments
+ @normalized_segments ||= slug.to_s.split("/").filter_map do |segment|
+ normalized = segment.to_s.parameterize
+ normalized if normalized.present?
+ end
+ end
+
+ def find_page
+ scope = site.published_pages
+ .includes(
+ :page_translations,
+ :localised,
+ { page_sections: :section },
+ :form_fields,
+ { hero_image_attachment: :blob }
+ )
+
+ if normalized_segments.empty?
+ scope.find_by(home: true) || scope.first
+ else
+ page = scope.find_by(slug: normalized_segments.last)
+ return unless page
+ return page if page.public_path_segments == normalized_segments
+
+ nil
+ end
+ end
+
+ def resolved_locale_for(page)
+ Cms::LocaleResolver.resolve(
+ requested: @locale,
+ site: @site,
+ available: page.page_translations.map(&:locale)
+ )
+ end
+ end
+end
diff --git a/app/services/cms/public_page_context.rb b/app/services/cms/public_page_context.rb
new file mode 100644
index 0000000..179259a
--- /dev/null
+++ b/app/services/cms/public_page_context.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Cms
+ class PublicPageContext
+ attr_reader :site, :page, :header_nav_items, :footer_pages, :sections, :form_fields, :submission
+
+ def self.build(site:, page:)
+ new(site: site, page: page).tap(&:load)
+ end
+
+ def initialize(site:, page:)
+ @site = site
+ @page = page
+ end
+
+ def load
+ @header_nav_items = load_header_nav
+ @footer_pages = site.footer_pages.includes(:localised)
+ @sections = load_sections
+ @form_fields = page.form_fields.ordered.to_a
+ @submission = page.form_submissions.build
+ end
+
+ private
+
+ def load_sections
+ page.page_sections
+ .ordered
+ .includes(:section)
+ .filter_map do |page_section|
+ section = page_section.section
+ section if section.enabled? && section.kept?
+ end
+ end
+
+ def load_header_nav
+ site.published_pages
+ .root
+ .header_nav
+ .includes(:localised, subpages: :localised)
+ .map do |p|
+ {
+ page: p,
+ children: p.subpages.select { |subpage| subpage.status_published? && subpage.show_in_header? }
+ }
+ end
+ end
+ end
+end
diff --git a/app/views/cms/admin/api_keys/_form.html.erb b/app/views/cms/admin/api_keys/_form.html.erb
new file mode 100644
index 0000000..14ff8a6
--- /dev/null
+++ b/app/views/cms/admin/api_keys/_form.html.erb
@@ -0,0 +1,23 @@
+<%= form_with model: [:admin, api_key] do |f| %>
+ <% if api_key.errors.any? %>
+
+ <% api_key.errors.full_messages.each do |msg| %>
+
<%= msg %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= f.label :name %>
+ <%= f.text_field :name %>
+
+
+
+ <%= f.label :active %>
+ <%= f.check_box :active %>
+
+
+
+ <%= f.submit %>
+
+<% end %>
diff --git a/app/views/cms/admin/api_keys/create.html.erb b/app/views/cms/admin/api_keys/create.html.erb
new file mode 100644
index 0000000..52b388a
--- /dev/null
+++ b/app/views/cms/admin/api_keys/create.html.erb
@@ -0,0 +1,9 @@
+<%= t(".title") %>
+
+
+ <%= t(".token_warning") %>
+
+
+<%= @new_token %>
+
+<%= link_to t(".back"), admin_api_keys_path %>
diff --git a/app/views/cms/admin/api_keys/edit.html.erb b/app/views/cms/admin/api_keys/edit.html.erb
new file mode 100644
index 0000000..454c489
--- /dev/null
+++ b/app/views/cms/admin/api_keys/edit.html.erb
@@ -0,0 +1,5 @@
+<%= t(".title") %>
+
+<%= render "form", api_key: @api_key %>
+
+<%= link_to t(".back"), admin_api_keys_path %>
diff --git a/app/views/cms/admin/api_keys/index.html.erb b/app/views/cms/admin/api_keys/index.html.erb
new file mode 100644
index 0000000..f4960e9
--- /dev/null
+++ b/app/views/cms/admin/api_keys/index.html.erb
@@ -0,0 +1,36 @@
+<%= t(".title") %>
+
+<%= link_to t(".new"), new_admin_api_key_path %>
+
+<% if @api_keys.any? %>
+
+
+
+ | <%= t(".name") %> |
+ <%= t(".active") %> |
+ <%= t(".last_used") %> |
+ <%= t(".created") %> |
+ |
+
+
+
+ <% @api_keys.each do |key| %>
+
+ | <%= key.name %> |
+ <%= cms_yes_no(key.active?) %> |
+ <%= key.last_used_at ? cms_datetime(key.last_used_at) : t(".never") %> |
+ <%= cms_date(key.created_at) %> |
+
+ <%= link_to t(".edit"), edit_admin_api_key_path(key) %>
+ |
+ <%= link_to t(".delete"),
+ admin_api_key_path(key),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+ |
+
+ <% end %>
+
+
+<% else %>
+ <%= t(".empty") %>
+<% end %>
diff --git a/app/views/cms/admin/api_keys/new.html.erb b/app/views/cms/admin/api_keys/new.html.erb
new file mode 100644
index 0000000..454c489
--- /dev/null
+++ b/app/views/cms/admin/api_keys/new.html.erb
@@ -0,0 +1,5 @@
+<%= t(".title") %>
+
+<%= render "form", api_key: @api_key %>
+
+<%= link_to t(".back"), admin_api_keys_path %>
diff --git a/app/views/cms/admin/documents/_form.html.erb b/app/views/cms/admin/documents/_form.html.erb
new file mode 100644
index 0000000..d42b97e
--- /dev/null
+++ b/app/views/cms/admin/documents/_form.html.erb
@@ -0,0 +1,24 @@
+<%= form_with model: [:admin, document], local: true do |f| %>
+
+ <%= f.label :title %>
+ <%= f.text_field :title %>
+
+
+
+ <%= f.label :file, t(".file") %>
+ <%= f.file_field :file, accept: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip" %>
+ <% if document.file.attached? %>
+
<%= t(".current") %>: <%= link_to document.file.filename.to_s, url_for(document.file) %>
+ <% end %>
+
+
+
+ <%= f.label :description %>
+ <%= f.text_area :description, rows: 3 %>
+
+
+
+ <%= f.submit %>
+ <%= link_to t(".cancel"), admin_documents_path %>
+
+<% end %>
diff --git a/app/views/cms/admin/documents/edit.html.erb b/app/views/cms/admin/documents/edit.html.erb
new file mode 100644
index 0000000..7c7310b
--- /dev/null
+++ b/app/views/cms/admin/documents/edit.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title", title: @document.title) %>
+<%= render "form", document: @document %>
diff --git a/app/views/cms/admin/documents/index.html.erb b/app/views/cms/admin/documents/index.html.erb
new file mode 100644
index 0000000..7fc6d0d
--- /dev/null
+++ b/app/views/cms/admin/documents/index.html.erb
@@ -0,0 +1,37 @@
+<%= t(".title") %>
+
+<%= link_to t(".upload"), new_admin_document_path %>
+
+<% if @documents.empty? %>
+ <%= t(".empty") %>
+<% else %>
+
+
+
+ | <%= t(".title_heading") %> |
+ <%= t(".file") %> |
+ <%= t(".description") %> |
+ <%= t(".actions") %> |
+
+
+
+ <% @documents.each do |doc| %>
+
+ | <%= doc.title %> |
+
+ <% if doc.file.attached? %>
+ <%= link_to doc.file.filename.to_s, url_for(doc.file) %>
+ <% end %>
+ |
+ <%= doc.description %> |
+
+ <%= link_to t(".edit"), edit_admin_document_path(doc) %>
+ <%= link_to t(".delete"),
+ admin_document_path(doc),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+ |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/cms/admin/documents/new.html.erb b/app/views/cms/admin/documents/new.html.erb
new file mode 100644
index 0000000..1f1a1af
--- /dev/null
+++ b/app/views/cms/admin/documents/new.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title") %>
+<%= render "form", document: @document %>
diff --git a/app/views/cms/admin/form_fields/_form.html.erb b/app/views/cms/admin/form_fields/_form.html.erb
new file mode 100644
index 0000000..20b7cf0
--- /dev/null
+++ b/app/views/cms/admin/form_fields/_form.html.erb
@@ -0,0 +1,46 @@
+<%= form_with model: [:admin, @page, field], local: true do |f| %>
+
+ <%= f.label :label %>
+ <%= f.text_field :label %>
+
+
+
+ <%= f.label :field_name, t(".field_name") %>
+ <%= f.text_field :field_name %>
+
+
+
+ <%= f.label :kind %>
+ <%= f.select :kind, Cms::FormField::KINDS.map { |k| [cms_form_field_kind_label(k), k] } %>
+
+
+
+ <%= f.label :placeholder %>
+ <%= f.text_field :placeholder %>
+
+
+
+ <%= f.label :hint, t(".hint") %>
+ <%= f.text_field :hint %>
+
+
+
+ <%= f.label :required %>
+ <%= f.check_box :required %>
+
+
+
+ <%= f.label :options, t(".options") %>
+ <%= f.text_area :options, value: Array(field.options).join("\n"), rows: 5 %>
+
+
+
+ <%= f.label :position %>
+ <%= f.number_field :position %>
+
+
+
+ <%= f.submit %>
+ <%= link_to t(".cancel"), admin_page_form_fields_path(@page) %>
+
+<% end %>
diff --git a/app/views/cms/admin/form_fields/edit.html.erb b/app/views/cms/admin/form_fields/edit.html.erb
new file mode 100644
index 0000000..18c35e3
--- /dev/null
+++ b/app/views/cms/admin/form_fields/edit.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title", label: @field.label) %>
+<%= render "form", field: @field %>
diff --git a/app/views/cms/admin/form_fields/index.html.erb b/app/views/cms/admin/form_fields/index.html.erb
new file mode 100644
index 0000000..0488f39
--- /dev/null
+++ b/app/views/cms/admin/form_fields/index.html.erb
@@ -0,0 +1,41 @@
+<%= t(".title", page: @page.display_title) %>
+
+
+ <%= link_to t(".back"), admin_page_path(@page) %>
+ |
+ <%= link_to t(".submissions"), admin_page_form_submissions_path(@page) %>
+ |
+ <%= link_to t(".new"), new_admin_page_form_field_path(@page) %>
+
+
+<% if @fields.empty? %>
+ <%= t(".empty") %>
+<% else %>
+
+
+
+ | <%= t(".label") %> |
+ <%= t(".field_name") %> |
+ <%= t(".kind") %> |
+ <%= t(".required") %> |
+ <%= t(".actions") %> |
+
+
+
+ <% @fields.each do |field| %>
+
+ | <%= field.label %> |
+ <%= field.field_name %> |
+ <%= cms_form_field_kind_label(field.kind) %> |
+ <%= cms_yes_no(field.required?) %> |
+
+ <%= link_to t(".edit"), edit_admin_page_form_field_path(@page, field) %>
+ <%= link_to t(".delete"),
+ admin_page_form_field_path(@page, field),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+ |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/cms/admin/form_fields/new.html.erb b/app/views/cms/admin/form_fields/new.html.erb
new file mode 100644
index 0000000..26bada8
--- /dev/null
+++ b/app/views/cms/admin/form_fields/new.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title", page: @page.display_title) %>
+<%= render "form", field: @field %>
diff --git a/app/views/cms/admin/form_submissions/index.html.erb b/app/views/cms/admin/form_submissions/index.html.erb
new file mode 100644
index 0000000..5259422
--- /dev/null
+++ b/app/views/cms/admin/form_submissions/index.html.erb
@@ -0,0 +1,38 @@
+<%= t(".title", page: @page.display_title) %>
+
+
+ <%= link_to t(".back"), admin_page_form_fields_path(@page) %>
+ |
+ <%= link_to t(".export"), admin_page_form_submissions_path(@page, format: :csv) %>
+
+
+<% if @submissions.empty? %>
+ <%= t(".empty") %>
+<% else %>
+
+
+
+ | <%= t(".submitted_at") %> |
+ <% @fields.each do |field| %>
+ <%= field.label %> |
+ <% end %>
+ <%= t(".actions") %> |
+
+
+
+ <% @submissions.each do |sub| %>
+
+ | <%= cms_datetime(sub.created_at) %> |
+ <% @fields.each do |field| %>
+ <%= sub.data[field.field_name] %> |
+ <% end %>
+
+ <%= link_to t(".delete"),
+ admin_page_form_submission_path(@page, sub),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+ |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/cms/admin/images/_form.html.erb b/app/views/cms/admin/images/_form.html.erb
new file mode 100644
index 0000000..37c7ee8
--- /dev/null
+++ b/app/views/cms/admin/images/_form.html.erb
@@ -0,0 +1,36 @@
+<%= form_with model: [:admin, image], local: true do |f| %>
+ <% translation = image.image_translations.find { |record| record.locale == @translation_locale } || image.image_translations.build(locale: @translation_locale) %>
+
+ <%= f.label :file, t(".file") %>
+ <%= f.file_field :file, accept: "image/*" %>
+ <% if image.file.attached? %>
+
+ <%= image_tag image.file, alt: image.display_title, style: "max-width: 280px; height: auto;" %>
+ <% end %>
+
+
+
+ <%= f.label :title %>
+ <%= f.text_field :title %>
+
+
+ <%= f.fields_for :image_translations, translation do |tf| %>
+ <%= tf.hidden_field :id if tf.object.persisted? %>
+ <%= tf.hidden_field :locale, value: @translation_locale %>
+
+
+ <%= tf.label :alt_text, t(".alt_text") %>
+ <%= tf.text_field :alt_text %>
+
+
+
+ <%= tf.label :caption %>
+ <%= tf.text_field :caption %>
+
+ <% end %>
+
+
+ <%= f.submit %>
+ <%= link_to t(".cancel"), admin_images_path %>
+
+<% end %>
diff --git a/app/views/cms/admin/images/edit.html.erb b/app/views/cms/admin/images/edit.html.erb
new file mode 100644
index 0000000..b1ebab5
--- /dev/null
+++ b/app/views/cms/admin/images/edit.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title", title: @image.display_title) %>
+<%= render "form", image: @image %>
diff --git a/app/views/cms/admin/images/index.html.erb b/app/views/cms/admin/images/index.html.erb
new file mode 100644
index 0000000..733ad33
--- /dev/null
+++ b/app/views/cms/admin/images/index.html.erb
@@ -0,0 +1,25 @@
+<%= t(".title") %>
+
+<%= link_to t(".upload"), new_admin_image_path %>
+
+<% if @images.empty? %>
+ <%= t(".empty") %>
+<% else %>
+
+ <% @images.each do |image| %>
+
+ <% if image.file.attached? %>
+ <%= image_tag image.file, alt: image.alt_text.presence || image.display_title,
+ style: "max-width: 180px; height: 120px; object-fit: cover;" %>
+ <% end %>
+
<%= image.display_title %>
+
+ <%= link_to t(".edit"), edit_admin_image_path(image) %>
+ <%= link_to t(".delete"),
+ admin_image_path(image),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/cms/admin/images/new.html.erb b/app/views/cms/admin/images/new.html.erb
new file mode 100644
index 0000000..7c013ba
--- /dev/null
+++ b/app/views/cms/admin/images/new.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title") %>
+<%= render "form", image: @image %>
diff --git a/app/views/cms/admin/pages/_attach_section_panel.html.erb b/app/views/cms/admin/pages/_attach_section_panel.html.erb
new file mode 100644
index 0000000..fff1d3f
--- /dev/null
+++ b/app/views/cms/admin/pages/_attach_section_panel.html.erb
@@ -0,0 +1,20 @@
+
+ <% if available_sections.any? %>
+
+
+ <%= form_with url: admin_page_attach_section_path(page), method: :post, local: true do |f| %>
+
+ <%= f.label :section_id, t("cms.admin.pages.show.attach_existing") %>
+ <%= f.select :section_id,
+ available_sections.map do |section|
+ ["#{cms_section_kind_label(section.kind)}: #{section.title.presence || t("cms.admin.pages.show.no_title")}", section.id]
+ end,
+ include_blank: t("cms.admin.pages.show.select_global") %>
+
+
+
+ <%= f.submit t("cms.admin.pages.show.attach"), class: "cms-btn cms-btn--sm" %>
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/cms/admin/pages/_form.html.erb b/app/views/cms/admin/pages/_form.html.erb
new file mode 100644
index 0000000..14b3c5a
--- /dev/null
+++ b/app/views/cms/admin/pages/_form.html.erb
@@ -0,0 +1,116 @@
+<%= form_with model: [:admin, page] do |f| %>
+
+ <%= f.label :parent_id, t(".parent_page") %>
+ <%= f.select :parent_id, @parent_options, { include_blank: t(".no_parent") } %>
+
+
+
+ <%= f.label :slug %>
+ <%= f.text_field :slug %>
+
+
+
+ <%= f.label :template_key, t(".template") %>
+ <%= f.select :template_key, Cms::Page.template_keys.map { |key| [cms_page_template_label(key), key] } %>
+
+
+
+ <%= f.label :status %>
+ <%= f.select :status, Cms::Page.statuses.keys.map { |key| [cms_page_status_label(key), key] } %>
+
+
+
+ <%= f.label :position %>
+ <%= f.number_field :position %>
+
+
+
+ <%= f.label :home %>
+ <%= f.check_box :home %>
+
+
+
+ <%= f.label :show_in_header %>
+ <%= f.check_box :show_in_header %>
+
+
+
+ <%= f.label :show_in_footer %>
+ <%= f.check_box :show_in_footer %>
+
+
+
+ <%= f.label :nav_group %>
+ <%= f.text_field :nav_group %>
+
+
+
+ <%= f.label :nav_order %>
+ <%= f.number_field :nav_order %>
+
+
+
+ <%= f.label :footer_order %>
+ <%= f.number_field :footer_order %>
+
+
+
+
+ <%= f.fields_for :page_translations do |t| %>
+ <%= I18n.t("cms.admin.pages.form.translation", locale: t.object.locale) %>
+
+ <%= t.hidden_field :locale %>
+
+
+ <%= t.label :title, I18n.t("cms.admin.pages.form.translation_title") %>
+ <%= t.text_field :title %>
+
+
+
+ <%= t.label :seo_title, I18n.t("cms.admin.pages.form.seo_title") %>
+ <%= t.text_field :seo_title %>
+
+
+
+ <%= t.label :seo_description, I18n.t("cms.admin.pages.form.seo_description") %>
+ <%= t.text_area :seo_description, rows: 3 %>
+
+ <% end %>
+
+
+ <%= f.label :hero_image %>
+ <%= f.file_field :hero_image, accept: "image/*" %>
+ <% if page.hero_image.attached? %>
+
+ <%= image_tag cms_attachment_path(page.hero_image), alt: page.display_title, style: "max-width: 280px; height: auto;" %>
+
+
+ <% end %>
+
+
+
+ <%= f.label :media_files, t(".media_files") %>
+ <%= f.file_field :media_files, multiple: true, accept: "image/*,.pdf,.doc,.docx" %>
+ <% if page.media_files.attached? %>
+
+ <%= t(".current_files") %>:
+
+ <% page.media_files.each do |file| %>
+ - <%= file.filename.to_s %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+ <%= f.submit %>
+ <%= link_to t(".cancel"), admin_pages_path(locale: @translation_locale) %>
+
+<% end %>
diff --git a/app/views/cms/admin/pages/_section_editor_frame.html.erb b/app/views/cms/admin/pages/_section_editor_frame.html.erb
new file mode 100644
index 0000000..a743ab4
--- /dev/null
+++ b/app/views/cms/admin/pages/_section_editor_frame.html.erb
@@ -0,0 +1,3 @@
+<%= turbo_frame_tag "cms-section-form" do %>
+ <%= t("cms.admin.pages.show.section_hint") %>
+<% end %>
diff --git a/app/views/cms/admin/pages/_sections_list.html.erb b/app/views/cms/admin/pages/_sections_list.html.erb
new file mode 100644
index 0000000..b84a4df
--- /dev/null
+++ b/app/views/cms/admin/pages/_sections_list.html.erb
@@ -0,0 +1,9 @@
+<%= turbo_frame_tag "cms-sections-list" do %>
+
+ <% page_sections.each do |page_section| %>
+ <%= render "cms/admin/sections/section", page_section: page_section %>
+ <% end %>
+
+<% end %>
diff --git a/app/views/cms/admin/pages/edit.html.erb b/app/views/cms/admin/pages/edit.html.erb
new file mode 100644
index 0000000..71ea272
--- /dev/null
+++ b/app/views/cms/admin/pages/edit.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title") %>
+<%= render "form", page: @page %>
diff --git a/app/views/cms/admin/pages/index.html.erb b/app/views/cms/admin/pages/index.html.erb
new file mode 100644
index 0000000..58e9182
--- /dev/null
+++ b/app/views/cms/admin/pages/index.html.erb
@@ -0,0 +1,62 @@
+<%= t(".title") %>
+
+
+ <%= link_to t(".site_settings"), admin_site_path %>
+ |
+ <%= link_to t(".new"), new_admin_page_path(locale: params[:locale]) %>
+
+
+<%= form_with url: admin_pages_path, method: :get, local: true do |f| %>
+
+ <%= f.text_field :q, value: params[:q], placeholder: t(".search_placeholder") %>
+ <%= f.submit t(".search") %>
+ <% if params[:q].present? %>
+ <%= link_to t(".clear"), admin_pages_path %>
+ <% end %>
+
+<% end %>
+
+<%= t(".intro") %>
+
+<% if @page_rows.empty? %>
+
+ <%= params[:q].present? ? t(".empty_search") : t(".empty") %>
+
+<% else %>
+
+
+
+ | <%= t(".title_heading") %> |
+ <%= t(".slug") %> |
+ <%= t(".template") %> |
+ <%= t(".status") %> |
+ <%= t(".nav") %> |
+ <%= t(".actions") %> |
+
+
+
+ <% @page_rows.each do |page, depth| %>
+
+ | <%= page.display_title %> |
+ <%= page.slug %> |
+ <%= cms_page_template_label(page.template_key) %> |
+ <%= cms_page_status_label(page.status) %> |
+
+ <% nav_items = [] %>
+ <% nav_items << t(".header") if page.show_in_header? %>
+ <% nav_items << t(".footer") if page.show_in_footer? %>
+ <%= nav_items.join(" / ") %>
+ <%= " (#{page.nav_group})" unless page.nav_group == "main" %>
+ |
+
+ <%= link_to t(".view"), admin_page_path(page, locale: params[:locale]) %>
+ <%= link_to t(".edit"), edit_admin_page_path(page, locale: params[:locale]) %>
+ <%= link_to t(".delete"),
+ admin_page_path(page, locale: params[:locale]),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+ |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/cms/admin/pages/new.html.erb b/app/views/cms/admin/pages/new.html.erb
new file mode 100644
index 0000000..71ea272
--- /dev/null
+++ b/app/views/cms/admin/pages/new.html.erb
@@ -0,0 +1,2 @@
+<%= t(".title") %>
+<%= render "form", page: @page %>
diff --git a/app/views/cms/admin/pages/show.html.erb b/app/views/cms/admin/pages/show.html.erb
new file mode 100644
index 0000000..4820d31
--- /dev/null
+++ b/app/views/cms/admin/pages/show.html.erb
@@ -0,0 +1,111 @@
+<% crumbs = breadcrumbs_for(@page) %>
+<% if crumbs.length > 1 %>
+
+<% end %>
+
+<%= @page.display_title %>
+
+<% if @page.parent %>
+ <%= t(".parent") %>: <%= link_to @page.parent.display_title, admin_page_path(@page.parent) %>
+<% end %>
+<% if @subpages.any? %>
+
+ <%= t(".subpages") %>:
+ <% @subpages.each do |sub| %>
+ <%= link_to sub.display_title, admin_page_path(sub) %>
+ <% end %>
+
+<% end %>
+
+
+ <%= link_to t(".back"), admin_pages_path(locale: @translation_locale) %>
+ |
+ <%= link_to t(".edit"), edit_admin_page_path(@page, locale: @translation_locale) %>
+ |
+ <%= link_to t(".section_library"), admin_sections_path(locale: @translation_locale) %>
+ |
+ <%= link_to t(".preview"), preview_admin_page_path(@page), target: "_blank" %>
+ |
+ <%= link_to t(".share_preview"), page_preview_url(@page.preview_token), target: "_blank" %>
+ <% if @page.template_key == "form" %>
+ |
+ <%= link_to t(".form_fields"), admin_page_form_fields_path(@page) %>
+ |
+ <%= link_to t(".submissions"), admin_page_form_submissions_path(@page) %>
+ <% end %>
+
+
+
+ <%= t(".translations") %>:
+ <% @page.page_translations.each do |t| %>
+ <%= link_to t.locale.upcase, admin_page_path(@page, locale: t.locale) %>
+ <% end %>
+
+ <% translation_completeness(@page).each do |locale, status| %>
+ <% unless @page.page_translations.map(&:locale).include?(locale) %>
+ ">[<%= t(".translation_missing_badge", locale: locale.upcase) %>]
+ <% end %>
+ <% end %>
+
+<%= t(".locale") %>: <%= @translation_locale %>
+<%= t(".slug") %>: <%= @page.slug %>
+<%= t(".template") %>: <%= cms_page_template_label(@page.template_key) %>
+<%= t(".status") %>: <%= cms_page_status_label(@page.status) %>
+<%= t(".home_page") %>: <%= cms_yes_no(@page.home?) %>
+<%= t(".header") %>: <%= cms_yes_no(@page.show_in_header?) %>
+<%= t(".footer") %>: <%= cms_yes_no(@page.show_in_footer?) %>
+<%= t(".nav_group") %>: <%= @page.nav_group %>
+<%= t(".meta_title") %>: <%= @page.display_meta_title.presence || t("cms.shared.empty_value") %>
+<%= t(".meta_description") %>: <%= @page.seo_description.presence || t("cms.shared.empty_value") %>
+
+<% if @page.hero_image.attached? %>
+ <%= t(".hero_image") %>:
+ <%= image_tag cms_attachment_path(@page.hero_image), alt: @page.display_title, style: "max-width: 420px; height: auto;" %>
+<% end %>
+
+<% if @page.media_files.attached? %>
+ <%= t(".attachments") %>:
+
+ <% @page.media_files.each do |file| %>
+ - <%= link_to file.filename.to_s, file %>
+ <% end %>
+
+<% end %>
+
+<%# Sections panel %>
+
+ <%= t(".content_sections") %>
+
+ <%= render "cms/admin/pages/sections_list",
+ page: @page,
+ page_sections: @page_sections %>
+
+
+
+
+ <%= t(".add_section") %>
+
+ <% Cms::Section::KindRegistry.registered_kinds.each do |kind| %>
+ <%= link_to cms_section_kind_label(kind),
+ new_admin_page_section_path(@page, kind: kind, locale: @translation_locale),
+ data: { turbo_frame: "cms-section-form" },
+ class: "cms-btn cms-btn--sm" %>
+ <% end %>
+
+
+
+ <%# Turbo Frame target for inline section forms %>
+ <%= render "cms/admin/pages/section_editor_frame" %>
+
+ <%= render "cms/admin/pages/attach_section_panel",
+ page: @page,
+ available_sections: @available_sections %>
+
diff --git a/app/views/cms/admin/sections/_form.html.erb b/app/views/cms/admin/sections/_form.html.erb
new file mode 100644
index 0000000..8d24e0a
--- /dev/null
+++ b/app/views/cms/admin/sections/_form.html.erb
@@ -0,0 +1,128 @@
+<%= form_with model: @section, url: (
+ if @page
+ @section.new_record? ? admin_page_sections_path(@page) : admin_page_section_path(@page, @section)
+ else
+ @section.new_record? ? admin_sections_path : admin_section_path(@section)
+ end
+ ) do |f| %>
+ <% if @section.errors.any? %>
+
+ <% end %>
+
+ <%= f.hidden_field :kind %>
+
+
+
+ <%= cms_section_kind_label(@section.kind) %>
+
+
+
+ <%= f.label :enabled, class: "cms-form__label" %>
+ <%= f.check_box :enabled, class: "cms-checkbox" %>
+
+
+ <% translation = @section.translations.find { |record| record.locale == @translation_locale.to_s } || @section.translations.build(locale: @translation_locale) %>
+
+ <% case @section.kind %>
+ <% when "rich_text" %>
+ <%= f.fields_for :translations, translation do |tf| %>
+ <%= tf.hidden_field :id if tf.object.persisted? %>
+ <%= tf.hidden_field :locale, value: @translation_locale %>
+
+
+ <%= tf.label :title, class: "cms-form__label" %>
+ <%= tf.text_field :title, class: "cms-input" %>
+
+
+
+ <%= tf.label :content, t(".content"), class: "cms-form__label" %>
+ <%= tf.rich_text_area :content, class: "cms-input cms-input--textarea" %>
+
+ <% end %>
+ <% when "image" %>
+
+ <% when "hero" %>
+
+ <%= f.label :background_color, class: "cms-form__label" %>
+ <%= f.color_field :background_color, class: "cms-input" %>
+
+
+
+ <%= f.label :cta_url, class: "cms-form__label" %>
+ <%= f.url_field :cta_url, class: "cms-input" %>
+
+
+ <%= f.fields_for :translations, translation do |tf| %>
+ <%= tf.hidden_field :id if tf.object.persisted? %>
+ <%= tf.hidden_field :locale, value: @translation_locale %>
+
+
+ <%= tf.label :title, class: "cms-form__label" %>
+ <%= tf.text_field :title, class: "cms-input" %>
+
+
+
+ <%= tf.label :subtitle, t(".cta_text"), class: "cms-form__label" %>
+ <%= tf.text_field :subtitle, class: "cms-input" %>
+
+
+
+ <%= tf.label :content, t(".content"), class: "cms-form__label" %>
+ <%= tf.rich_text_area :content, class: "cms-input cms-input--textarea" %>
+
+ <% end %>
+ <% when "cta" %>
+
+ <%= f.label :button_url, class: "cms-form__label" %>
+ <%= f.url_field :button_url, class: "cms-input" %>
+
+
+
+ <%= f.label :alignment, class: "cms-form__label" %>
+ <%= f.select :alignment, [["Left", "left"], ["Center", "center"], ["Right", "right"]], {}, class: "cms-input" %>
+
+
+ <%= f.fields_for :translations, translation do |tf| %>
+ <%= tf.hidden_field :id if tf.object.persisted? %>
+ <%= tf.hidden_field :locale, value: @translation_locale %>
+
+
+ <%= tf.label :title, class: "cms-form__label" %>
+ <%= tf.text_field :title, class: "cms-input" %>
+
+
+
+ <%= tf.label :subtitle, t(".button_text"), class: "cms-form__label" %>
+ <%= tf.text_field :subtitle, class: "cms-input" %>
+
+
+
+ <%= tf.label :content, t(".content"), class: "cms-form__label" %>
+ <%= tf.rich_text_area :content, class: "cms-input cms-input--textarea" %>
+
+ <% end %>
+ <% end %>
+
+
+ <%= f.submit @section.new_record? ? t(".add") : t(".update"), class: "cms-btn cms-btn--primary" %>
+ <%= link_to t(".cancel"),
+ (@page ? admin_page_path(@page, locale: @translation_locale) : admin_sections_path(locale: @translation_locale)),
+ class: "cms-btn" %>
+
+<% end %>
diff --git a/app/views/cms/admin/sections/_section.html.erb b/app/views/cms/admin/sections/_section.html.erb
new file mode 100644
index 0000000..bf481a9
--- /dev/null
+++ b/app/views/cms/admin/sections/_section.html.erb
@@ -0,0 +1,22 @@
+
+ ">☰
+
+ <%= cms_section_kind_label(page_section.section.kind) %>
+ <%= page_section.section.title.presence || t(".no_title") %>
+
+ <%= page_section.section.enabled? ? t(".enabled") : t(".disabled") %>
+
+
+
+ <%= link_to t(".edit"),
+ edit_admin_page_section_path(@page, page_section.section, locale: @translation_locale),
+ data: { turbo_frame: "cms-section-form" },
+ class: "cms-btn cms-btn--sm" %>
+ <%= link_to t(".delete"),
+ admin_page_section_path(@page, page_section.section),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") },
+ class: "cms-btn cms-btn--sm cms-btn--danger" %>
+
+
diff --git a/app/views/cms/admin/sections/edit.html.erb b/app/views/cms/admin/sections/edit.html.erb
new file mode 100644
index 0000000..479338f
--- /dev/null
+++ b/app/views/cms/admin/sections/edit.html.erb
@@ -0,0 +1,9 @@
+<% if @page %>
+ <%= turbo_frame_tag "cms-section-form" do %>
+ <%= t(".title", kind: cms_section_kind_label(@section.kind)) %>
+ <%= render "form" %>
+ <% end %>
+<% else %>
+ <%= t(".title", kind: cms_section_kind_label(@section.kind)) %>
+ <%= render "form" %>
+<% end %>
diff --git a/app/views/cms/admin/sections/index.html.erb b/app/views/cms/admin/sections/index.html.erb
new file mode 100644
index 0000000..0970486
--- /dev/null
+++ b/app/views/cms/admin/sections/index.html.erb
@@ -0,0 +1,47 @@
+<%= t(".title") %>
+
+
+ <%= link_to t(".new"), new_admin_section_path(locale: params[:locale]) %>
+ |
+ <%= link_to t(".back"), admin_pages_path(locale: params[:locale]) %>
+
+
+<%= t(".intro") %>
+
+<% if @sections.empty? %>
+ <%= t(".empty") %>
+<% else %>
+
+
+
+ | <%= t(".kind") %> |
+ <%= t(".title_heading") %> |
+ <%= t(".usage") %> |
+ <%= t(".status") %> |
+ <%= t(".actions") %> |
+
+
+
+ <% @sections.each do |section| %>
+
+ | <%= cms_section_kind_label(section.kind) %> |
+ <%= section.title.presence || t(".no_title") %> |
+
+ <% if section.pages.any? %>
+ <%= section.pages.map(&:display_title).join(", ") %>
+ <% else %>
+ <%= t(".unused") %>
+ <% end %>
+ |
+ <%= section.enabled? ? t(".enabled") : t(".disabled") %> |
+
+ <%= link_to t(".edit"), edit_admin_section_path(section, locale: params[:locale]) %>
+ <%= link_to t(".delete"),
+ admin_section_path(section, locale: params[:locale]),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+ |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/cms/admin/sections/new.html.erb b/app/views/cms/admin/sections/new.html.erb
new file mode 100644
index 0000000..479338f
--- /dev/null
+++ b/app/views/cms/admin/sections/new.html.erb
@@ -0,0 +1,9 @@
+<% if @page %>
+ <%= turbo_frame_tag "cms-section-form" do %>
+ <%= t(".title", kind: cms_section_kind_label(@section.kind)) %>
+ <%= render "form" %>
+ <% end %>
+<% else %>
+ <%= t(".title", kind: cms_section_kind_label(@section.kind)) %>
+ <%= render "form" %>
+<% end %>
diff --git a/app/views/cms/admin/sections/page_update.turbo_stream.erb b/app/views/cms/admin/sections/page_update.turbo_stream.erb
new file mode 100644
index 0000000..62b53e4
--- /dev/null
+++ b/app/views/cms/admin/sections/page_update.turbo_stream.erb
@@ -0,0 +1,17 @@
+<%= turbo_stream.replace "cms-sections-list" do %>
+ <%= render "cms/admin/pages/sections_list",
+ page: @page,
+ page_sections: @page_sections %>
+<% end %>
+
+<%= turbo_stream.replace "cms-section-form" do %>
+ <%= render "cms/admin/pages/section_editor_frame" %>
+<% end %>
+
+<%= turbo_stream.replace "cms-attach-section-panel" do %>
+ <%= render "cms/admin/pages/attach_section_panel",
+ page: @page,
+ available_sections: @available_sections %>
+<% end %>
+
+<%= turbo_stream.replace "flash", partial: "shared/alerts" %>
diff --git a/app/views/cms/admin/sections/show.html.erb b/app/views/cms/admin/sections/show.html.erb
new file mode 100644
index 0000000..348c0cc
--- /dev/null
+++ b/app/views/cms/admin/sections/show.html.erb
@@ -0,0 +1,97 @@
+<% content_for :page_title, "#{@section.title.presence || t("cms.admin.sections.index.no_title")} - CMS" %>
+<% content_for :title, @section.title.presence || t("cms.admin.sections.index.no_title") %>
+
+
+
+ <%= t("cms.admin.sections.show.details") %>
+
+ <%= t("cms.admin.sections.index.kind") %>: <%= cms_section_kind_label(@section.kind) %>
+ <%= t("cms.admin.sections.index.status") %>: <%= @section.enabled? ? t("cms.admin.sections.index.enabled") : t("cms.admin.sections.index.disabled") %>
+ <%= t("cms.admin.sections.show.global") %>: <%= cms_yes_no(@section.global?) %>
+
+ <% if @section.kind == "hero" %>
+ <%= t("cms.admin.sections.form.settings") %>
+ <% if @section.background_color.present? %>
+ <%= Cms::Section.human_attribute_name(:background_color) %>: <%= @section.background_color %>
+ <% end %>
+ <% if @section.cta_url.present? %>
+ <%= Cms::Section.human_attribute_name(:cta_url) %>: <%= @section.cta_url %>
+ <% end %>
+ <% elsif @section.kind == "cta" %>
+ <%= t("cms.admin.sections.form.settings") %>
+ <% if @section.button_url.present? %>
+ <%= Cms::Section.human_attribute_name(:button_url) %>: <%= @section.button_url %>
+ <% end %>
+ <% if @section.alignment.present? %>
+ <%= Cms::Section.human_attribute_name(:alignment) %>: <%= @section.alignment.capitalize %>
+ <% end %>
+ <% end %>
+
+ <%= t("cms.admin.sections.index.usage") %>
+ <% if @section.pages.any? %>
+
+ <% @section.pages.each do |page| %>
+ - <%= link_to page.display_title, admin_page_path(page, locale: @translation_locale) %>
+ <% end %>
+
+ <% else %>
+ <%= t("cms.admin.sections.index.unused") %>
+ <% end %>
+
+
+
+ <%= t("cms.admin.pages.show.translations") %>
+ <% locales = [@translation_locale, *@section.available_locales].compact.uniq %>
+ <% translations_by_locale = @section.translations.index_by(&:locale) %>
+
+ <% locales.each do |locale| %>
+ <% translation = translations_by_locale[locale.to_s] %>
+
+ <%= t("locales.#{locale}", default: locale.to_s.upcase) %>
+ <% if translation %>
+ <%= t("cms.admin.sections.form.title") %>: <%= translation.title.presence || t("cms.shared.empty_value") %>
+
+ <% if @section.kind == "hero" && translation.subtitle.present? %>
+ <%= t("cms.admin.sections.form.cta_text") %>: <%= translation.subtitle %>
+ <% end %>
+
+ <% if @section.kind == "cta" && translation.subtitle.present? %>
+ <%= t("cms.admin.sections.form.button_text") %>: <%= translation.subtitle %>
+ <% end %>
+
+ <%= t("cms.admin.sections.form.content") %>:
+ <% if translation.content.present? %>
+ <%= translation.content %>
+ <% else %>
+ <%= t("cms.shared.empty_value") %>
+ <% end %>
+ <% else %>
+ <%= t("cms.admin.sections.show.no_translation") %>
+ <% end %>
+
+ <% end %>
+
+ <% if @section.kind == "image" && @section.image_assets.any? %>
+
+ <%= t("cms.admin.sections.form.select_images") %>
+
+ <% @section.image_assets.each do |image| %>
+
+ <%= image_tag image.file, alt: image.alt_text.presence || image.display_title, style: "max-width: 100%; height: auto;" %>
+ <%= image.display_title %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+ <%= link_to t("cms.admin.sections.index.back", default: "Back"), admin_sections_path(locale: @translation_locale), class: "cms-btn" %>
+ <%= link_to t("cms.admin.sections.index.edit"), edit_admin_section_path(@section, locale: @translation_locale), class: "cms-btn cms-btn--primary" %>
+ <%= link_to t("cms.admin.sections.index.delete"),
+ admin_section_path(@section, locale: @translation_locale),
+ class: "cms-btn",
+ data: { turbo_method: :delete, turbo_confirm: t("cms.admin.sections.index.delete_confirm") } %>
+
diff --git a/app/views/cms/admin/sites/_form.html.erb b/app/views/cms/admin/sites/_form.html.erb
new file mode 100644
index 0000000..b62f2a3
--- /dev/null
+++ b/app/views/cms/admin/sites/_form.html.erb
@@ -0,0 +1,44 @@
+<%= form_with model: @site, url: admin_site_path, method: @site.persisted? ? :patch : :post do |f| %>
+
+ <%= f.label :name %>
+ <%= f.text_field :name %>
+
+
+
+ <%= f.label :slug %>
+ <%= f.text_field :slug %>
+
+
+
+ <%= f.label :default_locale %>
+ <%= f.text_field :default_locale %>
+
+
+
+ <%= f.label :published %>
+ <%= f.check_box :published %>
+
+
+
+ <%= f.label :logo %>
+ <%= f.file_field :logo, accept: "image/*" %>
+
+ <%= t("cms.admin.sites.form.logo_hint") %>
+ <% if @site.logo.attached? %>
+
+ <%= image_tag cms_attachment_path(@site.logo), alt: t("cms.admin.sites.form.logo_alt", site: @site.name), style: "max-width: 200px; height: auto;" %>
+
+
+ <% end %>
+
+
+
+ <%= f.submit submit_label %>
+ <% if cancel_path.present? %>
+ <%= link_to t("cms.admin.sites.form.cancel"), cancel_path %>
+ <% end %>
+
+<% end %>
diff --git a/app/views/cms/admin/sites/edit.html.erb b/app/views/cms/admin/sites/edit.html.erb
new file mode 100644
index 0000000..281adc5
--- /dev/null
+++ b/app/views/cms/admin/sites/edit.html.erb
@@ -0,0 +1,3 @@
+<%= t("cms.admin.sites.edit.title") %>
+
+<%= render "form", submit_label: t("cms.admin.sites.form.save"), cancel_path: admin_site_path %>
diff --git a/app/views/cms/admin/sites/new.html.erb b/app/views/cms/admin/sites/new.html.erb
new file mode 100644
index 0000000..85642b9
--- /dev/null
+++ b/app/views/cms/admin/sites/new.html.erb
@@ -0,0 +1,5 @@
+<%= t("cms.admin.sites.new.title") %>
+
+<%= t("cms.admin.sites.new.intro") %>
+
+<%= render "form", submit_label: t("cms.admin.sites.form.create"), cancel_path: nil %>
diff --git a/app/views/cms/admin/sites/show.html.erb b/app/views/cms/admin/sites/show.html.erb
new file mode 100644
index 0000000..d67369d
--- /dev/null
+++ b/app/views/cms/admin/sites/show.html.erb
@@ -0,0 +1,22 @@
+<%= t("cms.admin.sites.show.title") %>
+
+
+ <%= t("cms.admin.sites.show.public_url") %>:
+ <%= link_to site_path(@site.slug), site_path(@site.slug) %>
+
+
+<%= t("cms.admin.sites.show.published") %>: <%= cms_yes_no(@site.published?) %>
+<%= t("cms.admin.sites.show.default_locale") %>: <%= @site.default_locale %>
+<%= t("cms.admin.sites.show.available_locales") %>: <%= I18n.available_locales.map(&:to_s).join(", ") %>
+
+<% if @site.logo.attached? %>
+ <%= t("cms.admin.sites.show.logo") %>:
+ <%= image_tag cms_attachment_path(@site.logo), alt: t("cms.admin.sites.show.logo_alt", site: @site.name), style: "max-width: 220px; height: auto;" %>
+ <%= t("cms.admin.sites.show.logo_hint") %>
+<% end %>
+
+
+ <%= link_to t("cms.admin.sites.show.edit"), edit_admin_site_path %>
+ |
+ <%= link_to t("cms.admin.sites.show.manage_pages"), admin_pages_path %>
+
diff --git a/app/views/cms/admin/webhook_deliveries/index.html.erb b/app/views/cms/admin/webhook_deliveries/index.html.erb
new file mode 100644
index 0000000..a428b70
--- /dev/null
+++ b/app/views/cms/admin/webhook_deliveries/index.html.erb
@@ -0,0 +1,29 @@
+<%= t("cms.admin.webhook_deliveries.index.title", url: @webhook.url) %>
+<%= link_to t("cms.admin.webhook_deliveries.index.back"), admin_webhooks_path %>
+
+<% if @deliveries.empty? %>
+ <%= t("cms.admin.webhook_deliveries.index.empty") %>
+<% else %>
+
+
+
+ | <%= t("cms.admin.webhook_deliveries.index.delivered_at") %> |
+ <%= t("cms.admin.webhook_deliveries.index.event") %> |
+ <%= t("cms.admin.webhook_deliveries.index.status") %> |
+ <%= t("cms.admin.webhook_deliveries.index.response_code") %> |
+ <%= t("cms.admin.webhook_deliveries.index.error") %> |
+
+
+
+ <% @deliveries.each do |delivery| %>
+
+ | <%= cms_datetime(delivery.delivered_at) %> |
+ <%= delivery.event %> |
+ <%= delivery.success? ? t("cms.shared.yes") : t("cms.shared.no") %> |
+ <%= delivery.response_code.presence || t("cms.shared.empty_value") %> |
+ <%= delivery.error_message.presence || t("cms.shared.empty_value") %> |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/cms/admin/webhooks/_form.html.erb b/app/views/cms/admin/webhooks/_form.html.erb
new file mode 100644
index 0000000..4911779
--- /dev/null
+++ b/app/views/cms/admin/webhooks/_form.html.erb
@@ -0,0 +1,38 @@
+<%= form_with model: [:admin, webhook] do |f| %>
+ <% if webhook.errors.any? %>
+
+ <% webhook.errors.full_messages.each do |msg| %>
+
<%= msg %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= f.label :url, t(".url") %>
+ <%= f.url_field :url %>
+
+
+
+ <%= f.label :secret, t(".secret") %>
+ <%= f.text_field :secret %>
+
+
+
+
<%= t(".events") %>
+ <% Cms::Webhook::EVENTS.each do |event| %>
+
+ <% end %>
+
+
+
+ <%= f.label :active %>
+ <%= f.check_box :active %>
+
+
+
+ <%= f.submit %>
+
+<% end %>
diff --git a/app/views/cms/admin/webhooks/edit.html.erb b/app/views/cms/admin/webhooks/edit.html.erb
new file mode 100644
index 0000000..420d58b
--- /dev/null
+++ b/app/views/cms/admin/webhooks/edit.html.erb
@@ -0,0 +1,5 @@
+<%= t(".title") %>
+
+<%= render "form", webhook: @webhook %>
+
+<%= link_to t(".back"), admin_webhooks_path %>
diff --git a/app/views/cms/admin/webhooks/index.html.erb b/app/views/cms/admin/webhooks/index.html.erb
new file mode 100644
index 0000000..d33c15d
--- /dev/null
+++ b/app/views/cms/admin/webhooks/index.html.erb
@@ -0,0 +1,34 @@
+<%= t(".title") %>
+
+<%= link_to t(".new"), new_admin_webhook_path %>
+
+<% if @webhooks.any? %>
+
+
+
+ | <%= t(".url") %> |
+ <%= t(".events") %> |
+ <%= t(".active") %> |
+ |
+
+
+
+ <% @webhooks.each do |webhook| %>
+
+ | <%= webhook.url %> |
+ <%= webhook.events.map { |event| cms_webhook_event_label(event) }.join(", ") %> |
+ <%= cms_yes_no(webhook.active?) %> |
+
+ <%= link_to t(".edit"), edit_admin_webhook_path(webhook) %>
+ |
+ <%= link_to t(".delete"),
+ admin_webhook_path(webhook),
+ data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %>
+ |
+
+ <% end %>
+
+
+<% else %>
+ <%= t(".empty") %>
+<% end %>
diff --git a/app/views/cms/admin/webhooks/new.html.erb b/app/views/cms/admin/webhooks/new.html.erb
new file mode 100644
index 0000000..420d58b
--- /dev/null
+++ b/app/views/cms/admin/webhooks/new.html.erb
@@ -0,0 +1,5 @@
+<%= t(".title") %>
+
+<%= render "form", webhook: @webhook %>
+
+<%= link_to t(".back"), admin_webhooks_path %>
diff --git a/app/views/cms/form_submission_mailer/notify.html.erb b/app/views/cms/form_submission_mailer/notify.html.erb
new file mode 100644
index 0000000..778e525
--- /dev/null
+++ b/app/views/cms/form_submission_mailer/notify.html.erb
@@ -0,0 +1,14 @@
+<%= t(".title", page: @page.display_title) %>
+
+
+ <% @fields.each do |field| %>
+
+ | <%= field.label %> |
+ <%= @submission.data[field.field_name] %> |
+
+ <% end %>
+
+
+
+ <%= t(".submitted_at") %> <%= cms_datetime(@submission.created_at) %>
+
diff --git a/app/views/cms/form_submission_mailer/notify.text.erb b/app/views/cms/form_submission_mailer/notify.text.erb
new file mode 100644
index 0000000..048ae43
--- /dev/null
+++ b/app/views/cms/form_submission_mailer/notify.text.erb
@@ -0,0 +1,7 @@
+<%= t(".title", page: @page.display_title) %>
+
+<% @fields.each do |field| -%>
+<%= field.label %>: <%= @submission.data[field.field_name] %>
+<% end -%>
+
+<%= t(".submitted_at") %> <%= cms_datetime(@submission.created_at) %>
diff --git a/app/views/cms/public/pages/_content.html.erb b/app/views/cms/public/pages/_content.html.erb
new file mode 100644
index 0000000..e18423a
--- /dev/null
+++ b/app/views/cms/public/pages/_content.html.erb
@@ -0,0 +1,48 @@
+<%= @page.display_title %>
+
+<% if @page.hero_image.attached? %>
+ <%= image_tag cms_attachment_path(@page.hero_image), alt: @page.display_title, style: "max-width: 900px; height: auto;" %>
+<% end %>
+
+<% if @submission&.errors&.any? %>
+
+ <%= t("cms.public.forms.submission_problem") %>
+
+ <% @submission.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+<% end %>
+
+<% @sections.each do |section| %>
+ <%= render_section(section) %>
+<% end %>
+
+<% if @form_fields.any? %>
+
+ <%= t("cms.public.forms.contact") %>
+ <%= form_with url: page_form_submissions_path(@page, site_slug: @site.slug), scope: :submission, local: true do |form| %>
+ <% @form_fields.each do |field| %>
+
+
+ <% value = @submission.data.to_h[field.field_name] %>
+ <% case field.kind %>
+ <% when "textarea" %>
+ <%= form.text_area field.field_name, value: value, placeholder: field.placeholder %>
+ <% when "select" %>
+ <%= form.select field.field_name, options_for_select(field.parsed_options, value), include_blank: true %>
+ <% when "checkbox" %>
+ <%= form.check_box field.field_name, { checked: value.to_s == "1" }, "1", "0" %>
+ <% else %>
+ <%= form.text_field field.field_name, value: value, type: field.kind, placeholder: field.placeholder %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= form.submit t("cms.public.forms.send") %>
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/cms/public/pages/show.html.erb b/app/views/cms/public/pages/show.html.erb
new file mode 100644
index 0000000..044d3a4
--- /dev/null
+++ b/app/views/cms/public/pages/show.html.erb
@@ -0,0 +1,44 @@
+<% content_for :page_title, @page.display_meta_title %>
+<% content_for :page_description, @page.seo_description.to_s %>
+<% content_for :head do %>
+ <%= site_favicon_tag(@site) %>
+<% end %>
+
+
+
+
+ <%= render_page_template(@page) %>
+
+
+
diff --git a/app/views/cms/public/pages/templates/_custom.html.erb b/app/views/cms/public/pages/templates/_custom.html.erb
new file mode 100644
index 0000000..779dad5
--- /dev/null
+++ b/app/views/cms/public/pages/templates/_custom.html.erb
@@ -0,0 +1,3 @@
+
+ <%= render "cms/public/pages/content" %>
+
diff --git a/app/views/cms/public/pages/templates/_form.html.erb b/app/views/cms/public/pages/templates/_form.html.erb
new file mode 100644
index 0000000..49ca337
--- /dev/null
+++ b/app/views/cms/public/pages/templates/_form.html.erb
@@ -0,0 +1,3 @@
+
+ <%= render "cms/public/pages/content" %>
+
diff --git a/app/views/cms/public/pages/templates/_landing.html.erb b/app/views/cms/public/pages/templates/_landing.html.erb
new file mode 100644
index 0000000..ae94d5c
--- /dev/null
+++ b/app/views/cms/public/pages/templates/_landing.html.erb
@@ -0,0 +1,3 @@
+
+ <%= render "cms/public/pages/content" %>
+
diff --git a/app/views/cms/public/pages/templates/_standard.html.erb b/app/views/cms/public/pages/templates/_standard.html.erb
new file mode 100644
index 0000000..5a81831
--- /dev/null
+++ b/app/views/cms/public/pages/templates/_standard.html.erb
@@ -0,0 +1,3 @@
+
+ <%= render "cms/public/pages/content" %>
+
diff --git a/app/views/cms/sections/kinds/_cta.html.erb b/app/views/cms/sections/kinds/_cta.html.erb
new file mode 100644
index 0000000..9a3fa50
--- /dev/null
+++ b/app/views/cms/sections/kinds/_cta.html.erb
@@ -0,0 +1,13 @@
+">
+ <% if section.title.present? %>
+ <%= section.title %>
+ <% end %>
+ <% if section.content.present? %>
+ <%= section.content %>
+ <% end %>
+ <% if section.button_text.present? && section.button_url.present? %>
+
+ <%= section.button_text %>
+
+ <% end %>
+
diff --git a/app/views/cms/sections/kinds/_hero.html.erb b/app/views/cms/sections/kinds/_hero.html.erb
new file mode 100644
index 0000000..a11a57f
--- /dev/null
+++ b/app/views/cms/sections/kinds/_hero.html.erb
@@ -0,0 +1,14 @@
+">
+ <% if section.title.present? %>
+ <%= section.title %>
+ <% end %>
+ <% if section.content.present? %>
+ <%= section.content %>
+ <% end %>
+ <% if section.cta_text.present? && section.cta_url.present? %>
+
+ <%= section.cta_text %>
+
+ <% end %>
+
diff --git a/app/views/cms/sections/kinds/_image.html.erb b/app/views/cms/sections/kinds/_image.html.erb
new file mode 100644
index 0000000..ffcf669
--- /dev/null
+++ b/app/views/cms/sections/kinds/_image.html.erb
@@ -0,0 +1,19 @@
+
+ <% if section.title.present? %>
+ <%= section.title %>
+ <% end %>
+ <% images = section.image_assets %>
+ <% if images.present? %>
+
+ <% images.each do |image| %>
+
+ <%= cms_image_tag image, alt: image.alt_text.presence || section.title.to_s, loading: "lazy" %>
+ <% caption = image.caption.presence %>
+ <% if caption.present? %>
+ <%= caption %>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/cms/sections/kinds/_rich_text.html.erb b/app/views/cms/sections/kinds/_rich_text.html.erb
new file mode 100644
index 0000000..bd70506
--- /dev/null
+++ b/app/views/cms/sections/kinds/_rich_text.html.erb
@@ -0,0 +1,6 @@
+
+ <% if section.title.present? %>
+ <%= section.title %>
+ <% end %>
+ <%= section.content %>
+
diff --git a/app/views/layouts/cms/application.html.erb b/app/views/layouts/cms/application.html.erb
index 21ab018..c6f165b 100644
--- a/app/views/layouts/cms/application.html.erb
+++ b/app/views/layouts/cms/application.html.erb
@@ -1,17 +1,13 @@
-
- Cms
- <%= csrf_meta_tags %>
- <%= csp_meta_tag %>
-
- <%= yield :head %>
-
- <%= stylesheet_link_tag "cms/application", media: "all" %>
-
-
-
-<%= yield %>
-
-
+
+ CMS
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= stylesheet_link_tag "cms/application", media: "all" %>
+ <%= javascript_importmap_tags if respond_to?(:javascript_importmap_tags) %>
+
+
+ <%= yield %>
+
diff --git a/app/views/layouts/cms/public.html.erb b/app/views/layouts/cms/public.html.erb
new file mode 100644
index 0000000..c07d4d7
--- /dev/null
+++ b/app/views/layouts/cms/public.html.erb
@@ -0,0 +1,14 @@
+
+
+
+
+ <%= content_for?(:page_title) ? yield(:page_title) : "CMS" %>
+ ">
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= yield :head %>
+
+
+ <%= yield %>
+
+
diff --git a/bin/rails b/bin/rails
index 38ab44c..742d3be 100755
--- a/bin/rails
+++ b/bin/rails
@@ -1,25 +1,19 @@
#!/usr/bin/env ruby
-# This command will automatically be run when you run "rails" with Rails gems
-# installed from the root of your application.
+# frozen_string_literal: true
ENGINE_ROOT = File.expand_path("..", __dir__)
ENGINE_PATH = File.expand_path("../lib/cms/engine", __dir__)
+APP_PATH = File.expand_path("../spec/cms_app/config/application", __dir__)
-# Set up gems listed in the Gemfile.
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
require "rails"
-# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
-require "action_mailbox/engine"
-require "action_text/engine"
require "action_view/railtie"
-require "action_cable/engine"
-# require "rails/test_unit/railtie"
require "rails/engine/commands"
diff --git a/bin/rubocop b/bin/rubocop
index 40330c0..33fb979 100755
--- a/bin/rubocop
+++ b/bin/rubocop
@@ -1,8 +1,9 @@
#!/usr/bin/env ruby
+# frozen_string_literal: true
+
require "rubygems"
require "bundler/setup"
-# explicit rubocop config increases performance slightly while avoiding config confusion.
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
load Gem.bin_path("rubocop", "rubocop")
diff --git a/cms.gemspec b/cms.gemspec
index f9ab2d0..ae63fd7 100644
--- a/cms.gemspec
+++ b/cms.gemspec
@@ -1,26 +1,29 @@
+# frozen_string_literal: true
+
require_relative "lib/cms/version"
Gem::Specification.new do |spec|
spec.name = "cms"
spec.version = Cms::VERSION
- spec.authors = [ "Evangelos Giataganas" ]
- spec.email = [ "e.giataganas@gmail.com" ]
- spec.homepage = "TODO"
- spec.summary = "TODO: Summary of Cms."
- spec.description = "TODO: Description of Cms."
+ spec.authors = ["shift42"]
+ spec.email = ["hello@shift42.io"]
+ spec.summary = "Modular, mountable CMS engine for Rails apps"
+ spec.description = "A mountable Rails CMS engine. Multi-site, multilingual, content blocks, " \
+ "page tree, media management, forms, headless JSON API, and webhooks."
+ spec.homepage = "https://github.com/shift42/cms"
spec.license = "MIT"
+ spec.required_ruby_version = ">= 3.2"
+ spec.require_paths = ["lib"]
- # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
- # to allow pushing to a single host or delete this section to allow pushing to any host.
- spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
-
- spec.metadata["homepage_uri"] = spec.homepage
- spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
- spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
-
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
- Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"]
+ spec.files = Dir.chdir(__dir__) do
+ Dir["{app,bin,config,db,lib}/**/*", "MIT-LICENSE", "README.md", "Rakefile", "cms.gemspec"]
end
- spec.add_dependency "rails", ">= 8.1.2"
+ spec.add_dependency "actiontext", ">= 7.1", "< 9.0"
+ spec.add_dependency "discard", "~> 1.4"
+ spec.add_dependency "importmap-rails", ">= 1.2"
+ spec.add_dependency "rails", ">= 7.1", "< 9.0"
+ spec.add_dependency "stimulus-rails", ">= 1.3"
+ spec.add_dependency "turbo-rails", ">= 1.5"
+ spec.metadata["rubygems_mfa_required"] = "true"
end
diff --git a/config/importmap.rb b/config/importmap.rb
new file mode 100644
index 0000000..8e44014
--- /dev/null
+++ b/config/importmap.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+pin "sortablejs", to: "https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"
+pin "cms/controllers/sortable_controller", to: "cms/controllers/sortable_controller.js"
diff --git a/config/locales/activerecord.cms.en.yml b/config/locales/activerecord.cms.en.yml
new file mode 100644
index 0000000..aa670f9
--- /dev/null
+++ b/config/locales/activerecord.cms.en.yml
@@ -0,0 +1,65 @@
+---
+en:
+ activerecord:
+ attributes:
+ cms/api_key:
+ active: Active
+ name: Name
+ cms/document:
+ description: Description
+ file: File
+ title: Title
+ cms/form_field:
+ field_name: Field name
+ hint: Hint
+ kind: Kind
+ label: Label
+ options: Options
+ placeholder: Placeholder
+ position: Position
+ required: Required
+ cms/image:
+ file: Image file
+ title: Title
+ cms/image_translation:
+ alt_text: Alt text
+ caption: Caption
+ locale: Locale
+ cms/page:
+ footer_order: Footer order
+ hero_image: Hero image
+ home: Home page
+ media_files: Gallery/attachments
+ nav_group: Nav group
+ nav_order: Nav order
+ parent_id: Parent page
+ position: Position
+ show_in_footer: Show in footer
+ show_in_header: Show in header
+ slug: Slug
+ status: Status
+ template_key: Template
+ cms/page_translation:
+ locale: Locale
+ seo_description: SEO description
+ seo_title: SEO title
+ title: Title
+ cms/section:
+ enabled: Enabled
+ global: Global section
+ kind: Kind
+ cms/section_translation:
+ content: Content
+ locale: Locale
+ title: Title
+ cms/site:
+ default_locale: Default locale
+ logo: Logo
+ name: Name
+ published: Published
+ slug: Slug
+ cms/webhook:
+ active: Active
+ events: Events
+ secret: Secret
+ url: Endpoint URL
diff --git a/config/locales/en.yml b/config/locales/en.yml
new file mode 100644
index 0000000..28792cd
--- /dev/null
+++ b/config/locales/en.yml
@@ -0,0 +1,390 @@
+en:
+ date:
+ formats:
+ cms_date: "%d %b %Y"
+ time:
+ formats:
+ cms_csv_datetime: "%Y-%m-%d %H:%M"
+ cms_datetime: "%d %b %Y %H:%M"
+ cms:
+ shared:
+ empty_value: "-"
+ no: "No"
+ yes: "Yes"
+ errors:
+ admin_parent_controller_required: "Cms admin requires config.admin_parent_controller in production. Point it at a host controller that enforces authentication, for example \"Admin::BaseController\"."
+ current_site_required: "Cms admin requires config.current_site_resolver to return a Cms::Site. Configure it in Cms.setup so admin requests resolve the active CMS site explicitly."
+ page_not_found: "Page not found"
+ record_not_found: "The requested record was not found."
+ site_slug_not_provided: "CMS site slug not provided"
+ api:
+ site_not_found: "Site not found"
+ page_not_found: "Page not found"
+ unauthorized: "Unauthorized"
+ form_field:
+ invalid_field_name: "only lowercase letters, numbers and underscores"
+ form_submission:
+ blank: "Please complete at least one field."
+ required: "%{label} is required"
+ document:
+ invalid_file_type: "must be a supported document file"
+ image:
+ invalid_file_type: "must be a valid image file"
+ section:
+ setting_required: "%{name} is required"
+ invalid_image: "contains an image that is not available for this site"
+ global_section_requires_attachment: "A global section must stay attached to at least one page."
+ page:
+ sections_required: "Published pages must have at least one section."
+ site:
+ default_locale_unavailable: "is not included in I18n.available_locales"
+ webhook:
+ unsupported_events: "contains unsupported values: %{events}"
+ notices:
+ form_submission_sent: "Your message has been sent."
+ site_created: "Site created."
+ site_updated: "Site updated."
+ page_created: "Page created."
+ page_updated: "Page updated."
+ page_deleted: "Page deleted."
+ section_added: "Section added."
+ section_created: "Section created."
+ section_updated: "Section updated."
+ section_deleted: "Section deleted."
+ section_removed: "Section removed from page."
+ section_attached: "Section attached."
+ field_added: "Field added."
+ field_updated: "Field updated."
+ field_removed: "Field removed."
+ submission_deleted: "Submission deleted."
+ image_uploaded: "Image uploaded."
+ image_updated: "Image updated."
+ image_deleted: "Image deleted."
+ document_uploaded: "Document uploaded."
+ document_updated: "Document updated."
+ document_deleted: "Document deleted."
+ api_key_updated: "API key updated."
+ api_key_deleted: "API key deleted."
+ webhook_created: "Webhook created."
+ webhook_updated: "Webhook updated."
+ webhook_deleted: "Webhook deleted."
+ mailers:
+ form_submission:
+ subject: "New form submission - %{page_title}"
+ form_field_kinds:
+ checkbox: "Checkbox"
+ email: "Email"
+ select: "Select"
+ text: "Text"
+ textarea: "Textarea"
+ form_submission_mailer:
+ notify:
+ submitted_at: "Submitted at"
+ title: "New form submission — %{page}"
+ page_statuses:
+ archived: "Archived"
+ draft: "Draft"
+ published: "Published"
+ page_templates:
+ custom: "Custom"
+ form: "Form"
+ landing: "Landing"
+ standard: "Standard"
+ public:
+ forms:
+ send: "Send"
+ contact: "Contact"
+ submission_problem: "There was a problem with your submission"
+ section_kinds:
+ cta: "Call to action"
+ hero: "Hero"
+ image: "Image"
+ rich_text: "Rich text"
+ section_settings:
+ labels:
+ alignment: "Alignment"
+ alt_text: "Alt text"
+ background_color: "Background color"
+ button_text: "Button text"
+ button_url: "Button URL"
+ caption: "Caption"
+ cta_text: "CTA text"
+ cta_url: "CTA URL"
+ image_url: "Image URL"
+ options:
+ alignment:
+ center: "Center"
+ left: "Left"
+ right: "Right"
+ webhook_events:
+ page_published: "Page published"
+ page_unpublished: "Page unpublished"
+ admin:
+ api_keys:
+ create:
+ back: "Back to API keys"
+ title: "API Key Created"
+ token_warning: "Copy your API token now — it will not be shown again:"
+ edit:
+ back: "Back"
+ title: "Edit API Key"
+ index:
+ active: "Active"
+ created: "Created"
+ delete: "Delete"
+ delete_confirm: "Delete this API key?"
+ edit: "Edit"
+ empty: "No API keys yet."
+ last_used: "Last used"
+ name: "Name"
+ never: "Never"
+ new: "New API Key"
+ title: "API Keys"
+ new:
+ back: "Back"
+ title: "New API Key"
+ documents:
+ edit:
+ title: "Edit Document — %{title}"
+ form:
+ cancel: "Cancel"
+ current: "Current"
+ file: "File"
+ index:
+ actions: "Actions"
+ delete: "Delete"
+ delete_confirm: "Delete this document?"
+ description: "Description"
+ edit: "Edit"
+ empty: "No documents yet."
+ file: "File"
+ title: "Documents"
+ title_heading: "Title"
+ upload: "Upload document"
+ new:
+ title: "Upload Document"
+ form_fields:
+ edit:
+ title: "Edit field — %{label}"
+ form:
+ cancel: "Cancel"
+ field_name: "Field name (snake_case, used as data key)"
+ hint: "Hint text shown below the field"
+ options: "Options (for select fields, one per line)"
+ index:
+ actions: "Actions"
+ back: "Back to page"
+ delete: "Delete"
+ delete_confirm: "Delete this field?"
+ edit: "Edit"
+ empty: "No fields yet. Add fields to build your form."
+ field_name: "Field name"
+ kind: "Kind"
+ label: "Label"
+ new: "New field"
+ required: "Required"
+ submissions: "View submissions"
+ title: "Form fields — %{page}"
+ new:
+ title: "New form field — %{page}"
+ form_submissions:
+ index:
+ actions: "Actions"
+ back: "Back to form fields"
+ delete: "Delete"
+ delete_confirm: "Delete this submission?"
+ empty: "No submissions yet."
+ export: "Export CSV"
+ submitted_at: "Submitted at"
+ title: "Submissions — %{page}"
+ images:
+ edit:
+ title: "Edit Image — %{title}"
+ form:
+ alt_text: "Alt text"
+ cancel: "Cancel"
+ file: "Image file"
+ translation: "Translation (%{locale})"
+ index:
+ delete: "Delete"
+ delete_confirm: "Delete this image?"
+ edit: "Edit"
+ empty: "No images yet."
+ title: "Images"
+ upload: "Upload image"
+ new:
+ title: "Upload Image"
+ pages:
+ edit:
+ title: "Edit Page"
+ form:
+ cancel: "Cancel"
+ current_files: "Current files"
+ media_files: "Gallery/attachments"
+ no_parent: "— none (root page)"
+ parent_page: "Parent page"
+ remove_hero_image: "Remove hero image"
+ remove_media_files: "Remove all gallery/attachments"
+ seo_description: "SEO description"
+ seo_title: "SEO title"
+ template: "Template"
+ title: "Title"
+ translation: "Translation (%{locale})"
+ translation_title: "Title"
+ index:
+ actions: "Actions"
+ clear: "Clear"
+ delete: "Delete"
+ delete_confirm: "Delete this page?"
+ edit: "Edit"
+ empty: "No pages yet. Create your first page to start building the site."
+ empty_search: "No pages matched your search."
+ footer: "footer"
+ header: "header"
+ intro: "Pages are listed in site order. Use parent pages for simple hierarchies and search when you need to jump straight to one page."
+ nav: "Nav"
+ new: "New page"
+ search: "Search"
+ search_placeholder: "Search by title or slug…"
+ site_settings: "Website settings"
+ slug: "Slug"
+ status: "Status"
+ template: "Template"
+ title: "Pages"
+ title_heading: "Title"
+ view: "View"
+ new:
+ title: "New Page"
+ show:
+ add_section: "+ Add section"
+ attach: "Attach section"
+ attach_existing: "Attach global section"
+ attachments: "Attachments"
+ back: "Back"
+ content_sections: "Content sections"
+ edit: "Edit"
+ footer: "Footer"
+ form_fields: "Form fields"
+ header: "Header"
+ hero_image: "Hero image"
+ home_page: "Home page"
+ locale: "Locale"
+ meta_description: "Meta description"
+ meta_title: "Meta title"
+ nav_group: "Nav group"
+ no_title: "(no title)"
+ parent: "Parent"
+ preview: "Preview"
+ share_preview: "Share preview"
+ section_hint: "Select a section type above to add content."
+ select_global: "Select a global section"
+ slug: "Slug"
+ status: "Status"
+ submissions: "Submissions"
+ subpages: "Subpages"
+ template: "Template"
+ translation_missing_badge: "%{locale}: missing"
+ translation_missing_title: "%{locale}: missing"
+ translations: "Translations"
+ sections:
+ edit:
+ title: "Edit section — %{kind}"
+ form:
+ add: "Add section"
+ cancel: "Cancel"
+ content: "Content"
+ content_placeholder: "Rich text content"
+ image_library_empty: "Upload images in the media library before using this block."
+ kind: "Kind"
+ manage_images: "Manage images"
+ select_image: "Select image"
+ settings: "Block settings"
+ upload_image: "Upload image"
+ update: "Update section"
+ index:
+ actions: "Actions"
+ back: "Back to pages"
+ delete: "Delete"
+ delete_confirm: "Delete this section everywhere it is used?"
+ disabled: "Disabled"
+ edit: "Edit"
+ empty: "No global sections yet."
+ enabled: "Enabled"
+ intro: "Manage site-wide global sections here and attach them to pages where needed."
+ kind: "Kind"
+ new: "New global section"
+ no_title: "(no title)"
+ status: "Status"
+ title: "Sections"
+ title_heading: "Title"
+ unused: "Not attached"
+ usage: "Used on"
+ new:
+ title: "Add section — %{kind}"
+ show:
+ details: "Section details"
+ global: "Global"
+ no_translation: "No translation for this locale."
+ section:
+ delete: "Delete"
+ delete_confirm: "Remove this section?"
+ disabled: "Disabled"
+ drag: "Drag to reorder"
+ edit: "Edit"
+ enabled: "Enabled"
+ no_title: "(no title)"
+ sites:
+ new:
+ title: "Create Website"
+ intro: "Create the first CMS site before managing pages, media, and settings."
+ edit:
+ title: "Edit Website Settings"
+ show:
+ title: "Website Settings"
+ public_url: "Public URL"
+ published: "Published"
+ default_locale: "Default locale"
+ available_locales: "Available locales"
+ logo: "Logo"
+ logo_alt: "%{site} logo"
+ logo_hint: "The logo is also used as the site's favicon."
+ edit: "Edit site"
+ manage_pages: "Manage pages"
+ form:
+ logo_alt: "%{site} logo"
+ logo_hint: "Used for both the site logo and favicon."
+ remove_logo: "Remove logo"
+ create: "Create site"
+ save: "Save"
+ cancel: "Cancel"
+ webhook_deliveries:
+ index:
+ back: "Back to webhooks"
+ delivered_at: "Delivered at"
+ empty: "No deliveries yet."
+ error: "Error"
+ event: "Event"
+ response_code: "Response code"
+ status: "Success"
+ title: "Deliveries — %{url}"
+ webhooks:
+ edit:
+ back: "Back"
+ title: "Edit Webhook"
+ form:
+ events: "Events"
+ secret: "Secret (optional — used for HMAC signature)"
+ url: "Endpoint URL"
+ index:
+ active: "Active"
+ delete: "Delete"
+ delete_confirm: "Delete this webhook?"
+ edit: "Edit"
+ empty: "No webhooks yet."
+ events: "Events"
+ new: "New Webhook"
+ title: "Webhooks"
+ url: "URL"
+ new:
+ back: "Back"
+ title: "New Webhook"
diff --git a/config/routes.rb b/config/routes.rb
index c49d7b8..5333afe 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,2 +1,56 @@
+# frozen_string_literal: true
+
Cms::Engine.routes.draw do
+ namespace :admin do
+ resource :site, only: %i[new create show edit update]
+ resources :sections
+ resources :pages do
+ member do
+ get :preview
+ end
+ patch "sections/sort", to: "sections#sort", as: :sort_sections
+ resources :sections, except: :index
+ post "sections/attach", to: "sections#attach", as: :attach_section
+ resources :form_fields do
+ collection do
+ patch :sort
+ end
+ end
+ resources :form_submissions, only: %i[index destroy]
+ end
+ resources :images
+ resources :documents
+ resources :api_keys, except: %i[show]
+ resources :webhooks, except: %i[show] do
+ resources :deliveries, only: :index, controller: "webhook_deliveries"
+ end
+ end
+
+ namespace :api, defaults: { format: :json } do
+ namespace :v1 do
+ resources :sites, only: :show, param: :site_slug do
+ resources :pages, only: :show, param: :slug
+ end
+ resource :site, only: :show, controller: :sites
+ resources :pages, only: :show, param: :slug
+ end
+ end
+
+ scope module: :public do
+ # Public form submissions
+ resources :pages, only: [] do
+ resources :form_submissions, only: :create
+ end
+
+ # Public multi-site routes (site resolved via URL slug)
+ resources :sites, only: :show, param: :site_slug
+ get "sites/:site_slug/*slug", to: "sites#show", as: :site_page
+
+ # Draft preview (token-based, no auth required)
+ get "preview/:preview_token", to: "previews#show", as: :page_preview
+
+ # Public single-site routes (site resolved via header/subdomain)
+ get "/", to: "sites#show", as: :current_site
+ get "*slug", to: "sites#show", as: :current_site_page
+ end
end
diff --git a/lib/cms.rb b/lib/cms.rb
index 7e36fb4..77adaf4 100644
--- a/lib/cms.rb
+++ b/lib/cms.rb
@@ -1,6 +1,75 @@
+# frozen_string_literal: true
+
+require "discard"
require "cms/version"
require "cms/engine"
module Cms
- # Your code goes here...
+ class Configuration
+ attr_accessor :parent_controller,
+ :admin_parent_controller,
+ :current_site_resolver,
+ :form_submission_email,
+ :mailer_from,
+ :image_renditions,
+ :page_templates,
+ :page_resolver_class,
+ :api_site_serializer_class,
+ :api_page_serializer_class,
+ :admin_layout,
+ :public_layout,
+ :authorize_admin,
+ :auto_destroy_orphaned_sections
+
+ def initialize
+ @parent_controller = "ApplicationController"
+ @admin_parent_controller = nil
+ @admin_layout = "cms/application"
+ @public_layout = "cms/public"
+ @image_renditions = {}
+ @page_templates = []
+ @page_resolver_class = "Cms::PageResolver"
+ @api_site_serializer_class = "Cms::Api::SiteSerializer"
+ @api_page_serializer_class = "Cms::Api::PageSerializer"
+ @mailer_from = nil
+ @authorize_admin = nil
+ @auto_destroy_orphaned_sections = false
+ end
+ end
+
+ class << self
+ def config
+ @config ||= Configuration.new
+ end
+
+ def setup
+ yield config
+ end
+
+ def parent_controller
+ config.parent_controller
+ end
+
+ def parent_controller=(val)
+ config.parent_controller = val
+ end
+
+ def page_resolver_class
+ constantize_config_value(config.page_resolver_class)
+ end
+
+ def api_site_serializer_class
+ constantize_config_value(config.api_site_serializer_class)
+ end
+
+ def api_page_serializer_class
+ constantize_config_value(config.api_page_serializer_class)
+ end
+
+ private
+
+ def constantize_config_value(value)
+ value.is_a?(String) ? value.constantize : value
+ end
+ end
end
diff --git a/lib/cms/engine.rb b/lib/cms/engine.rb
index 80e30b7..7d11387 100644
--- a/lib/cms/engine.rb
+++ b/lib/cms/engine.rb
@@ -1,5 +1,45 @@
+# frozen_string_literal: true
+
+require "action_text/engine"
+
module Cms
class Engine < ::Rails::Engine
isolate_namespace Cms
+
+ initializer "cms.assets.precompile" do |app|
+ app.config.assets.precompile += %w[cms/application.css cms/application.js]
+ end
+
+ initializer "cms.importmap", before: "importmap" do |app|
+ if app.config.respond_to?(:importmap)
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
+ app.config.importmap.cache_sweepers << Engine.root.join("app/javascript")
+ end
+ end
+
+ config.after_initialize do
+ if Rails.env.production? && Cms.config.admin_parent_controller.blank?
+ raise "Cms: admin_parent_controller must be configured in production. " \
+ "Set it in your Cms.setup block, e.g.: config.admin_parent_controller = \"AdminController\""
+ end
+ end
+
+ config.to_prepare do
+ # Section block classes
+ section_block_classes = {
+ "rich_text" => Cms::Section::Blocks::RichTextBlock,
+ "image" => Cms::Section::Blocks::ImageBlock,
+ "hero" => Cms::Section::Blocks::HeroBlock,
+ "cta" => Cms::Section::Blocks::CallToActionBlock
+ }
+
+ Cms::Section::KindRegistry::BUILT_IN_KINDS.each do |kind|
+ Cms::Section::KindRegistry.register(kind, block_class: section_block_classes[kind])
+ end
+
+ Array(Cms.config.page_templates).each do |key|
+ Cms::Page::TemplateRegistry.register(key)
+ end
+ end
end
end
diff --git a/lib/cms/version.rb b/lib/cms/version.rb
index 4b1c97c..34139a2 100644
--- a/lib/cms/version.rb
+++ b/lib/cms/version.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
module Cms
VERSION = "0.1.0"
end
diff --git a/lib/generators/cms/install/install_generator.rb b/lib/generators/cms/install/install_generator.rb
new file mode 100644
index 0000000..d844dc3
--- /dev/null
+++ b/lib/generators/cms/install/install_generator.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require "rails/generators"
+require "rails/generators/active_record"
+
+module Cms
+ module Generators
+ class InstallGenerator < Rails::Generators::Base
+ include Rails::Generators::Migration
+
+ source_root File.expand_path("templates", __dir__)
+
+ def create_migrations
+ migration_template "create_cms_tables.rb", "db/migrate/create_cms_tables.rb"
+ end
+
+ def copy_initializer
+ template "initializer.rb", "config/initializers/cms.rb"
+ end
+
+ def self.next_migration_number(dirname)
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
+ end
+ end
+ end
+end
diff --git a/lib/generators/cms/install/templates/create_cms_tables.rb b/lib/generators/cms/install/templates/create_cms_tables.rb
new file mode 100644
index 0000000..51b2a55
--- /dev/null
+++ b/lib/generators/cms/install/templates/create_cms_tables.rb
@@ -0,0 +1,194 @@
+# frozen_string_literal: true
+
+class CreateCmsTables < ActiveRecord::Migration[7.1]
+ def change
+ enable_extension "unaccent" unless extension_enabled?("unaccent")
+
+ create_table :cms_sites do |t|
+ t.string :name, null: false
+ t.string :slug, null: false
+ t.boolean :published, null: false, default: false
+ t.string :default_locale, null: false, default: "en"
+ t.timestamps
+ end
+
+ add_index :cms_sites, :slug, unique: true
+
+ create_table :cms_pages do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.bigint :parent_id
+ t.string :slug, null: false
+ t.integer :position, null: false, default: 0
+ t.boolean :home, null: false, default: false
+ t.string :template_key, null: false, default: "standard"
+ t.string :status, null: false, default: "draft"
+ t.boolean :show_in_header, null: false, default: true
+ t.boolean :show_in_footer, null: false, default: false
+ t.string :nav_group, null: false, default: "main"
+ t.integer :nav_order, null: false, default: 0
+ t.integer :footer_order, null: false, default: 0
+ t.integer :depth, null: false, default: 0
+ t.string :preview_token
+ t.datetime :discarded_at
+ t.timestamps
+ end
+
+ add_index :cms_pages, %i[site_id slug], unique: true
+ add_index :cms_pages, %i[site_id status position]
+ add_index :cms_pages, %i[site_id nav_group nav_order]
+ add_index :cms_pages, :parent_id
+ add_index :cms_pages, :preview_token, unique: true
+ add_index :cms_pages, :discarded_at
+
+ add_foreign_key :cms_pages, :cms_pages, column: :parent_id
+
+ create_table :cms_page_translations do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.string :locale, null: false
+ t.string :title, null: false
+ t.string :seo_title
+ t.string :seo_description
+ t.timestamps
+ end
+
+ add_index :cms_page_translations, %i[page_id locale], unique: true
+
+ create_table :cms_images do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :title, null: false
+ t.timestamps
+ end
+
+ create_table :cms_image_translations do |t|
+ t.references :image, null: false, foreign_key: { to_table: :cms_images }
+ t.string :locale, null: false
+ t.string :alt_text, null: false
+ t.string :caption
+ t.timestamps
+ end
+
+ add_index :cms_image_translations, %i[image_id locale], unique: true
+
+ create_table :cms_sections do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :kind, null: false, default: "rich_text"
+ t.boolean :global, null: false, default: false
+ t.boolean :enabled, null: false, default: true
+ t.jsonb :settings, null: false, default: {}
+ t.datetime :discarded_at
+ t.timestamps
+ end
+
+ add_index :cms_sections, %i[site_id kind]
+ add_index :cms_sections, %i[site_id global kind]
+ add_index :cms_sections, :discarded_at
+
+ create_table :cms_section_translations do |t|
+ t.references :section, null: false, foreign_key: { to_table: :cms_sections }
+ t.string :locale, null: false
+ t.string :title, null: false
+ t.string :subtitle
+ t.timestamps
+ end
+
+ add_index :cms_section_translations, %i[section_id locale], unique: true
+
+ create_table :cms_section_images do |t|
+ t.references :section, null: false, foreign_key: { to_table: :cms_sections }
+ t.references :image, null: false, foreign_key: { to_table: :cms_images }
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ add_index :cms_section_images, %i[section_id position]
+ add_index :cms_section_images, %i[section_id image_id], unique: true
+
+ create_table :cms_documents do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :title, null: false
+ t.text :description
+ t.timestamps
+ end
+
+ create_table :cms_page_sections do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.references :section, null: false, foreign_key: { to_table: :cms_sections }
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ add_index :cms_page_sections, %i[page_id position]
+ add_index :cms_page_sections, %i[page_id section_id], unique: true
+
+ create_table :cms_form_fields do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.string :kind, null: false, default: "text"
+ t.string :label, null: false
+ t.string :field_name, null: false
+ t.string :placeholder
+ t.string :hint
+ t.boolean :required, null: false, default: false
+ t.jsonb :options, null: false, default: []
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ add_index :cms_form_fields, %i[page_id position]
+ add_index :cms_form_fields, %i[page_id field_name], unique: true
+
+ create_table :cms_form_submissions do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.jsonb :data, null: false, default: {}
+ t.string :ip_address
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
+ end
+
+ add_index :cms_form_submissions, :created_at
+
+ create_table :cms_api_keys do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :name, null: false
+ t.string :token, null: false
+ t.boolean :active, null: false, default: true
+ t.datetime :last_used_at
+ t.timestamps
+ end
+
+ add_index :cms_api_keys, :token, unique: true
+
+ create_table :cms_webhooks do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :url, null: false
+ t.jsonb :events, null: false, default: []
+ t.string :secret
+ t.boolean :active, null: false, default: true
+ t.timestamps
+ end
+
+ create_table :cms_webhook_deliveries do |t|
+ t.references :webhook, null: false, foreign_key: { to_table: :cms_webhooks }
+ t.string :event, null: false
+ t.integer :response_code
+ t.text :response_body
+ t.boolean :success, null: false, default: false
+ t.string :error_message
+ t.datetime :delivered_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
+ end
+
+ add_index :cms_webhook_deliveries, %i[webhook_id delivered_at]
+
+ return if table_exists?(:action_text_rich_texts)
+
+ create_table :action_text_rich_texts do |t|
+ t.string :name, null: false
+ t.text :body
+ t.references :record, null: false, polymorphic: true, index: false
+ t.timestamps
+ end
+
+ add_index :action_text_rich_texts,
+ %i[record_type record_id name],
+ unique: true,
+ name: "index_action_text_rich_texts_uniqueness"
+ end
+end
diff --git a/lib/generators/cms/install/templates/initializer.rb b/lib/generators/cms/install/templates/initializer.rb
new file mode 100644
index 0000000..7178f3d
--- /dev/null
+++ b/lib/generators/cms/install/templates/initializer.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Cms.setup do |config|
+ # Public/API controllers inherit from this host controller.
+ config.parent_controller = "ApplicationController"
+
+ # Required for admin in production: point this at a host controller that
+ # already enforces authentication and authorization for CMS access.
+ config.admin_parent_controller = "Admin::BaseController"
+
+ # Optional for public/API requests when you use site slugs, headers, or
+ # subdomains. Required for admin once any Cms::Site exists.
+ config.current_site_resolver = ->(controller) { controller.current_organization&.cms_site }
+
+ # Optional: replace the public/API page lookup service.
+ # config.page_resolver_class = "Cms::PageResolver"
+
+ # Optional: replace API serialization classes.
+ # config.api_site_serializer_class = "Cms::Api::SiteSerializer"
+ # config.api_page_serializer_class = "Cms::Api::PageSerializer"
+end
diff --git a/lib/generators/cms/views/views_generator.rb b/lib/generators/cms/views/views_generator.rb
new file mode 100644
index 0000000..b7de138
--- /dev/null
+++ b/lib/generators/cms/views/views_generator.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "rails/generators/base"
+
+module Cms
+ module Generators
+ class ViewsGenerator < Rails::Generators::Base
+ source_root Cms::Engine.root.join("app/views")
+ desc "Copies CMS views to your application."
+
+ argument :scope,
+ required: false,
+ default: nil,
+ desc: "Select a view scope (admin, public, mailer, all)"
+
+ class_option :views,
+ aliases: "-v",
+ type: :array,
+ desc: "Select specific view scopes to generate (admin, public, mailer)"
+
+ VIEW_GROUPS = {
+ "admin" => [
+ "cms/admin"
+ ],
+ "public" => [
+ "cms/sections",
+ "cms/public",
+ "layouts/cms"
+ ],
+ "mailer" => [
+ "cms/form_submission_mailer"
+ ],
+ "all" => [
+ "cms",
+ "layouts/cms"
+ ]
+ }.freeze
+
+ def copy_views
+ selected_groups.each do |group|
+ copy_group(group)
+ end
+ end
+
+ private
+
+ def selected_groups
+ groups =
+ if options[:views].present?
+ options[:views]
+ elsif scope.present?
+ [scope]
+ else
+ ["all"]
+ end
+
+ normalized = groups.map { |entry| entry.to_s.downcase }.uniq
+ invalid = normalized - VIEW_GROUPS.keys
+ return normalized if invalid.empty?
+
+ raise Thor::Error, "Unknown view scope(s): #{invalid.join(', ')}. " \
+ "Valid scopes: #{VIEW_GROUPS.keys.join(', ')}"
+ end
+
+ def copy_group(group)
+ VIEW_GROUPS.fetch(group).each do |relative_path|
+ source = Cms::Engine.root.join("app/views", relative_path)
+ target = File.join("app/views", relative_path)
+
+ if source.directory?
+ directory relative_path, target
+ else
+ copy_file relative_path, target
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/cms_tasks.rake b/lib/tasks/cms_tasks.rake
index 5cc7af3..a8b48a2 100644
--- a/lib/tasks/cms_tasks.rake
+++ b/lib/tasks/cms_tasks.rake
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
# desc "Explaining what the task does"
# task :cms do
# # Task goes here
diff --git a/lib/tasks/version.rake b/lib/tasks/version.rake
new file mode 100644
index 0000000..b229538
--- /dev/null
+++ b/lib/tasks/version.rake
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+namespace :cms do
+ desc "Print current gem version"
+ task :version do
+ puts Cms::VERSION
+ end
+end
diff --git a/spec/cms/version_spec.rb b/spec/cms/version_spec.rb
new file mode 100644
index 0000000..0199269
--- /dev/null
+++ b/spec/cms/version_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+require "cms/version"
+
+RSpec.describe Cms::VERSION do
+ it "has a version number" do
+ expect(Cms::VERSION).not_to be_nil
+ end
+end
diff --git a/spec/cms_app/Rakefile b/spec/cms_app/Rakefile
new file mode 100644
index 0000000..c4f9523
--- /dev/null
+++ b/spec/cms_app/Rakefile
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "config/application"
+
+Rails.application.load_tasks
diff --git a/spec/cms_app/app/assets/images/.keep b/spec/cms_app/app/assets/images/.keep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/spec/cms_app/app/assets/images/.keep
@@ -0,0 +1 @@
+
diff --git a/spec/cms_app/app/assets/stylesheets/application.css b/spec/cms_app/app/assets/stylesheets/application.css
new file mode 100644
index 0000000..38fa2fb
--- /dev/null
+++ b/spec/cms_app/app/assets/stylesheets/application.css
@@ -0,0 +1,4 @@
+/*
+ *= require_tree .
+ *= require_self
+ */
diff --git a/spec/cms_app/app/controllers/application_controller.rb b/spec/cms_app/app/controllers/application_controller.rb
new file mode 100644
index 0000000..7944f9f
--- /dev/null
+++ b/spec/cms_app/app/controllers/application_controller.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class ApplicationController < ActionController::Base
+end
diff --git a/spec/cms_app/app/controllers/concerns/.keep b/spec/cms_app/app/controllers/concerns/.keep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/spec/cms_app/app/controllers/concerns/.keep
@@ -0,0 +1 @@
+
diff --git a/spec/cms_app/app/helpers/application_helper.rb b/spec/cms_app/app/helpers/application_helper.rb
new file mode 100644
index 0000000..15b06f0
--- /dev/null
+++ b/spec/cms_app/app/helpers/application_helper.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+module ApplicationHelper
+end
diff --git a/spec/cms_app/app/javascript/application.js b/spec/cms_app/app/javascript/application.js
new file mode 100644
index 0000000..76c8ec2
--- /dev/null
+++ b/spec/cms_app/app/javascript/application.js
@@ -0,0 +1,2 @@
+import "@hotwired/turbo-rails"
+import "controllers"
diff --git a/spec/cms_app/app/javascript/controllers/application.js b/spec/cms_app/app/javascript/controllers/application.js
new file mode 100644
index 0000000..38c9def
--- /dev/null
+++ b/spec/cms_app/app/javascript/controllers/application.js
@@ -0,0 +1,7 @@
+import { Application } from "@hotwired/stimulus"
+
+const application = Application.start()
+application.debug = false
+window.Stimulus = application
+
+export { application }
diff --git a/spec/cms_app/app/javascript/controllers/index.js b/spec/cms_app/app/javascript/controllers/index.js
new file mode 100644
index 0000000..6ffb4e9
--- /dev/null
+++ b/spec/cms_app/app/javascript/controllers/index.js
@@ -0,0 +1,3 @@
+import { application } from "controllers/application"
+import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
+eagerLoadControllersFrom("controllers", application)
diff --git a/spec/cms_app/app/javascripts/application.js b/spec/cms_app/app/javascripts/application.js
new file mode 100644
index 0000000..7ea5408
--- /dev/null
+++ b/spec/cms_app/app/javascripts/application.js
@@ -0,0 +1 @@
+// Placeholder application JS for dummy app harness.
diff --git a/spec/cms_app/app/jobs/application_job.rb b/spec/cms_app/app/jobs/application_job.rb
new file mode 100644
index 0000000..d92ffdd
--- /dev/null
+++ b/spec/cms_app/app/jobs/application_job.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class ApplicationJob < ActiveJob::Base
+end
diff --git a/spec/cms_app/app/mailers/application_mailer.rb b/spec/cms_app/app/mailers/application_mailer.rb
new file mode 100644
index 0000000..5cc63a0
--- /dev/null
+++ b/spec/cms_app/app/mailers/application_mailer.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class ApplicationMailer < ActionMailer::Base
+ default from: "from@example.com"
+ layout "mailer"
+end
diff --git a/spec/cms_app/app/models/application_record.rb b/spec/cms_app/app/models/application_record.rb
new file mode 100644
index 0000000..08dc537
--- /dev/null
+++ b/spec/cms_app/app/models/application_record.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ApplicationRecord < ActiveRecord::Base
+ primary_abstract_class
+end
diff --git a/spec/cms_app/app/models/concerns/.keep b/spec/cms_app/app/models/concerns/.keep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/spec/cms_app/app/models/concerns/.keep
@@ -0,0 +1 @@
+
diff --git a/spec/cms_app/app/views/layouts/application.html.erb b/spec/cms_app/app/views/layouts/application.html.erb
new file mode 100644
index 0000000..a35139a
--- /dev/null
+++ b/spec/cms_app/app/views/layouts/application.html.erb
@@ -0,0 +1,13 @@
+
+
+
+ CmsApp
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
+
+
+ <%= yield %>
+
+
diff --git a/spec/cms_app/app/views/layouts/mailer.html.erb b/spec/cms_app/app/views/layouts/mailer.html.erb
new file mode 100644
index 0000000..1c95cd4
--- /dev/null
+++ b/spec/cms_app/app/views/layouts/mailer.html.erb
@@ -0,0 +1,6 @@
+
+
+
+ <%= yield %>
+
+
diff --git a/spec/cms_app/app/views/layouts/mailer.text.erb b/spec/cms_app/app/views/layouts/mailer.text.erb
new file mode 100644
index 0000000..37f0bdd
--- /dev/null
+++ b/spec/cms_app/app/views/layouts/mailer.text.erb
@@ -0,0 +1 @@
+<%= yield %>
diff --git a/spec/cms_app/app/views/pwa/manifest.json.erb b/spec/cms_app/app/views/pwa/manifest.json.erb
new file mode 100644
index 0000000..0ce5632
--- /dev/null
+++ b/spec/cms_app/app/views/pwa/manifest.json.erb
@@ -0,0 +1,10 @@
+{
+ "name": "CMS Test App",
+ "icons": [
+ {
+ "src": "/icon.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ]
+}
diff --git a/spec/cms_app/app/views/pwa/service-worker.js b/spec/cms_app/app/views/pwa/service-worker.js
new file mode 100644
index 0000000..2c08ad9
--- /dev/null
+++ b/spec/cms_app/app/views/pwa/service-worker.js
@@ -0,0 +1,2 @@
+self.addEventListener("install", () => self.skipWaiting());
+self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
diff --git a/spec/cms_app/bin/dev b/spec/cms_app/bin/dev
new file mode 100755
index 0000000..6981d91
--- /dev/null
+++ b/spec/cms_app/bin/dev
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+exec "./bin/rails", "server", *ARGV
diff --git a/spec/cms_app/bin/rails b/spec/cms_app/bin/rails
new file mode 100755
index 0000000..22f2d8d
--- /dev/null
+++ b/spec/cms_app/bin/rails
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+APP_PATH = File.expand_path("../config/application", __dir__)
+require_relative "../config/boot"
+require "rails/commands"
diff --git a/spec/cms_app/bin/rake b/spec/cms_app/bin/rake
new file mode 100755
index 0000000..e436ea5
--- /dev/null
+++ b/spec/cms_app/bin/rake
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require_relative "../config/boot"
+require "rake"
+Rake.application.run
diff --git a/spec/cms_app/bin/setup b/spec/cms_app/bin/setup
new file mode 100755
index 0000000..a6ce88c
--- /dev/null
+++ b/spec/cms_app/bin/setup
@@ -0,0 +1,27 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "fileutils"
+
+APP_ROOT = File.expand_path("..", __dir__)
+
+def system!(*)
+ system(*, exception: true)
+end
+
+FileUtils.chdir APP_ROOT do
+ puts "== Installing dependencies =="
+ system("bundle check") || system!("bundle install")
+
+ puts "\n== Preparing database =="
+ system! "bin/rails db:prepare"
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! "bin/rails log:clear tmp:clear"
+
+ unless ARGV.include?("--skip-server")
+ puts "\n== Starting development server =="
+ $stdout.flush
+ exec "bin/dev"
+ end
+end
diff --git a/spec/cms_app/config.ru b/spec/cms_app/config.ru
new file mode 100644
index 0000000..2797095
--- /dev/null
+++ b/spec/cms_app/config.ru
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+require_relative "config/environment"
+
+run Rails.application
+Rails.application.load_server
diff --git a/spec/cms_app/config/application.rb b/spec/cms_app/config/application.rb
new file mode 100644
index 0000000..d8e1dcb
--- /dev/null
+++ b/spec/cms_app/config/application.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require_relative "boot"
+
+require "rails"
+require "active_model/railtie"
+require "active_job/railtie"
+require "active_record/railtie"
+require "active_storage/engine"
+require "action_text/engine"
+require "action_controller/railtie"
+require "action_mailer/railtie"
+require "action_view/railtie"
+
+Bundler.require(*Rails.groups)
+
+module CmsApp
+ class Application < Rails::Application
+ config.load_defaults Rails::VERSION::STRING.to_f
+ config.eager_load = false
+ config.action_controller.include_all_helpers = false
+ config.hosts.clear
+ config.generators.system_tests = nil
+ config.active_support.deprecation = :stderr
+ config.i18n.available_locales = %i[en el fr de ja]
+ config.i18n.default_locale = :en
+ end
+end
diff --git a/spec/cms_app/config/boot.rb b/spec/cms_app/config/boot.rb
new file mode 100644
index 0000000..c231823
--- /dev/null
+++ b/spec/cms_app/config/boot.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __dir__)
+
+require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])
+$LOAD_PATH.unshift File.expand_path("../../../../lib", __dir__)
diff --git a/spec/cms_app/config/database.yml b/spec/cms_app/config/database.yml
new file mode 100644
index 0000000..bbb446b
--- /dev/null
+++ b/spec/cms_app/config/database.yml
@@ -0,0 +1,16 @@
+default: &default
+ adapter: postgresql
+ encoding: unicode
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
+
+development:
+ <<: *default
+ database: cms_development
+
+test:
+ <<: *default
+ database: cms_test
+
+production:
+ <<: *default
+ database: cms_production
diff --git a/spec/cms_app/config/environment.rb b/spec/cms_app/config/environment.rb
new file mode 100644
index 0000000..e8173e0
--- /dev/null
+++ b/spec/cms_app/config/environment.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+require_relative "application"
+
+Rails.application.initialize!
diff --git a/spec/cms_app/config/environments/development.rb b/spec/cms_app/config/environments/development.rb
new file mode 100644
index 0000000..faa692e
--- /dev/null
+++ b/spec/cms_app/config/environments/development.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Rails.application.configure do
+ config.cache_classes = false
+ config.eager_load = false
+ config.consider_all_requests_local = true
+end
diff --git a/spec/cms_app/config/environments/production.rb b/spec/cms_app/config/environments/production.rb
new file mode 100644
index 0000000..1404eb7
--- /dev/null
+++ b/spec/cms_app/config/environments/production.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Rails.application.configure do
+ config.cache_classes = true
+ config.eager_load = true
+ config.consider_all_requests_local = false
+end
diff --git a/spec/cms_app/config/environments/test.rb b/spec/cms_app/config/environments/test.rb
new file mode 100644
index 0000000..3b9249c
--- /dev/null
+++ b/spec/cms_app/config/environments/test.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+Rails.application.configure do
+ config.cache_classes = true
+ config.eager_load = false
+ config.public_file_server.enabled = true
+ config.consider_all_requests_local = true
+ config.action_controller.allow_forgery_protection = false
+ config.active_storage.service = :test
+ config.active_support.deprecation = :stderr
+end
diff --git a/spec/cms_app/config/importmap.rb b/spec/cms_app/config/importmap.rb
new file mode 100644
index 0000000..c381e99
--- /dev/null
+++ b/spec/cms_app/config/importmap.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+pin "application"
+pin "@hotwired/turbo-rails", to: "turbo.es2017-esm.js", preload: true
+pin "@hotwired/stimulus", to: "stimulus.min.js"
+pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
+pin_all_from "app/javascript/controllers", under: "controllers"
diff --git a/spec/cms_app/config/initializers/assets.rb b/spec/cms_app/config/initializers/assets.rb
new file mode 100644
index 0000000..0552da4
--- /dev/null
+++ b/spec/cms_app/config/initializers/assets.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+Rails.application.config.assets.version = "1.0"
diff --git a/spec/cms_app/config/initializers/content_security_policy.rb b/spec/cms_app/config/initializers/content_security_policy.rb
new file mode 100644
index 0000000..6da9380
--- /dev/null
+++ b/spec/cms_app/config/initializers/content_security_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Rails.application.configure do
+# config.content_security_policy do |policy|
+# policy.default_src :self, :https
+# end
+# end
diff --git a/spec/cms_app/config/initializers/filter_parameter_logging.rb b/spec/cms_app/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000..a598a55
--- /dev/null
+++ b/spec/cms_app/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Configure parameters to be filtered from log files.
+Rails.application.config.filter_parameters += %i[
+ passw
+ password
+]
diff --git a/spec/cms_app/config/initializers/inflections.rb b/spec/cms_app/config/initializers/inflections.rb
new file mode 100644
index 0000000..c93a545
--- /dev/null
+++ b/spec/cms_app/config/initializers/inflections.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# Add new inflection rules using the following format.
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.irregular "person", "people"
+# end
diff --git a/spec/cms_app/config/locales/en.yml b/spec/cms_app/config/locales/en.yml
new file mode 100644
index 0000000..a9f72ec
--- /dev/null
+++ b/spec/cms_app/config/locales/en.yml
@@ -0,0 +1,2 @@
+en:
+ hello: "Hello world"
diff --git a/spec/cms_app/config/puma.rb b/spec/cms_app/config/puma.rb
new file mode 100644
index 0000000..d2e056c
--- /dev/null
+++ b/spec/cms_app/config/puma.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
+threads threads_count, threads_count
+
+port ENV.fetch("PORT", 3000)
+
+plugin :tmp_restart
+
+pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
diff --git a/spec/cms_app/config/routes.rb b/spec/cms_app/config/routes.rb
new file mode 100644
index 0000000..76f04ba
--- /dev/null
+++ b/spec/cms_app/config/routes.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ mount Cms::Engine => "/"
+end
diff --git a/spec/cms_app/config/storage.yml b/spec/cms_app/config/storage.yml
new file mode 100644
index 0000000..695f17b
--- /dev/null
+++ b/spec/cms_app/config/storage.yml
@@ -0,0 +1,7 @@
+test:
+ service: Disk
+ root: <%= Rails.root.join("tmp/storage") %>
+
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
diff --git a/spec/cms_app/db/migrate/20260225000000_create_cms_tables.rb b/spec/cms_app/db/migrate/20260225000000_create_cms_tables.rb
new file mode 100644
index 0000000..51b2a55
--- /dev/null
+++ b/spec/cms_app/db/migrate/20260225000000_create_cms_tables.rb
@@ -0,0 +1,194 @@
+# frozen_string_literal: true
+
+class CreateCmsTables < ActiveRecord::Migration[7.1]
+ def change
+ enable_extension "unaccent" unless extension_enabled?("unaccent")
+
+ create_table :cms_sites do |t|
+ t.string :name, null: false
+ t.string :slug, null: false
+ t.boolean :published, null: false, default: false
+ t.string :default_locale, null: false, default: "en"
+ t.timestamps
+ end
+
+ add_index :cms_sites, :slug, unique: true
+
+ create_table :cms_pages do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.bigint :parent_id
+ t.string :slug, null: false
+ t.integer :position, null: false, default: 0
+ t.boolean :home, null: false, default: false
+ t.string :template_key, null: false, default: "standard"
+ t.string :status, null: false, default: "draft"
+ t.boolean :show_in_header, null: false, default: true
+ t.boolean :show_in_footer, null: false, default: false
+ t.string :nav_group, null: false, default: "main"
+ t.integer :nav_order, null: false, default: 0
+ t.integer :footer_order, null: false, default: 0
+ t.integer :depth, null: false, default: 0
+ t.string :preview_token
+ t.datetime :discarded_at
+ t.timestamps
+ end
+
+ add_index :cms_pages, %i[site_id slug], unique: true
+ add_index :cms_pages, %i[site_id status position]
+ add_index :cms_pages, %i[site_id nav_group nav_order]
+ add_index :cms_pages, :parent_id
+ add_index :cms_pages, :preview_token, unique: true
+ add_index :cms_pages, :discarded_at
+
+ add_foreign_key :cms_pages, :cms_pages, column: :parent_id
+
+ create_table :cms_page_translations do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.string :locale, null: false
+ t.string :title, null: false
+ t.string :seo_title
+ t.string :seo_description
+ t.timestamps
+ end
+
+ add_index :cms_page_translations, %i[page_id locale], unique: true
+
+ create_table :cms_images do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :title, null: false
+ t.timestamps
+ end
+
+ create_table :cms_image_translations do |t|
+ t.references :image, null: false, foreign_key: { to_table: :cms_images }
+ t.string :locale, null: false
+ t.string :alt_text, null: false
+ t.string :caption
+ t.timestamps
+ end
+
+ add_index :cms_image_translations, %i[image_id locale], unique: true
+
+ create_table :cms_sections do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :kind, null: false, default: "rich_text"
+ t.boolean :global, null: false, default: false
+ t.boolean :enabled, null: false, default: true
+ t.jsonb :settings, null: false, default: {}
+ t.datetime :discarded_at
+ t.timestamps
+ end
+
+ add_index :cms_sections, %i[site_id kind]
+ add_index :cms_sections, %i[site_id global kind]
+ add_index :cms_sections, :discarded_at
+
+ create_table :cms_section_translations do |t|
+ t.references :section, null: false, foreign_key: { to_table: :cms_sections }
+ t.string :locale, null: false
+ t.string :title, null: false
+ t.string :subtitle
+ t.timestamps
+ end
+
+ add_index :cms_section_translations, %i[section_id locale], unique: true
+
+ create_table :cms_section_images do |t|
+ t.references :section, null: false, foreign_key: { to_table: :cms_sections }
+ t.references :image, null: false, foreign_key: { to_table: :cms_images }
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ add_index :cms_section_images, %i[section_id position]
+ add_index :cms_section_images, %i[section_id image_id], unique: true
+
+ create_table :cms_documents do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :title, null: false
+ t.text :description
+ t.timestamps
+ end
+
+ create_table :cms_page_sections do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.references :section, null: false, foreign_key: { to_table: :cms_sections }
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ add_index :cms_page_sections, %i[page_id position]
+ add_index :cms_page_sections, %i[page_id section_id], unique: true
+
+ create_table :cms_form_fields do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.string :kind, null: false, default: "text"
+ t.string :label, null: false
+ t.string :field_name, null: false
+ t.string :placeholder
+ t.string :hint
+ t.boolean :required, null: false, default: false
+ t.jsonb :options, null: false, default: []
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ add_index :cms_form_fields, %i[page_id position]
+ add_index :cms_form_fields, %i[page_id field_name], unique: true
+
+ create_table :cms_form_submissions do |t|
+ t.references :page, null: false, foreign_key: { to_table: :cms_pages }
+ t.jsonb :data, null: false, default: {}
+ t.string :ip_address
+ t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
+ end
+
+ add_index :cms_form_submissions, :created_at
+
+ create_table :cms_api_keys do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :name, null: false
+ t.string :token, null: false
+ t.boolean :active, null: false, default: true
+ t.datetime :last_used_at
+ t.timestamps
+ end
+
+ add_index :cms_api_keys, :token, unique: true
+
+ create_table :cms_webhooks do |t|
+ t.references :site, null: false, foreign_key: { to_table: :cms_sites }
+ t.string :url, null: false
+ t.jsonb :events, null: false, default: []
+ t.string :secret
+ t.boolean :active, null: false, default: true
+ t.timestamps
+ end
+
+ create_table :cms_webhook_deliveries do |t|
+ t.references :webhook, null: false, foreign_key: { to_table: :cms_webhooks }
+ t.string :event, null: false
+ t.integer :response_code
+ t.text :response_body
+ t.boolean :success, null: false, default: false
+ t.string :error_message
+ t.datetime :delivered_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
+ end
+
+ add_index :cms_webhook_deliveries, %i[webhook_id delivered_at]
+
+ return if table_exists?(:action_text_rich_texts)
+
+ create_table :action_text_rich_texts do |t|
+ t.string :name, null: false
+ t.text :body
+ t.references :record, null: false, polymorphic: true, index: false
+ t.timestamps
+ end
+
+ add_index :action_text_rich_texts,
+ %i[record_type record_id name],
+ unique: true,
+ name: "index_action_text_rich_texts_uniqueness"
+ end
+end
diff --git a/spec/cms_app/db/migrate/20260225000001_create_active_storage_tables.rb b/spec/cms_app/db/migrate/20260225000001_create_active_storage_tables.rb
new file mode 100644
index 0000000..9ec9dbb
--- /dev/null
+++ b/spec/cms_app/db/migrate/20260225000001_create_active_storage_tables.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class CreateActiveStorageTables < ActiveRecord::Migration[8.1]
+ def change
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
+
+ create_table :active_storage_blobs, id: primary_key_type do |t|
+ t.string :key, null: false
+ t.string :filename, null: false
+ t.string :content_type
+ t.text :metadata
+ t.string :service_name, null: false
+ t.bigint :byte_size, null: false
+ t.string :checksum
+ t.datetime :created_at, null: false
+ t.index [:key], unique: true
+ end
+
+ create_table :active_storage_attachments, id: primary_key_type do |t|
+ t.string :name, null: false
+ t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
+ t.references :blob, null: false, type: foreign_key_type
+ t.datetime :created_at, null: false
+
+ t.index %i[record_type record_id name blob_id], name: :index_active_storage_attachments_uniqueness,
+ unique: true
+ t.foreign_key :active_storage_blobs, column: :blob_id
+ end
+
+ create_table :active_storage_variant_records, id: primary_key_type do |t|
+ t.belongs_to :blob, null: false, index: false, type: foreign_key_type
+ t.string :variation_digest, null: false
+
+ t.index %i[blob_id variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true
+ t.foreign_key :active_storage_blobs, column: :blob_id
+ end
+ end
+
+ private
+
+ def primary_and_foreign_key_types
+ config = Rails.configuration.generators
+ setting = config.options[config.orm][:primary_key_type]
+ primary_key_type = setting || :primary_key
+ foreign_key_type = setting || :bigint
+ [primary_key_type, foreign_key_type]
+ end
+end
diff --git a/spec/cms_app/db/schema.rb b/spec/cms_app/db/schema.rb
new file mode 100644
index 0000000..e355049
--- /dev/null
+++ b/spec/cms_app/db/schema.rb
@@ -0,0 +1,263 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[8.1].define(version: 2026_02_25_000001) do
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "pg_catalog.plpgsql"
+ enable_extension "unaccent"
+
+ create_table "action_text_rich_texts", force: :cascade do |t|
+ t.text "body"
+ t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
+ t.datetime "updated_at", null: false
+ t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
+ end
+
+ create_table "active_storage_attachments", force: :cascade do |t|
+ t.bigint "blob_id", null: false
+ t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.bigint "record_id", null: false
+ t.string "record_type", null: false
+ t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
+ t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
+ end
+
+ create_table "active_storage_blobs", force: :cascade do |t|
+ t.bigint "byte_size", null: false
+ t.string "checksum"
+ t.string "content_type"
+ t.datetime "created_at", null: false
+ t.string "filename", null: false
+ t.string "key", null: false
+ t.text "metadata"
+ t.string "service_name", null: false
+ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
+ end
+
+ create_table "active_storage_variant_records", force: :cascade do |t|
+ t.bigint "blob_id", null: false
+ t.string "variation_digest", null: false
+ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
+ end
+
+ create_table "cms_api_keys", force: :cascade do |t|
+ t.boolean "active", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "last_used_at"
+ t.string "name", null: false
+ t.bigint "site_id", null: false
+ t.string "token", null: false
+ t.datetime "updated_at", null: false
+ t.index ["site_id"], name: "index_cms_api_keys_on_site_id"
+ t.index ["token"], name: "index_cms_api_keys_on_token", unique: true
+ end
+
+ create_table "cms_documents", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "description"
+ t.bigint "site_id", null: false
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.index ["site_id"], name: "index_cms_documents_on_site_id"
+ end
+
+ create_table "cms_form_fields", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "field_name", null: false
+ t.string "hint"
+ t.string "kind", default: "text", null: false
+ t.string "label", null: false
+ t.jsonb "options", default: [], null: false
+ t.bigint "page_id", null: false
+ t.string "placeholder"
+ t.integer "position", default: 0, null: false
+ t.boolean "required", default: false, null: false
+ t.datetime "updated_at", null: false
+ t.index ["page_id", "field_name"], name: "index_cms_form_fields_on_page_id_and_field_name", unique: true
+ t.index ["page_id", "position"], name: "index_cms_form_fields_on_page_id_and_position"
+ t.index ["page_id"], name: "index_cms_form_fields_on_page_id"
+ end
+
+ create_table "cms_form_submissions", force: :cascade do |t|
+ t.datetime "created_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
+ t.jsonb "data", default: {}, null: false
+ t.string "ip_address"
+ t.bigint "page_id", null: false
+ t.index ["created_at"], name: "index_cms_form_submissions_on_created_at"
+ t.index ["page_id"], name: "index_cms_form_submissions_on_page_id"
+ end
+
+ create_table "cms_image_translations", force: :cascade do |t|
+ t.string "alt_text", null: false
+ t.string "caption"
+ t.datetime "created_at", null: false
+ t.bigint "image_id", null: false
+ t.string "locale", null: false
+ t.datetime "updated_at", null: false
+ t.index ["image_id", "locale"], name: "index_cms_image_translations_on_image_id_and_locale", unique: true
+ t.index ["image_id"], name: "index_cms_image_translations_on_image_id"
+ end
+
+ create_table "cms_images", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "site_id", null: false
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.index ["site_id"], name: "index_cms_images_on_site_id"
+ end
+
+ create_table "cms_page_sections", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "page_id", null: false
+ t.integer "position", default: 0, null: false
+ t.bigint "section_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["page_id", "position"], name: "index_cms_page_sections_on_page_id_and_position"
+ t.index ["page_id", "section_id"], name: "index_cms_page_sections_on_page_id_and_section_id", unique: true
+ t.index ["page_id"], name: "index_cms_page_sections_on_page_id"
+ t.index ["section_id"], name: "index_cms_page_sections_on_section_id"
+ end
+
+ create_table "cms_page_translations", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "locale", null: false
+ t.bigint "page_id", null: false
+ t.string "seo_description"
+ t.string "seo_title"
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.index ["page_id", "locale"], name: "index_cms_page_translations_on_page_id_and_locale", unique: true
+ t.index ["page_id"], name: "index_cms_page_translations_on_page_id"
+ end
+
+ create_table "cms_pages", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "depth", default: 0, null: false
+ t.datetime "discarded_at"
+ t.integer "footer_order", default: 0, null: false
+ t.boolean "home", default: false, null: false
+ t.string "nav_group", default: "main", null: false
+ t.integer "nav_order", default: 0, null: false
+ t.bigint "parent_id"
+ t.integer "position", default: 0, null: false
+ t.string "preview_token"
+ t.boolean "show_in_footer", default: false, null: false
+ t.boolean "show_in_header", default: true, null: false
+ t.bigint "site_id", null: false
+ t.string "slug", null: false
+ t.string "status", default: "draft", null: false
+ t.string "template_key", default: "standard", null: false
+ t.datetime "updated_at", null: false
+ t.index ["discarded_at"], name: "index_cms_pages_on_discarded_at"
+ t.index ["parent_id"], name: "index_cms_pages_on_parent_id"
+ t.index ["preview_token"], name: "index_cms_pages_on_preview_token", unique: true
+ t.index ["site_id", "nav_group", "nav_order"], name: "index_cms_pages_on_site_id_and_nav_group_and_nav_order"
+ t.index ["site_id", "slug"], name: "index_cms_pages_on_site_id_and_slug", unique: true
+ t.index ["site_id", "status", "position"], name: "index_cms_pages_on_site_id_and_status_and_position"
+ t.index ["site_id"], name: "index_cms_pages_on_site_id"
+ end
+
+ create_table "cms_sections", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "discarded_at"
+ t.boolean "enabled", default: true, null: false
+ t.boolean "global", default: false, null: false
+ t.string "kind", default: "rich_text", null: false
+ t.jsonb "settings", default: {}, null: false
+ t.bigint "site_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["discarded_at"], name: "index_cms_sections_on_discarded_at"
+ t.index ["site_id", "global", "kind"], name: "index_cms_sections_on_site_id_and_global_and_kind"
+ t.index ["site_id", "kind"], name: "index_cms_sections_on_site_id_and_kind"
+ t.index ["site_id"], name: "index_cms_sections_on_site_id"
+ end
+
+ create_table "cms_section_translations", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "locale", null: false
+ t.bigint "section_id", null: false
+ t.string "subtitle"
+ t.string "title", null: false
+ t.datetime "updated_at", null: false
+ t.index ["section_id", "locale"], name: "index_cms_section_translations_on_section_id_and_locale", unique: true
+ t.index ["section_id"], name: "index_cms_section_translations_on_section_id"
+ end
+
+ create_table "cms_section_images", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "image_id", null: false
+ t.integer "position", default: 0, null: false
+ t.bigint "section_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["section_id", "image_id"], name: "index_cms_section_images_on_section_id_and_image_id", unique: true
+ t.index ["section_id", "position"], name: "index_cms_section_images_on_section_id_and_position"
+ t.index ["image_id"], name: "index_cms_section_images_on_image_id"
+ t.index ["section_id"], name: "index_cms_section_images_on_section_id"
+ end
+
+ create_table "cms_sites", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "default_locale", default: "en", null: false
+ t.string "name", null: false
+ t.boolean "published", default: false, null: false
+ t.string "slug", null: false
+ t.datetime "updated_at", null: false
+ t.index ["slug"], name: "index_cms_sites_on_slug", unique: true
+ end
+
+ create_table "cms_webhook_deliveries", force: :cascade do |t|
+ t.datetime "delivered_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
+ t.string "error_message"
+ t.string "event", null: false
+ t.text "response_body"
+ t.integer "response_code"
+ t.boolean "success", default: false, null: false
+ t.bigint "webhook_id", null: false
+ t.index ["webhook_id", "delivered_at"], name: "index_cms_webhook_deliveries_on_webhook_id_and_delivered_at"
+ t.index ["webhook_id"], name: "index_cms_webhook_deliveries_on_webhook_id"
+ end
+
+ create_table "cms_webhooks", force: :cascade do |t|
+ t.boolean "active", default: true, null: false
+ t.datetime "created_at", null: false
+ t.jsonb "events", default: [], null: false
+ t.string "secret"
+ t.bigint "site_id", null: false
+ t.datetime "updated_at", null: false
+ t.string "url", null: false
+ t.index ["site_id"], name: "index_cms_webhooks_on_site_id"
+ end
+
+ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "cms_api_keys", "cms_sites", column: "site_id"
+ add_foreign_key "cms_documents", "cms_sites", column: "site_id"
+ add_foreign_key "cms_form_fields", "cms_pages", column: "page_id"
+ add_foreign_key "cms_form_submissions", "cms_pages", column: "page_id"
+ add_foreign_key "cms_image_translations", "cms_images", column: "image_id"
+ add_foreign_key "cms_images", "cms_sites", column: "site_id"
+ add_foreign_key "cms_page_sections", "cms_pages", column: "page_id"
+ add_foreign_key "cms_page_sections", "cms_sections", column: "section_id"
+ add_foreign_key "cms_page_translations", "cms_pages", column: "page_id"
+ add_foreign_key "cms_pages", "cms_pages", column: "parent_id"
+ add_foreign_key "cms_pages", "cms_sites", column: "site_id"
+ add_foreign_key "cms_section_images", "cms_images", column: "image_id"
+ add_foreign_key "cms_section_images", "cms_sections", column: "section_id"
+ add_foreign_key "cms_section_translations", "cms_sections", column: "section_id"
+ add_foreign_key "cms_sections", "cms_sites", column: "site_id"
+ add_foreign_key "cms_webhook_deliveries", "cms_webhooks", column: "webhook_id"
+ add_foreign_key "cms_webhooks", "cms_sites", column: "site_id"
+end
diff --git a/spec/cms_app/db/seed.rb b/spec/cms_app/db/seed.rb
new file mode 100644
index 0000000..50e3e15
--- /dev/null
+++ b/spec/cms_app/db/seed.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+site = Cms::Site.create!(
+ name: "Demo Site",
+ slug: "demo"
+)
+
+[
+ { slug: "home", home: true, title: "Home", body: "Welcome to CMS demo" },
+ { slug: "about", title: "About", body: "About us..." },
+ { slug: "contact", title: "Contact", body: "Get in touch..." }
+].each do |attrs|
+ page = site.pages.create!(
+ slug: attrs[:slug],
+ home: attrs[:home] || false,
+ status: "published",
+ nav_group: attrs[:nav_group] || "main"
+ )
+ page.page_translations.create!(locale: "en", title: attrs[:title])
+end
+
+puts "Seeded test data in cms_app!"
diff --git a/spec/cms_app/public/400.html b/spec/cms_app/public/400.html
new file mode 100644
index 0000000..48d814a
--- /dev/null
+++ b/spec/cms_app/public/400.html
@@ -0,0 +1,5 @@
+
+
+ 400 Bad Request
+ 400 Bad Request
+
diff --git a/spec/cms_app/public/404.html b/spec/cms_app/public/404.html
new file mode 100644
index 0000000..6e2f206
--- /dev/null
+++ b/spec/cms_app/public/404.html
@@ -0,0 +1,5 @@
+
+
+ 404 Not Found
+ 404 Not Found
+
diff --git a/spec/cms_app/public/406-unsupported-browser.html b/spec/cms_app/public/406-unsupported-browser.html
new file mode 100644
index 0000000..ada33e8
--- /dev/null
+++ b/spec/cms_app/public/406-unsupported-browser.html
@@ -0,0 +1,5 @@
+
+
+ 406 Unsupported Browser
+ 406 Unsupported Browser
+
diff --git a/spec/cms_app/public/422.html b/spec/cms_app/public/422.html
new file mode 100644
index 0000000..33b5056
--- /dev/null
+++ b/spec/cms_app/public/422.html
@@ -0,0 +1,5 @@
+
+
+ 422 Unprocessable Entity
+ 422 Unprocessable Entity
+
diff --git a/spec/cms_app/public/500.html b/spec/cms_app/public/500.html
new file mode 100644
index 0000000..d7cb23e
--- /dev/null
+++ b/spec/cms_app/public/500.html
@@ -0,0 +1,5 @@
+
+
+ 500 Internal Server Error
+ 500 Internal Server Error
+
diff --git a/spec/cms_app/public/icon.png b/spec/cms_app/public/icon.png
new file mode 100644
index 0000000..c4c9dbf
Binary files /dev/null and b/spec/cms_app/public/icon.png differ
diff --git a/spec/cms_app/public/icon.svg b/spec/cms_app/public/icon.svg
new file mode 100644
index 0000000..04b34bf
--- /dev/null
+++ b/spec/cms_app/public/icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/spec/cms_app/spec/factories/api_keys.rb b/spec/cms_app/spec/factories/api_keys.rb
new file mode 100644
index 0000000..5004565
--- /dev/null
+++ b/spec/cms_app/spec/factories/api_keys.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_api_key, class: "Cms::ApiKey" do
+ association :site, factory: :cms_site
+ sequence(:name) { |n| "Key #{n}" }
+ active { true }
+ end
+end
diff --git a/spec/cms_app/spec/factories/form_fields.rb b/spec/cms_app/spec/factories/form_fields.rb
new file mode 100644
index 0000000..863a273
--- /dev/null
+++ b/spec/cms_app/spec/factories/form_fields.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_form_field, class: "Cms::FormField" do
+ association :page, factory: :cms_page
+ kind { "text" }
+ sequence(:label) { |n| "Field #{n}" }
+ sequence(:field_name) { |n| "field_#{n}" }
+ required { false }
+ position { 0 }
+ options { [] }
+ end
+end
diff --git a/spec/cms_app/spec/factories/images.rb b/spec/cms_app/spec/factories/images.rb
new file mode 100644
index 0000000..e2286f9
--- /dev/null
+++ b/spec/cms_app/spec/factories/images.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_image, class: "Cms::Image" do
+ association :site, factory: :cms_site
+ sequence(:title) { |n| "Image #{n}" }
+
+ after(:build) do |image|
+ if image.image_translations.empty?
+ image.image_translations.build(locale: "en", alt_text: "Alt text", caption: "Caption")
+ end
+
+ next if image.file.attached?
+
+ image.file.attach(
+ io: File.open(Rails.root.join("public/icon.png")),
+ filename: "icon.png",
+ content_type: "image/png"
+ )
+ end
+ end
+end
diff --git a/spec/cms_app/spec/factories/page_translations.rb b/spec/cms_app/spec/factories/page_translations.rb
new file mode 100644
index 0000000..6ebdb8f
--- /dev/null
+++ b/spec/cms_app/spec/factories/page_translations.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_page_translation, class: "Cms::PageTranslation" do
+ association :page, factory: :cms_page
+ locale { "en" }
+ sequence(:title) { |n| "Page #{n}" }
+ end
+end
diff --git a/spec/cms_app/spec/factories/pages.rb b/spec/cms_app/spec/factories/pages.rb
new file mode 100644
index 0000000..c0bb27e
--- /dev/null
+++ b/spec/cms_app/spec/factories/pages.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_page, class: "Cms::Page" do
+ association :site, factory: :cms_site
+ sequence(:slug) { |n| "page-#{n}" }
+ template_key { "standard" }
+ status { "draft" }
+ show_in_header { true }
+ show_in_footer { false }
+ nav_group { "main" }
+ nav_order { 0 }
+ footer_order { 0 }
+ position { 0 }
+
+ trait :published do
+ after(:create) do |page|
+ next if page.page_sections.exists?
+
+ section = create(:cms_section, site: page.site, kind: "rich_text")
+ create(:cms_section_translation,
+ section: section,
+ locale: page.site.default_locale,
+ title: "Section",
+ content: "Section body")
+ create(:cms_page_section, page: page, section: section, position: 0)
+ page.update!(status: "published")
+ end
+ end
+ end
+end
diff --git a/spec/cms_app/spec/factories/sections.rb b/spec/cms_app/spec/factories/sections.rb
new file mode 100644
index 0000000..930078e
--- /dev/null
+++ b/spec/cms_app/spec/factories/sections.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_section, class: "Cms::Section" do
+ association :site, factory: :cms_site
+ kind { "rich_text" }
+ global { false }
+ enabled { true }
+ settings { {} }
+
+ trait :hero do
+ kind { "hero" }
+ settings { { "background_color" => "#ffffff", "cta_url" => "https://example.com/start" } }
+ end
+
+ trait :cta do
+ kind { "cta" }
+ settings { { "button_url" => "https://example.com", "alignment" => "center" } }
+ end
+
+ trait :image do
+ kind { "image" }
+ end
+
+ trait :global do
+ global { true }
+ end
+ end
+
+ factory :cms_section_translation, class: "Cms::SectionTranslation" do
+ association :section, factory: :cms_section
+ locale { "en" }
+ title { "Section title" }
+
+ trait :with_subtitle do
+ subtitle { "Section subtitle" }
+ end
+
+ transient do
+ content { nil }
+ end
+
+ after(:build) do |translation, evaluator|
+ translation.content = evaluator.content if evaluator.content.present?
+ end
+ end
+
+ factory :cms_section_image, class: "Cms::SectionImage" do
+ association :section, factory: :cms_section
+ association :image, factory: :cms_image
+ position { 0 }
+ end
+
+ factory :cms_page_section, class: "Cms::PageSection" do
+ association :page, factory: :cms_page
+ association :section, factory: :cms_section
+ position { 0 }
+
+ after(:build) do |page_section|
+ page_section.section.site = page_section.page.site
+ end
+ end
+end
diff --git a/spec/cms_app/spec/factories/sites.rb b/spec/cms_app/spec/factories/sites.rb
new file mode 100644
index 0000000..86a1fc9
--- /dev/null
+++ b/spec/cms_app/spec/factories/sites.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_site, class: "Cms::Site" do
+ sequence(:name) { |n| "Site #{n}" }
+ sequence(:slug) { |n| "site-#{n}" }
+ published { true }
+ default_locale { "en" }
+ end
+end
diff --git a/spec/cms_app/spec/factories/webhooks.rb b/spec/cms_app/spec/factories/webhooks.rb
new file mode 100644
index 0000000..b355646
--- /dev/null
+++ b/spec/cms_app/spec/factories/webhooks.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :cms_webhook, class: "Cms::Webhook" do
+ association :site, factory: :cms_site
+ sequence(:url) { |n| "https://example#{n}.test/webhooks/cms" }
+ events { ["page.published"] }
+ active { true }
+ secret { "secret-token" }
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/api_key_spec.rb b/spec/cms_app/spec/models/cms/api_key_spec.rb
new file mode 100644
index 0000000..83d487e
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/api_key_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Cms::ApiKey, type: :model do
+ let!(:site) { create(:cms_site) }
+
+ describe "token generation" do
+ it "generates a token before create" do
+ key = create(:cms_api_key, site: site)
+ expect(key.token).to be_present
+ expect(key.token.length).to eq(64)
+ end
+
+ it "generates unique tokens" do
+ k1 = create(:cms_api_key, site: site)
+ k2 = create(:cms_api_key, site: site)
+ expect(k1.token).not_to eq(k2.token)
+ end
+ end
+
+ describe "scopes" do
+ let!(:active_key) { create(:cms_api_key, site: site, active: true) }
+ let!(:inactive_key) { create(:cms_api_key, site: site, active: false) }
+
+ it "active returns only active keys" do
+ expect(Cms::ApiKey.active).to include(active_key)
+ expect(Cms::ApiKey.active).not_to include(inactive_key)
+ end
+ end
+
+ describe "#touch_last_used!" do
+ it "updates last_used_at" do
+ key = create(:cms_api_key, site: site)
+ expect { key.touch_last_used! }.to(change { key.reload.last_used_at })
+ end
+ end
+
+ describe "validations" do
+ it "requires a name" do
+ key = build(:cms_api_key, site: site, name: nil)
+ expect(key).not_to be_valid
+ end
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/page_search_spec.rb b/spec/cms_app/spec/models/cms/page_search_spec.rb
new file mode 100644
index 0000000..f8270a1
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/page_search_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms::Page search scope", type: :model do
+ let!(:site) { create(:cms_site) }
+
+ before do
+ p1 = create(:cms_page, site: site, slug: "hello-world")
+ create(:cms_page_translation, page: p1, locale: "en", title: "Hello World")
+
+ p2 = create(:cms_page, site: site, slug: "about-us")
+ create(:cms_page_translation, page: p2, locale: "en", title: "About Us")
+
+ p3 = create(:cms_page, site: site, slug: "cafe")
+ create(:cms_page_translation, page: p3, locale: "en", title: "Café Corner")
+ end
+
+ it "finds pages by title" do
+ results = site.pages.search("hello")
+ expect(results.map(&:slug)).to include("hello-world")
+ expect(results.map(&:slug)).not_to include("about-us")
+ end
+
+ it "finds pages by slug" do
+ results = site.pages.search("about")
+ expect(results.map(&:slug)).to include("about-us")
+ end
+
+ it "is case-insensitive" do
+ results = site.pages.search("HELLO")
+ expect(results.map(&:slug)).to include("hello-world")
+ end
+
+ it "handles accented characters (unaccent)" do
+ results = site.pages.search("cafe")
+ expect(results.map(&:slug)).to include("cafe")
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/page_spec.rb b/spec/cms_app/spec/models/cms/page_spec.rb
new file mode 100644
index 0000000..50b103b
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/page_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Cms::Page, type: :model do
+ let(:site) { create(:cms_site) }
+
+ describe "nav_group" do
+ it "allows custom navigation groups" do
+ page = build(:cms_page, site: site, nav_group: "footer_secondary")
+
+ expect(page).to be_valid
+ end
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/page_tree_spec.rb b/spec/cms_app/spec/models/cms/page_tree_spec.rb
new file mode 100644
index 0000000..9f9ee19
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/page_tree_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms::Page tree", type: :model do
+ let!(:site) { create(:cms_site) }
+
+ let!(:root) { create(:cms_page, site: site, slug: "root") }
+ let!(:child) { create(:cms_page, site: site, slug: "child", parent: root) }
+ let!(:grand) { create(:cms_page, site: site, slug: "grand", parent: child) }
+
+ describe "#ancestors" do
+ it "returns empty array for root page" do
+ expect(root.ancestors).to eq([])
+ end
+
+ it "returns parent for child page" do
+ expect(child.ancestors).to eq([root])
+ end
+
+ it "returns full chain for grandchild" do
+ expect(grand.ancestors).to eq([root, child])
+ end
+ end
+
+ describe "#depth" do
+ it "returns 0 for root" do
+ expect(root.depth).to eq(0)
+ end
+
+ it "returns 1 for child" do
+ expect(child.depth).to eq(1)
+ end
+
+ it "returns 2 for grandchild" do
+ expect(grand.depth).to eq(2)
+ end
+ end
+
+ describe "#descendants" do
+ it "returns all descendants of root" do
+ expect(root.descendants).to include(child, grand)
+ end
+
+ it "returns direct child descendants" do
+ expect(child.descendants).to eq([grand])
+ end
+
+ it "returns empty for leaf" do
+ expect(grand.descendants).to eq([])
+ end
+ end
+
+ describe "scope :root" do
+ it "returns only pages without a parent" do
+ roots = site.pages.root
+ expect(roots).to include(root)
+ expect(roots).not_to include(child, grand)
+ end
+ end
+
+ describe "subpages dependent: :nullify" do
+ it "nullifies parent_id on child when parent is destroyed" do
+ root.destroy
+ expect(child.reload.parent_id).to be_nil
+ end
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/section/block_base_spec.rb b/spec/cms_app/spec/models/cms/section/block_base_spec.rb
new file mode 100644
index 0000000..d7d1607
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/section/block_base_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Cms::Section::BlockBase do
+ describe ".settings_schema" do
+ it "returns an empty array by default" do
+ block = Class.new(described_class)
+ expect(block.settings_schema).to eq([])
+ end
+
+ it "is independent per subclass" do
+ block_a = Class.new(described_class) { settings_field :title, type: :string }
+ block_b = Class.new(described_class) { settings_field :url, type: :url }
+
+ expect(block_a.settings_schema.map { |f| f[:name] }).to eq(["title"])
+ expect(block_b.settings_schema.map { |f| f[:name] }).to eq(["url"])
+ end
+ end
+
+ describe ".settings_field" do
+ let(:block) do
+ Class.new(described_class) do
+ settings_field :button_text, type: :string, required: true
+ settings_field :alignment, type: :select, default: "center", options: %w[left center right]
+ end
+ end
+
+ it "adds field definitions to the schema" do
+ expect(block.settings_schema.length).to eq(2)
+ end
+
+ it "stores the field name as a string" do
+ expect(block.settings_schema.first[:name]).to eq("button_text")
+ end
+
+ it "stores type, required, default, options" do
+ align = block.settings_schema.last
+ expect(align[:type]).to eq(:select)
+ expect(align[:default]).to eq("center")
+ expect(align[:options]).to eq(%w[left center right])
+ end
+
+ it "omits nil optional keys (compact)" do
+ field = block.settings_schema.first
+ expect(field).not_to have_key(:options)
+ expect(field).not_to have_key(:default)
+ end
+ end
+
+ describe ".kind" do
+ it "raises NotImplementedError on the base class" do
+ expect { described_class.kind }.to raise_error(NotImplementedError)
+ end
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/section/blocks/built_in_blocks_spec.rb b/spec/cms_app/spec/models/cms/section/blocks/built_in_blocks_spec.rb
new file mode 100644
index 0000000..68e2a76
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/section/blocks/built_in_blocks_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Built-in block classes" do
+ describe Cms::Section::Blocks::RichTextBlock do
+ it "has kind 'rich_text'" do
+ expect(described_class.kind).to eq("rich_text")
+ end
+
+ it "has no settings fields" do
+ expect(described_class.settings_schema).to be_empty
+ end
+ end
+
+ describe Cms::Section::Blocks::ImageBlock do
+ it "has kind 'image'" do
+ expect(described_class.kind).to eq("image")
+ end
+
+ it "declares only the image associations field" do
+ names = described_class.settings_schema.map { |f| f[:name] }
+ expect(names).to eq(%w[image_ids])
+ end
+ end
+
+ describe Cms::Section::Blocks::HeroBlock do
+ it "has kind 'hero'" do
+ expect(described_class.kind).to eq("hero")
+ end
+
+ it "has a background_color field with default" do
+ field = described_class.settings_schema.find { |f| f[:name] == "background_color" }
+ expect(field[:default]).to eq("#ffffff")
+ end
+ end
+
+ describe Cms::Section::Blocks::CallToActionBlock do
+ it "has kind 'cta'" do
+ expect(described_class.kind).to eq("cta")
+ end
+
+ it "marks button_text and button_url as required" do
+ required = described_class.settings_schema.select { |f| f[:required] }.map { |f| f[:name] }
+ expect(required).to contain_exactly("button_text", "button_url")
+ end
+
+ it "has alignment select with options" do
+ field = described_class.settings_schema.find { |f| f[:name] == "alignment" }
+ expect(field[:options]).to eq(%w[left center right])
+ end
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/section/kind_registry_spec.rb b/spec/cms_app/spec/models/cms/section/kind_registry_spec.rb
new file mode 100644
index 0000000..01d5d32
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/section/kind_registry_spec.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Cms::Section::KindRegistry do
+ around do |example|
+ described_class.reset!
+ example.run
+ described_class.reset!
+ # Re-register built-ins so other specs are unaffected
+ block_classes = {
+ "rich_text" => Cms::Section::Blocks::RichTextBlock,
+ "image" => Cms::Section::Blocks::ImageBlock,
+ "hero" => Cms::Section::Blocks::HeroBlock,
+ "cta" => Cms::Section::Blocks::CallToActionBlock
+ }
+ Cms::Section::KindRegistry::BUILT_IN_KINDS.each do |k|
+ described_class.register(k, block_class: block_classes[k])
+ end
+ end
+
+ describe ".register" do
+ it "registers a kind with the default partial path" do
+ described_class.register("cta")
+ expect(described_class.partial_for("cta")).to eq("cms/sections/kinds/cta")
+ end
+
+ it "registers a kind with a custom partial path" do
+ described_class.register("cta", partial: "my_app/sections/cta")
+ expect(described_class.partial_for("cta")).to eq("my_app/sections/cta")
+ end
+
+ it "accepts symbol kind" do
+ described_class.register(:banner)
+ expect(described_class.partial_for("banner")).to eq("cms/sections/kinds/banner")
+ end
+
+ it "stores an optional block_class" do
+ described_class.register("hero", block_class: Cms::Section::Blocks::HeroBlock)
+ expect(described_class.block_class_for("hero")).to eq(Cms::Section::Blocks::HeroBlock)
+ end
+ end
+
+ describe ".partial_for" do
+ it "returns the partial path for a registered kind" do
+ described_class.register("rich_text")
+ expect(described_class.partial_for("rich_text")).to eq("cms/sections/kinds/rich_text")
+ end
+
+ it "raises UnknownKindError for an unregistered kind" do
+ expect { described_class.partial_for("unknown") }
+ .to raise_error(Cms::Section::KindRegistry::UnknownKindError, /unknown/)
+ end
+
+ it "includes registered kinds in the error message" do
+ described_class.register("hero")
+ expect { described_class.partial_for("unknown") }
+ .to raise_error(Cms::Section::KindRegistry::UnknownKindError, /hero/)
+ end
+ end
+
+ describe ".block_class_for" do
+ it "returns nil when no block_class was registered" do
+ described_class.register("plain")
+ expect(described_class.block_class_for("plain")).to be_nil
+ end
+
+ it "raises UnknownKindError for an unregistered kind" do
+ expect { described_class.block_class_for("unknown") }
+ .to raise_error(Cms::Section::KindRegistry::UnknownKindError)
+ end
+ end
+
+ describe ".registered_kinds" do
+ it "returns all registered kind strings" do
+ described_class.register("rich_text")
+ described_class.register("image")
+ expect(described_class.registered_kinds).to contain_exactly("rich_text", "image")
+ end
+
+ it "returns an empty array when nothing is registered" do
+ expect(described_class.registered_kinds).to eq([])
+ end
+ end
+
+ describe ".registered?" do
+ it "returns true for a registered kind" do
+ described_class.register("hero")
+ expect(described_class.registered?("hero")).to be true
+ end
+
+ it "returns false for an unregistered kind" do
+ expect(described_class.registered?("unknown")).to be false
+ end
+ end
+
+ describe "built-in kinds (registered via engine initializer)" do
+ before do
+ block_classes = {
+ "rich_text" => Cms::Section::Blocks::RichTextBlock,
+ "image" => Cms::Section::Blocks::ImageBlock,
+ "hero" => Cms::Section::Blocks::HeroBlock,
+ "cta" => Cms::Section::Blocks::CallToActionBlock
+ }
+ Cms::Section::KindRegistry::BUILT_IN_KINDS.each do |k|
+ described_class.register(k, block_class: block_classes[k])
+ end
+ end
+
+ it "registers rich_text" do
+ expect(described_class.registered?("rich_text")).to be true
+ end
+
+ it "registers image" do
+ expect(described_class.registered?("image")).to be true
+ end
+
+ it "registers hero" do
+ expect(described_class.registered?("hero")).to be true
+ end
+
+ it "registers cta" do
+ expect(described_class.registered?("cta")).to be true
+ end
+
+ it "associates block classes with built-in kinds" do
+ expect(described_class.block_class_for("cta")).to eq(Cms::Section::Blocks::CallToActionBlock)
+ end
+ end
+end
diff --git a/spec/cms_app/spec/models/cms/webhook_spec.rb b/spec/cms_app/spec/models/cms/webhook_spec.rb
new file mode 100644
index 0000000..406b827
--- /dev/null
+++ b/spec/cms_app/spec/models/cms/webhook_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Cms::Webhook, type: :model do
+ describe "validations" do
+ it "is valid with supported events" do
+ webhook = build(:cms_webhook)
+
+ expect(webhook).to be_valid
+ end
+
+ it "rejects unsupported events" do
+ webhook = build(:cms_webhook, events: ["page.published", "page.deleted"])
+
+ expect(webhook).not_to be_valid
+ expect(webhook.errors[:events]).to include("contains unsupported values: page.deleted")
+ end
+
+ it "normalizes duplicate and blank events" do
+ webhook = build(:cms_webhook, events: ["page.unpublished", "", "page.unpublished", :"page.published"])
+
+ webhook.validate
+
+ expect(webhook.events).to eq(%w[page.unpublished page.published])
+ end
+ end
+end
diff --git a/spec/cms_app/spec/requests/cms/admin/pages_spec.rb b/spec/cms_app/spec/requests/cms/admin/pages_spec.rb
new file mode 100644
index 0000000..dfba9dd
--- /dev/null
+++ b/spec/cms_app/spec/requests/cms/admin/pages_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms admin pages", type: :request do
+ include Cms::Engine.routes.url_helpers
+
+ let(:site) { create(:cms_site, name: "Docs", slug: "docs", published: true) }
+ let!(:page) do
+ create(:cms_page, :published, site: site, slug: "about", template_key: "landing").tap do |record|
+ create(:cms_page_translation, page: record, locale: "en", title: "About")
+ create(:cms_section, site: site, kind: "rich_text").tap do |section|
+ create(:cms_section_translation,
+ section: section,
+ locale: "en",
+ title: "About intro",
+ content: "About content")
+ create(:cms_page_section, page: record, section: section, position: 0)
+ end
+ end
+ end
+
+ let(:logo_upload) do
+ Rack::Test::UploadedFile.new(
+ Rails.root.join("public/icon.png"),
+ "image/png"
+ )
+ end
+
+ around do |example|
+ previous_resolver = Cms.config.current_site_resolver
+ Cms.config.current_site_resolver = ->(_controller) { site }
+ example.run
+ Cms.config.current_site_resolver = previous_resolver
+ end
+
+ describe "GET /admin/pages/:id/preview" do
+ before do
+ site.logo.attach(logo_upload)
+ end
+
+ it "renders the public page template with the public layout" do
+ get preview_admin_page_path(page)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("")
+ expect(response.body).to include("About")
+ expect(response.body).to include("rel=\"icon\"")
+ expect(response.body).to include("cms-page-template--landing")
+ end
+ end
+
+ describe "GET /admin/pages" do
+ context "when the site has no pages" do
+ let!(:page) { nil }
+
+ it "renders a helpful empty state" do
+ get admin_pages_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Pages are listed in site order.")
+ expect(response.body).to include("No pages yet. Create your first page to start building the site.")
+ end
+ end
+
+ context "when no site exists yet" do
+ let!(:page) { nil }
+
+ it "redirects to the site bootstrap form" do
+ previous_resolver = Cms.config.current_site_resolver
+ Cms.config.current_site_resolver = ->(*) {}
+
+ get admin_pages_path
+
+ expect(response).to redirect_to(new_admin_site_path)
+ ensure
+ Cms.config.current_site_resolver = previous_resolver
+ end
+ end
+ end
+end
diff --git a/spec/cms_app/spec/requests/cms/admin/sections_spec.rb b/spec/cms_app/spec/requests/cms/admin/sections_spec.rb
new file mode 100644
index 0000000..e116f74
--- /dev/null
+++ b/spec/cms_app/spec/requests/cms/admin/sections_spec.rb
@@ -0,0 +1,270 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms::Admin::Sections", type: :request do
+ include Cms::Engine.routes.url_helpers
+
+ let!(:site) { create(:cms_site, name: "Test", slug: "test", published: true) }
+ let!(:page) do
+ create(:cms_page, :published, site: site, slug: "home").tap do |p|
+ create(:cms_page_translation, page: p, locale: "en", title: "Home")
+ end
+ end
+
+ around do |example|
+ previous_resolver = Cms.config.current_site_resolver
+ Cms.config.current_site_resolver = ->(_controller) { site }
+ example.run
+ Cms.config.current_site_resolver = previous_resolver
+ end
+
+ describe "GET /admin/pages/:page_id/sections/new" do
+ it "returns 200 with the default kind" do
+ get new_admin_page_section_path(page)
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "returns 200 with a specified kind" do
+ get new_admin_page_section_path(page, kind: "cta")
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(I18n.t("cms.section_kinds.cta"))
+ end
+ end
+
+ describe "GET /admin/sections" do
+ let!(:section) do
+ create(:cms_section, :global, site: site, kind: "cta",
+ button_url: "https://example.com", alignment: "center").tap do |record|
+ create(:cms_section_translation,
+ section: record,
+ locale: "en",
+ title: "Shared CTA",
+ subtitle: "Book",
+ content: "Shared CTA body")
+ create(:cms_page_section, page: page, section: record)
+ end
+ end
+
+ it "renders the reusable section library" do
+ get admin_sections_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Sections")
+ expect(response.body).to include("Shared CTA")
+ expect(response.body).to include("Home")
+ end
+ end
+
+ describe "POST /admin/sections" do
+ let!(:image) { create(:cms_image, site: site, title: "Hero asset") }
+
+ it "creates a reusable image section without attaching it to a page" do
+ initial_page_section_count = Cms::PageSection.count
+
+ expect do
+ post admin_sections_path, params: {
+ section: {
+ kind: "image",
+ enabled: true,
+ image_ids: [image.id.to_s]
+ }
+ }
+ end.to change(Cms::Section, :count).by(1)
+
+ expect(response).to redirect_to(admin_sections_path(locale: "en"))
+ expect(Cms::PageSection.count).to eq(initial_page_section_count)
+ expect(Cms::Section.last.images.pluck(:id)).to eq([image.id])
+ end
+ end
+
+ describe "POST /admin/pages/:page_id/sections" do
+ context "with valid rich_text params" do
+ let(:valid_params) do
+ {
+ section: {
+ kind: "rich_text",
+ enabled: true,
+ translations_attributes: [{ locale: "en", title: "Intro", content: "Intro body" }]
+ }
+ }
+ end
+
+ it "creates a section and redirects to the page" do
+ initial_section_count = Cms::Section.count
+ initial_page_section_count = Cms::PageSection.count
+
+ expect do
+ post admin_page_sections_path(page), params: valid_params
+ end.to change { Cms::Section.count - initial_section_count }.by(1)
+
+ expect(Cms::PageSection.count - initial_page_section_count).to eq(1)
+ expect(response).to redirect_to(admin_page_path(page, locale: "en"))
+ end
+
+ it "creates a section translation" do
+ post admin_page_sections_path(page), params: valid_params
+ expect(page.reload.sections.last.translations.where(locale: "en").count).to eq(1)
+ end
+ end
+
+ context "with a cta section requiring settings" do
+ it "creates a section without required settings validation at create time" do
+ params = {
+ section: {
+ kind: "cta",
+ enabled: true,
+ button_url: "https://example.com",
+ alignment: "center",
+ translations_attributes: [{ locale: "en", title: "CTA", subtitle: "Click me", content: "CTA body" }]
+ }
+ }
+
+ expect do
+ post admin_page_sections_path(page), params: params
+ end.to change(Cms::Section, :count).by(1)
+ .and change(Cms::PageSection, :count).by(1)
+ end
+ end
+
+ context "with invalid params (missing kind)" do
+ it "renders new with 422" do
+ post admin_page_sections_path(page), params: { section: { kind: "" } }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+ end
+
+ describe "GET /admin/pages/:page_id/sections/:id/edit" do
+ let!(:section) do
+ create(:cms_section, site: site, kind: "rich_text").tap do |s|
+ create(:cms_page_section, page: page, section: s)
+ create(:cms_section_translation, section: s, locale: "en", title: "Intro block", content: "Intro body")
+ end
+ end
+
+ it "returns 200" do
+ get edit_admin_page_section_path(page, section)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Intro block")
+ end
+ end
+
+ describe "PATCH /admin/pages/:page_id/sections/:id" do
+ let!(:section) do
+ create(:cms_section, site: site, kind: "rich_text").tap do |s|
+ create(:cms_page_section, page: page, section: s)
+ create(:cms_section_translation, section: s, locale: "en", title: "Old title", content: "Old body")
+ end
+ end
+
+ it "updates the section translation and redirects" do
+ patch admin_page_section_path(page, section), params: {
+ section: {
+ translations_attributes: [
+ { id: section.translations.first.id, locale: "en", title: "New title", content: "Old body" }
+ ]
+ }
+ }
+
+ expect(response).to redirect_to(admin_page_path(page, locale: "en"))
+ expect(section.translations.first.reload.title).to eq("New title")
+ end
+ end
+
+ describe "PATCH /admin/sections/:id" do
+ let!(:section) do
+ create(:cms_section, site: site, kind: "rich_text").tap do |record|
+ create(:cms_section_translation, section: record, locale: "en", title: "Old shared title", content: "Old body")
+ end
+ end
+
+ it "updates a reusable section from the library" do
+ patch admin_section_path(section), params: {
+ section: {
+ translations_attributes: [
+ { id: section.translations.first.id, locale: "en", title: "New shared title", content: "Old body" }
+ ]
+ }
+ }
+
+ expect(response).to redirect_to(admin_sections_path(locale: "en"))
+ expect(section.translations.first.reload.title).to eq("New shared title")
+ end
+ end
+
+ describe "DELETE /admin/pages/:page_id/sections/:id" do
+ let!(:section) do
+ create(:cms_section, site: site, kind: "rich_text").tap do |s|
+ create(:cms_page_section, page: page, section: s)
+ end
+ end
+
+ it "removes the page placement and preserves the section" do
+ expect do
+ delete admin_page_section_path(page, section)
+ end.to change(Cms::PageSection, :count).by(-1)
+
+ expect(Cms::Section.exists?(section.id)).to be true
+ expect(response).to redirect_to(admin_page_path(page))
+ end
+ end
+
+ describe "DELETE /admin/sections/:id" do
+ let!(:section) do
+ create(:cms_section, site: site, kind: "rich_text").tap do |record|
+ create(:cms_page_section, page: page, section: record)
+ end
+ end
+
+ it "soft-deletes the reusable section and preserves its page attachments" do
+ expect do
+ delete admin_section_path(section)
+ end.to change { Cms::Section.kept.count }.by(-1)
+
+ expect(response).to redirect_to(admin_sections_path)
+ expect(section.reload.discarded?).to be true
+ expect(section.page_sections.count).to eq(1)
+ end
+ end
+
+ describe "PATCH /admin/pages/:page_id/sections/sort" do
+ let!(:section_a) { create(:cms_section, site: site, kind: "rich_text") }
+ let!(:section_b) { create(:cms_section, site: site, kind: "hero") }
+ let!(:page_section_a) { create(:cms_page_section, page: page, section: section_a, position: 0) }
+ let!(:page_section_b) { create(:cms_page_section, page: page, section: section_b, position: 1) }
+
+ it "updates positions and returns 200" do
+ patch admin_page_sort_sections_path(page), params: {
+ page_section_ids: [page_section_b.id, page_section_a.id]
+ }
+
+ expect(response).to have_http_status(:ok)
+ expect(page_section_b.reload.position).to eq(0)
+ expect(page_section_a.reload.position).to eq(1)
+ end
+ end
+
+ describe "POST /admin/pages/:page_id/sections/attach" do
+ let!(:section) do
+ create(:cms_section, :global, site: site, kind: "cta",
+ button_url: "https://example.com/book", alignment: "center").tap do |record|
+ create(:cms_section_translation,
+ section: record,
+ locale: "en",
+ title: "Shared CTA",
+ subtitle: "Book a call",
+ content: "CTA body")
+ end
+ end
+
+ it "attaches an existing reusable section to the page" do
+ expect do
+ post admin_page_attach_section_path(page), params: { section_id: section.id }
+ end.to change(Cms::PageSection, :count).by(1)
+
+ expect(response).to redirect_to(admin_page_path(page))
+ expect(page.reload.sections).to include(section)
+ end
+ end
+end
diff --git a/spec/cms_app/spec/requests/cms/admin/sites_spec.rb b/spec/cms_app/spec/requests/cms/admin/sites_spec.rb
new file mode 100644
index 0000000..f0bea09
--- /dev/null
+++ b/spec/cms_app/spec/requests/cms/admin/sites_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms admin sites", type: :request do
+ include Cms::Engine.routes.url_helpers
+
+ around do |example|
+ previous_resolver = Cms.config.current_site_resolver
+ Cms.config.current_site_resolver = current_site_resolver
+ example.run
+ Cms.config.current_site_resolver = previous_resolver
+ end
+
+ let(:current_site_resolver) { ->(_controller) { site } }
+
+ describe "GET /admin/site/new" do
+ context "when no site exists yet" do
+ let(:current_site_resolver) { ->(*) {} }
+
+ it "renders the bootstrap form" do
+ get new_admin_site_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Create Website")
+ expect(response.body).to include("Create the first CMS site")
+ end
+ end
+
+ context "when a site already exists" do
+ let!(:site) { create(:cms_site, name: "Docs", slug: "docs", published: true) }
+
+ it "redirects to edit" do
+ get new_admin_site_path
+
+ expect(response).to redirect_to(edit_admin_site_path)
+ end
+ end
+ end
+
+ describe "POST /admin/site" do
+ let(:current_site_resolver) { ->(*) {} }
+
+ it "creates the first site" do
+ post admin_site_path, params: {
+ site: { name: "Docs", slug: "docs", default_locale: "en", published: true }
+ }
+
+ expect(response).to redirect_to(admin_site_path)
+ expect(Cms::Site.count).to eq(1)
+ expect(Cms::Site.last.slug).to eq("docs")
+ end
+ end
+
+ describe "GET /admin/site/edit" do
+ let!(:site) { create(:cms_site, name: "Docs", slug: "docs", published: true) }
+ let!(:other_site) { create(:cms_site, name: "Blog", slug: "blog", published: true) }
+
+ it "does not expose a separate favicon attachment field" do
+ get edit_admin_site_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Used for both the site logo and favicon.")
+ expect(response.body).not_to include("site[favicon]")
+ expect(response.body).not_to include("Remove favicon")
+ end
+
+ it "uses the configured current_site_resolver when present" do
+ Cms.config.current_site_resolver = ->(_controller) { other_site }
+
+ get edit_admin_site_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("value=\"Blog\"")
+ end
+ end
+end
diff --git a/spec/cms_app/spec/requests/cms/api/v1/pages_spec.rb b/spec/cms_app/spec/requests/cms/api/v1/pages_spec.rb
new file mode 100644
index 0000000..e3b7eff
--- /dev/null
+++ b/spec/cms_app/spec/requests/cms/api/v1/pages_spec.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms API v1 pages", type: :request do
+ include Cms::Engine.routes.url_helpers
+
+ around do |example|
+ previous_page_resolver_class = Cms.config.page_resolver_class
+ previous_page_serializer_class = Cms.config.api_page_serializer_class
+
+ example.run
+ ensure
+ Cms.config.page_resolver_class = previous_page_resolver_class
+ Cms.config.api_page_serializer_class = previous_page_serializer_class
+ end
+
+ let!(:site) { create(:cms_site, slug: "docs", published: true) }
+ let!(:api_key) { create(:cms_api_key, site: site, active: true) }
+
+ let!(:page) do
+ create(:cms_page, :published, site: site, slug: "about").tap do |p|
+ create(:cms_page_translation, page: p, locale: "en", title: "About")
+ create(:cms_page_translation, page: p, locale: "el", title: "About EL")
+ end
+ end
+
+ let!(:image) do
+ create(:cms_image,
+ site: site,
+ title: "Section image").tap do |record|
+ record.image_translations.find_by!(locale: "en").update!(alt_text: "Library alt", caption: "Library caption")
+ end
+ end
+
+ let!(:hero_section) do
+ create(:cms_section,
+ site: site,
+ kind: "hero",
+ background_color: "#101010",
+ cta_url: "/start").tap do |section|
+ create(:cms_section_translation,
+ section: section,
+ locale: "en",
+ title: "Hero title",
+ subtitle: "Start",
+ content: "Hero body")
+ create(:cms_page_section, page: page, section: section, position: 0)
+ end
+ end
+
+ let!(:image_section) do
+ create(:cms_section, site: site, kind: "image").tap do |section|
+ create(:cms_section_image, section: section, image: image, position: 0)
+ create(:cms_page_section, page: page, section: section, position: 1)
+ end
+ end
+
+ def auth_headers(key = api_key)
+ { "Authorization" => "Bearer #{key.token}", "X-CMS-SITE-SLUG" => site.slug }
+ end
+
+ describe "GET /api/v1/pages/:slug" do
+ it "returns 401 without an API key" do
+ get "/api/v1/pages/about", headers: { "X-CMS-SITE-SLUG" => site.slug }
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it "returns 401 with an invalid token" do
+ get "/api/v1/pages/about", headers: { "Authorization" => "Bearer invalid", "X-CMS-SITE-SLUG" => site.slug }
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it "returns 401 with an inactive key" do
+ inactive = create(:cms_api_key, site: site, active: false)
+ get "/api/v1/pages/about", headers: auth_headers(inactive)
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it "returns the page with a valid key" do
+ get "/api/v1/pages/about", headers: auth_headers
+ expect(response).to have_http_status(:ok)
+ payload = JSON.parse(response.body)
+ expect(payload["slug"]).to eq("about")
+ expect(payload["title"]).to eq("About")
+ expect(payload).not_to have_key("site")
+ end
+
+ it "exposes resolved locale, available locales, settings, and kind-specific section data" do
+ hero_section
+ image_section
+
+ get "/api/v1/pages/about", headers: auth_headers.merge("Accept-Language" => "el")
+
+ expect(response).to have_http_status(:ok)
+
+ payload = JSON.parse(response.body)
+ expect(payload["resolved_locale"]).to eq("el")
+ expect(payload["available_locales"]).to contain_exactly("en", "el")
+ expect(payload["title"]).to eq("About EL")
+
+ hero_payload = payload["sections"].find { |section| section["kind"] == "hero" }
+ expect(hero_payload["settings"]).to eq({})
+ expect(hero_payload["data"]).to include(
+ "background_color" => "#101010",
+ "cta_text" => "Start",
+ "cta_url" => "/start"
+ )
+ expect(hero_payload["resolved_locale"]).to eq("en")
+
+ image_payload = payload["sections"].find { |section| section["kind"] == "image" }
+ expect(image_payload["settings"]).to eq({})
+ expect(image_payload["resolved_locale"]).to eq("en")
+ expect(image_payload["data"]["images"].first).to include(
+ "id" => image.id,
+ "title" => "Section image",
+ "alt_text" => "Library alt",
+ "caption" => "Library caption"
+ )
+ expect(image_payload["data"]["images"].first["url"]).to include("/rails/active_storage/blobs/")
+ end
+
+ it "optionally includes lightweight site metadata behind a query flag" do
+ get "/api/v1/pages/about", params: { include_site: true }, headers: auth_headers
+
+ expect(response).to have_http_status(:ok)
+ payload = JSON.parse(response.body)
+ expect(payload["slug"]).to eq("about")
+ expect(payload["site"]).to include(
+ "slug" => "docs",
+ "default_locale" => site.default_locale
+ )
+ expect(payload["site"]).not_to have_key("pages")
+ end
+
+ it "returns 404 for unknown slug" do
+ get "/api/v1/pages/nonexistent", headers: auth_headers
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "touches last_used_at on the key" do
+ expect { get "/api/v1/pages/about", headers: auth_headers }
+ .to(change { api_key.reload.last_used_at })
+ end
+
+ it "rejects a valid token for a different site" do
+ other_site = create(:cms_site, slug: "blog", published: true)
+ other_key = create(:cms_api_key, site: other_site, active: true)
+ headers = {
+ "Authorization" => "Bearer #{other_key.token}",
+ "X-CMS-SITE-SLUG" => site.slug
+ }
+
+ get "/api/v1/pages/about", headers: headers
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it "uses the configured page_resolver_class" do
+ custom_result = Cms::PageResolver::Result.new(page: page, locale: "en")
+ resolver = Class.new do
+ def self.resolve(site:, slug:, locale:)
+ raise "unexpected site" unless site.slug == "docs"
+ raise "unexpected slug" unless slug == "about"
+ raise "unexpected locale" unless locale == "en"
+
+ Cms::PageResolver::Result.new(page: Cms::Page.find_by!(slug: "about"), locale: "en")
+ end
+ end
+ stub_const("CustomPageResolver", resolver)
+ Cms.config.page_resolver_class = "CustomPageResolver"
+
+ get "/api/v1/pages/about", headers: auth_headers.merge("Accept-Language" => "en")
+
+ expect(response).to have_http_status(:ok)
+ expect(JSON.parse(response.body)["slug"]).to eq(custom_result.page.slug)
+ end
+
+ it "uses the configured api_page_serializer_class" do
+ serializer = Class.new do
+ def initialize(site:, page:, requested_locale:, main_app: nil)
+ @main_app = main_app
+ @site = site
+ @page = page
+ @requested_locale = requested_locale
+ end
+
+ def as_json(include_site: false)
+ {
+ slug: @page.slug,
+ requested_locale: @requested_locale,
+ site_slug: @site.slug,
+ include_site: include_site
+ }
+ end
+ end
+ stub_const("CustomPageSerializer", serializer)
+ Cms.config.api_page_serializer_class = "CustomPageSerializer"
+
+ get "/api/v1/pages/about", params: { include_site: true }, headers: auth_headers
+
+ expect(response).to have_http_status(:ok)
+ expect(JSON.parse(response.body)).to include(
+ "slug" => "about",
+ "site_slug" => "docs",
+ "include_site" => true
+ )
+ end
+ end
+end
diff --git a/spec/cms_app/spec/requests/cms/api/v1/sites_spec.rb b/spec/cms_app/spec/requests/cms/api/v1/sites_spec.rb
new file mode 100644
index 0000000..534fc4c
--- /dev/null
+++ b/spec/cms_app/spec/requests/cms/api/v1/sites_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms API v1 sites", type: :request do
+ around do |example|
+ previous_site_serializer_class = Cms.config.api_site_serializer_class
+ example.run
+ ensure
+ Cms.config.api_site_serializer_class = previous_site_serializer_class
+ end
+
+ let!(:site) { create(:cms_site, name: "Docs", slug: "docs", published: true) }
+ let!(:api_key) { create(:cms_api_key, site: site) }
+
+ let(:logo_upload) do
+ Rack::Test::UploadedFile.new(
+ Rails.root.join("public/icon.png"),
+ "image/png"
+ )
+ end
+
+ def auth_headers(key = api_key)
+ {
+ "Authorization" => "Bearer #{key.token}",
+ "X-CMS-SITE-SLUG" => site.slug
+ }
+ end
+
+ before do
+ create(:cms_page, :published, site: site, slug: "home", home: true).tap do |page|
+ create(:cms_page_translation, page: page, locale: "en", title: "Home")
+ end
+ create(:cms_page, :published, site: site, slug: "about").tap do |page|
+ create(:cms_page_translation, page: page, locale: "en", title: "About")
+ end
+ about_page = Cms::Page.find_by!(site: site, slug: "about")
+ create(:cms_page, :published, site: site, slug: "history", parent: about_page).tap do |page|
+ create(:cms_page_translation, page: page, locale: "en", title: "History")
+ end
+ site.logo.attach(logo_upload)
+ end
+
+ describe "GET /api/v1/sites/:site_slug" do
+ it "returns site data with published pages" do
+ get "/api/v1/sites/#{site.slug}", headers: auth_headers
+
+ expect(response).to have_http_status(:ok)
+ payload = JSON.parse(response.body)
+ expect(payload["slug"]).to eq("docs")
+ expect(payload["resolved_locale"]).to eq("en")
+ expect(payload["pages"].map { |page| page["slug"] }).to include("home", "about")
+ expect(payload["pages"].find { |page| page["slug"] == "history" }["path"]).to eq("about/history")
+ expect(payload["logo_url"]).to eq(payload["favicon_url"])
+ expect(payload["favicon_url"]).to include("/rails/active_storage/blobs/")
+ end
+
+ it "uses the configured api_site_serializer_class" do
+ serializer = Class.new do
+ def initialize(site:, requested_locale:, main_app: nil)
+ @main_app = main_app
+ @site = site
+ @requested_locale = requested_locale
+ end
+
+ def as_json(include_pages: true)
+ {
+ slug: @site.slug,
+ requested_locale: @requested_locale,
+ include_pages: include_pages
+ }
+ end
+ end
+ stub_const("CustomSiteSerializer", serializer)
+ Cms.config.api_site_serializer_class = "CustomSiteSerializer"
+
+ get "/api/v1/sites/#{site.slug}", headers: auth_headers
+
+ expect(response).to have_http_status(:ok)
+ expect(JSON.parse(response.body)).to include(
+ "slug" => "docs",
+ "include_pages" => true
+ )
+ end
+ end
+end
diff --git a/spec/cms_app/spec/requests/cms/form_submissions_spec.rb b/spec/cms_app/spec/requests/cms/form_submissions_spec.rb
new file mode 100644
index 0000000..3119da5
--- /dev/null
+++ b/spec/cms_app/spec/requests/cms/form_submissions_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Cms form submissions", type: :request do
+ include Cms::Engine.routes.url_helpers
+
+ let!(:site) { create(:cms_site, name: "Docs", slug: "docs", published: true, default_locale: "el") }
+ let!(:page) do
+ create(:cms_page, :published, site: site, slug: "contact").tap do |record|
+ create(:cms_page_translation, page: record, locale: "el", title: "Contact")
+ end
+ end
+ let!(:field) do
+ create(:cms_form_field, page: page, field_name: "email", label: "Email", kind: "email", required: true)
+ end
+
+ around do |example|
+ translations = {
+ cms: {
+ notices: { form_submission_sent: "Το μήνυμα στάλθηκε." },
+ errors: {
+ form_submission: { required: "Το %