From c2e256646fec9e6d42cefe38782865a04b7479a0 Mon Sep 17 00:00:00 2001 From: Evangelos Giataganas Date: Fri, 6 Mar 2026 12:47:35 +0200 Subject: [PATCH] Implement initial mountable CMS engine Add the first end-to-end CMS engine foundation, including core content models, admin management flows, public rendering, and versioned API endpoints for single-site and multi-site usage. Include install generators, engine tasks, test infrastructure, CI, and documentation so the engine can be developed and integrated as a reusable Rails component. --- .github/workflows/ci.yml | 46 ++ .gitignore | 5 + .rspec | 1 + .rubocop.yml | 62 ++- CHANGELOG.md | 108 +++++ Gemfile | 22 +- Gemfile.lock | 434 ++++++++++++++++++ MIT-LICENSE | 35 +- README.md | 290 +++++++++++- Rakefile | 7 + app/assets/stylesheets/cms/application.css | 12 - .../cms/admin/api_keys_controller.rb | 52 +++ app/controllers/cms/admin/base_controller.rb | 54 +++ .../cms/admin/documents_controller.rb | 56 +++ .../cms/admin/form_fields_controller.rb | 70 +++ .../cms/admin/form_submissions_controller.rb | 36 ++ .../cms/admin/images_controller.rb | 67 +++ app/controllers/cms/admin/pages_controller.rb | 188 ++++++++ .../cms/admin/sections_controller.rb | 177 +++++++ app/controllers/cms/admin/sites_controller.rb | 60 +++ .../admin/webhook_deliveries_controller.rb | 19 + .../cms/admin/webhooks_controller.rb | 51 ++ app/controllers/cms/api/base_controller.rb | 45 ++ app/controllers/cms/api/v1/base_controller.rb | 29 ++ .../cms/api/v1/pages_controller.rb | 21 + .../cms/api/v1/sites_controller.rb | 18 + app/controllers/cms/application_controller.rb | 9 +- app/controllers/cms/public/base_controller.rb | 23 + .../cms/public/form_submissions_controller.rb | 63 +++ .../cms/public/previews_controller.rb | 21 + .../cms/public/sites_controller.rb | 37 ++ .../cms/admin/page_scoped_sections.rb | 42 ++ .../concerns/cms/current_site_resolver.rb | 17 + .../concerns/cms/public/page_paths.rb | 31 ++ .../concerns/cms/public/page_rendering.rb | 23 + .../concerns/cms/site_resolvable.rb | 27 ++ app/helpers/cms/admin/pages_helper.rb | 29 ++ app/helpers/cms/admin/sections_helper.rb | 86 ++++ app/helpers/cms/admin/sites_helper.rb | 8 + app/helpers/cms/application_helper.rb | 47 ++ app/helpers/cms/media_helper.rb | 28 ++ app/helpers/cms/pages_helper.rb | 16 + app/helpers/cms/sections_helper.rb | 25 + app/helpers/cms/sites_helper.rb | 11 + .../cms/controllers/sortable_controller.js | 38 ++ app/jobs/cms/application_job.rb | 2 + app/jobs/cms/deliver_webhook_job.rb | 66 +++ app/mailers/cms/application_mailer.rb | 5 +- app/mailers/cms/form_submission_mailer.rb | 16 + app/models/cms/api_key.rb | 27 ++ app/models/cms/application_record.rb | 2 + app/models/cms/document.rb | 48 ++ app/models/cms/form_field.rb | 27 ++ app/models/cms/form_submission.rb | 42 ++ app/models/cms/image.rb | 61 +++ app/models/cms/image_translation.rb | 22 + app/models/cms/page.rb | 228 +++++++++ app/models/cms/page_section.rb | 43 ++ app/models/cms/page_translation.rb | 22 + app/models/cms/section.rb | 94 ++++ app/models/cms/section/block_base.rb | 32 ++ .../section/blocks/call_to_action_block.rb | 16 + app/models/cms/section/blocks/hero_block.rb | 14 + app/models/cms/section/blocks/image_block.rb | 13 + .../cms/section/blocks/rich_text_block.rb | 12 + app/models/cms/section/kind_registry.rb | 66 +++ app/models/cms/section_image.rb | 10 + app/models/cms/section_translation.rb | 25 + app/models/cms/site.rb | 87 ++++ app/models/cms/webhook.rb | 41 ++ app/models/cms/webhook_delivery.rb | 12 + app/serializers/cms/api/base_serializer.rb | 51 ++ app/serializers/cms/api/page_serializer.rb | 145 ++++++ app/serializers/cms/api/site_serializer.rb | 45 ++ app/services/cms/locale_resolver.rb | 30 ++ app/services/cms/page_resolver.rb | 73 +++ app/services/cms/public_page_context.rb | 49 ++ app/views/cms/admin/api_keys/_form.html.erb | 23 + app/views/cms/admin/api_keys/create.html.erb | 9 + app/views/cms/admin/api_keys/edit.html.erb | 5 + app/views/cms/admin/api_keys/index.html.erb | 36 ++ app/views/cms/admin/api_keys/new.html.erb | 5 + app/views/cms/admin/documents/_form.html.erb | 24 + app/views/cms/admin/documents/edit.html.erb | 2 + app/views/cms/admin/documents/index.html.erb | 37 ++ app/views/cms/admin/documents/new.html.erb | 2 + .../cms/admin/form_fields/_form.html.erb | 46 ++ app/views/cms/admin/form_fields/edit.html.erb | 2 + .../cms/admin/form_fields/index.html.erb | 41 ++ app/views/cms/admin/form_fields/new.html.erb | 2 + .../cms/admin/form_submissions/index.html.erb | 38 ++ app/views/cms/admin/images/_form.html.erb | 36 ++ app/views/cms/admin/images/edit.html.erb | 2 + app/views/cms/admin/images/index.html.erb | 25 + app/views/cms/admin/images/new.html.erb | 2 + .../pages/_attach_section_panel.html.erb | 20 + app/views/cms/admin/pages/_form.html.erb | 116 +++++ .../pages/_section_editor_frame.html.erb | 3 + .../cms/admin/pages/_sections_list.html.erb | 9 + app/views/cms/admin/pages/edit.html.erb | 2 + app/views/cms/admin/pages/index.html.erb | 62 +++ app/views/cms/admin/pages/new.html.erb | 2 + app/views/cms/admin/pages/show.html.erb | 111 +++++ app/views/cms/admin/sections/_form.html.erb | 128 ++++++ .../cms/admin/sections/_section.html.erb | 22 + app/views/cms/admin/sections/edit.html.erb | 9 + app/views/cms/admin/sections/index.html.erb | 47 ++ app/views/cms/admin/sections/new.html.erb | 9 + .../sections/page_update.turbo_stream.erb | 17 + app/views/cms/admin/sections/show.html.erb | 97 ++++ app/views/cms/admin/sites/_form.html.erb | 44 ++ app/views/cms/admin/sites/edit.html.erb | 3 + app/views/cms/admin/sites/new.html.erb | 5 + app/views/cms/admin/sites/show.html.erb | 22 + .../admin/webhook_deliveries/index.html.erb | 29 ++ app/views/cms/admin/webhooks/_form.html.erb | 38 ++ app/views/cms/admin/webhooks/edit.html.erb | 5 + app/views/cms/admin/webhooks/index.html.erb | 34 ++ app/views/cms/admin/webhooks/new.html.erb | 5 + .../form_submission_mailer/notify.html.erb | 14 + .../form_submission_mailer/notify.text.erb | 7 + app/views/cms/public/pages/_content.html.erb | 48 ++ app/views/cms/public/pages/show.html.erb | 44 ++ .../public/pages/templates/_custom.html.erb | 3 + .../cms/public/pages/templates/_form.html.erb | 3 + .../public/pages/templates/_landing.html.erb | 3 + .../public/pages/templates/_standard.html.erb | 3 + app/views/cms/sections/kinds/_cta.html.erb | 13 + app/views/cms/sections/kinds/_hero.html.erb | 14 + app/views/cms/sections/kinds/_image.html.erb | 19 + .../cms/sections/kinds/_rich_text.html.erb | 6 + app/views/layouts/cms/application.html.erb | 24 +- app/views/layouts/cms/public.html.erb | 14 + bin/rails | 10 +- bin/rubocop | 3 +- cms.gemspec | 35 +- config/importmap.rb | 4 + config/locales/activerecord.cms.en.yml | 65 +++ config/locales/en.yml | 390 ++++++++++++++++ config/routes.rb | 54 +++ lib/cms.rb | 71 ++- lib/cms/engine.rb | 40 ++ lib/cms/version.rb | 2 + .../cms/install/install_generator.rb | 26 ++ .../install/templates/create_cms_tables.rb | 194 ++++++++ .../cms/install/templates/initializer.rb | 21 + lib/generators/cms/views/views_generator.rb | 79 ++++ lib/tasks/cms_tasks.rake | 2 + lib/tasks/version.rake | 8 + spec/cms/version_spec.rb | 10 + spec/cms_app/Rakefile | 5 + spec/cms_app/app/assets/images/.keep | 1 + .../app/assets/stylesheets/application.css | 4 + .../app/controllers/application_controller.rb | 4 + spec/cms_app/app/controllers/concerns/.keep | 1 + .../cms_app/app/helpers/application_helper.rb | 4 + spec/cms_app/app/javascript/application.js | 2 + .../app/javascript/controllers/application.js | 7 + .../app/javascript/controllers/index.js | 3 + spec/cms_app/app/javascripts/application.js | 1 + spec/cms_app/app/jobs/application_job.rb | 4 + .../cms_app/app/mailers/application_mailer.rb | 6 + spec/cms_app/app/models/application_record.rb | 5 + spec/cms_app/app/models/concerns/.keep | 1 + .../app/views/layouts/application.html.erb | 13 + .../cms_app/app/views/layouts/mailer.html.erb | 6 + .../cms_app/app/views/layouts/mailer.text.erb | 1 + spec/cms_app/app/views/pwa/manifest.json.erb | 10 + spec/cms_app/app/views/pwa/service-worker.js | 2 + spec/cms_app/bin/dev | 4 + spec/cms_app/bin/rails | 6 + spec/cms_app/bin/rake | 6 + spec/cms_app/bin/setup | 27 ++ spec/cms_app/config.ru | 6 + spec/cms_app/config/application.rb | 28 ++ spec/cms_app/config/boot.rb | 6 + spec/cms_app/config/database.yml | 16 + spec/cms_app/config/environment.rb | 5 + .../config/environments/development.rb | 7 + .../cms_app/config/environments/production.rb | 7 + spec/cms_app/config/environments/test.rb | 11 + spec/cms_app/config/importmap.rb | 7 + spec/cms_app/config/initializers/assets.rb | 5 + .../initializers/content_security_policy.rb | 7 + .../initializers/filter_parameter_logging.rb | 7 + .../config/initializers/inflections.rb | 6 + spec/cms_app/config/locales/en.yml | 2 + spec/cms_app/config/puma.rb | 10 + spec/cms_app/config/routes.rb | 5 + spec/cms_app/config/storage.yml | 7 + .../20260225000000_create_cms_tables.rb | 194 ++++++++ ...0225000001_create_active_storage_tables.rb | 48 ++ spec/cms_app/db/schema.rb | 263 +++++++++++ spec/cms_app/db/seed.rb | 22 + spec/cms_app/public/400.html | 5 + spec/cms_app/public/404.html | 5 + .../public/406-unsupported-browser.html | 5 + spec/cms_app/public/422.html | 5 + spec/cms_app/public/500.html | 5 + spec/cms_app/public/icon.png | Bin 0 -> 4166 bytes spec/cms_app/public/icon.svg | 3 + spec/cms_app/spec/factories/api_keys.rb | 9 + spec/cms_app/spec/factories/form_fields.rb | 13 + spec/cms_app/spec/factories/images.rb | 22 + .../spec/factories/page_translations.rb | 9 + spec/cms_app/spec/factories/pages.rb | 31 ++ spec/cms_app/spec/factories/sections.rb | 63 +++ spec/cms_app/spec/factories/sites.rb | 10 + spec/cms_app/spec/factories/webhooks.rb | 11 + spec/cms_app/spec/models/cms/api_key_spec.rb | 45 ++ .../spec/models/cms/page_search_spec.rb | 39 ++ spec/cms_app/spec/models/cms/page_spec.rb | 15 + .../cms_app/spec/models/cms/page_tree_spec.rb | 68 +++ .../models/cms/section/block_base_spec.rb | 56 +++ .../section/blocks/built_in_blocks_spec.rb | 53 +++ .../models/cms/section/kind_registry_spec.rb | 130 ++++++ spec/cms_app/spec/models/cms/webhook_spec.rb | 28 ++ .../spec/requests/cms/admin/pages_spec.rb | 81 ++++ .../spec/requests/cms/admin/sections_spec.rb | 270 +++++++++++ .../spec/requests/cms/admin/sites_spec.rb | 77 ++++ .../spec/requests/cms/api/v1/pages_spec.rb | 210 +++++++++ .../spec/requests/cms/api/v1/sites_spec.rb | 86 ++++ .../requests/cms/form_submissions_spec.rb | 60 +++ spec/cms_app/spec/requests/cms/pages_spec.rb | 205 +++++++++ .../spec/services/cms/page_resolver_spec.rb | 141 ++++++ spec/cms_app/spec/support/factory_bot.rb | 5 + spec/rails_helper.rb | 34 ++ spec/spec_helper.rb | 13 + 228 files changed, 8881 insertions(+), 100 deletions(-) create mode 100644 .rspec create mode 100644 CHANGELOG.md create mode 100644 Gemfile.lock create mode 100644 app/controllers/cms/admin/api_keys_controller.rb create mode 100644 app/controllers/cms/admin/base_controller.rb create mode 100644 app/controllers/cms/admin/documents_controller.rb create mode 100644 app/controllers/cms/admin/form_fields_controller.rb create mode 100644 app/controllers/cms/admin/form_submissions_controller.rb create mode 100644 app/controllers/cms/admin/images_controller.rb create mode 100644 app/controllers/cms/admin/pages_controller.rb create mode 100644 app/controllers/cms/admin/sections_controller.rb create mode 100644 app/controllers/cms/admin/sites_controller.rb create mode 100644 app/controllers/cms/admin/webhook_deliveries_controller.rb create mode 100644 app/controllers/cms/admin/webhooks_controller.rb create mode 100644 app/controllers/cms/api/base_controller.rb create mode 100644 app/controllers/cms/api/v1/base_controller.rb create mode 100644 app/controllers/cms/api/v1/pages_controller.rb create mode 100644 app/controllers/cms/api/v1/sites_controller.rb create mode 100644 app/controllers/cms/public/base_controller.rb create mode 100644 app/controllers/cms/public/form_submissions_controller.rb create mode 100644 app/controllers/cms/public/previews_controller.rb create mode 100644 app/controllers/cms/public/sites_controller.rb create mode 100644 app/controllers/concerns/cms/admin/page_scoped_sections.rb create mode 100644 app/controllers/concerns/cms/current_site_resolver.rb create mode 100644 app/controllers/concerns/cms/public/page_paths.rb create mode 100644 app/controllers/concerns/cms/public/page_rendering.rb create mode 100644 app/controllers/concerns/cms/site_resolvable.rb create mode 100644 app/helpers/cms/admin/pages_helper.rb create mode 100644 app/helpers/cms/admin/sections_helper.rb create mode 100644 app/helpers/cms/admin/sites_helper.rb create mode 100644 app/helpers/cms/media_helper.rb create mode 100644 app/helpers/cms/pages_helper.rb create mode 100644 app/helpers/cms/sections_helper.rb create mode 100644 app/helpers/cms/sites_helper.rb create mode 100644 app/javascript/cms/controllers/sortable_controller.js create mode 100644 app/jobs/cms/deliver_webhook_job.rb create mode 100644 app/mailers/cms/form_submission_mailer.rb create mode 100644 app/models/cms/api_key.rb create mode 100644 app/models/cms/document.rb create mode 100644 app/models/cms/form_field.rb create mode 100644 app/models/cms/form_submission.rb create mode 100644 app/models/cms/image.rb create mode 100644 app/models/cms/image_translation.rb create mode 100644 app/models/cms/page.rb create mode 100644 app/models/cms/page_section.rb create mode 100644 app/models/cms/page_translation.rb create mode 100644 app/models/cms/section.rb create mode 100644 app/models/cms/section/block_base.rb create mode 100644 app/models/cms/section/blocks/call_to_action_block.rb create mode 100644 app/models/cms/section/blocks/hero_block.rb create mode 100644 app/models/cms/section/blocks/image_block.rb create mode 100644 app/models/cms/section/blocks/rich_text_block.rb create mode 100644 app/models/cms/section/kind_registry.rb create mode 100644 app/models/cms/section_image.rb create mode 100644 app/models/cms/section_translation.rb create mode 100644 app/models/cms/site.rb create mode 100644 app/models/cms/webhook.rb create mode 100644 app/models/cms/webhook_delivery.rb create mode 100644 app/serializers/cms/api/base_serializer.rb create mode 100644 app/serializers/cms/api/page_serializer.rb create mode 100644 app/serializers/cms/api/site_serializer.rb create mode 100644 app/services/cms/locale_resolver.rb create mode 100644 app/services/cms/page_resolver.rb create mode 100644 app/services/cms/public_page_context.rb create mode 100644 app/views/cms/admin/api_keys/_form.html.erb create mode 100644 app/views/cms/admin/api_keys/create.html.erb create mode 100644 app/views/cms/admin/api_keys/edit.html.erb create mode 100644 app/views/cms/admin/api_keys/index.html.erb create mode 100644 app/views/cms/admin/api_keys/new.html.erb create mode 100644 app/views/cms/admin/documents/_form.html.erb create mode 100644 app/views/cms/admin/documents/edit.html.erb create mode 100644 app/views/cms/admin/documents/index.html.erb create mode 100644 app/views/cms/admin/documents/new.html.erb create mode 100644 app/views/cms/admin/form_fields/_form.html.erb create mode 100644 app/views/cms/admin/form_fields/edit.html.erb create mode 100644 app/views/cms/admin/form_fields/index.html.erb create mode 100644 app/views/cms/admin/form_fields/new.html.erb create mode 100644 app/views/cms/admin/form_submissions/index.html.erb create mode 100644 app/views/cms/admin/images/_form.html.erb create mode 100644 app/views/cms/admin/images/edit.html.erb create mode 100644 app/views/cms/admin/images/index.html.erb create mode 100644 app/views/cms/admin/images/new.html.erb create mode 100644 app/views/cms/admin/pages/_attach_section_panel.html.erb create mode 100644 app/views/cms/admin/pages/_form.html.erb create mode 100644 app/views/cms/admin/pages/_section_editor_frame.html.erb create mode 100644 app/views/cms/admin/pages/_sections_list.html.erb create mode 100644 app/views/cms/admin/pages/edit.html.erb create mode 100644 app/views/cms/admin/pages/index.html.erb create mode 100644 app/views/cms/admin/pages/new.html.erb create mode 100644 app/views/cms/admin/pages/show.html.erb create mode 100644 app/views/cms/admin/sections/_form.html.erb create mode 100644 app/views/cms/admin/sections/_section.html.erb create mode 100644 app/views/cms/admin/sections/edit.html.erb create mode 100644 app/views/cms/admin/sections/index.html.erb create mode 100644 app/views/cms/admin/sections/new.html.erb create mode 100644 app/views/cms/admin/sections/page_update.turbo_stream.erb create mode 100644 app/views/cms/admin/sections/show.html.erb create mode 100644 app/views/cms/admin/sites/_form.html.erb create mode 100644 app/views/cms/admin/sites/edit.html.erb create mode 100644 app/views/cms/admin/sites/new.html.erb create mode 100644 app/views/cms/admin/sites/show.html.erb create mode 100644 app/views/cms/admin/webhook_deliveries/index.html.erb create mode 100644 app/views/cms/admin/webhooks/_form.html.erb create mode 100644 app/views/cms/admin/webhooks/edit.html.erb create mode 100644 app/views/cms/admin/webhooks/index.html.erb create mode 100644 app/views/cms/admin/webhooks/new.html.erb create mode 100644 app/views/cms/form_submission_mailer/notify.html.erb create mode 100644 app/views/cms/form_submission_mailer/notify.text.erb create mode 100644 app/views/cms/public/pages/_content.html.erb create mode 100644 app/views/cms/public/pages/show.html.erb create mode 100644 app/views/cms/public/pages/templates/_custom.html.erb create mode 100644 app/views/cms/public/pages/templates/_form.html.erb create mode 100644 app/views/cms/public/pages/templates/_landing.html.erb create mode 100644 app/views/cms/public/pages/templates/_standard.html.erb create mode 100644 app/views/cms/sections/kinds/_cta.html.erb create mode 100644 app/views/cms/sections/kinds/_hero.html.erb create mode 100644 app/views/cms/sections/kinds/_image.html.erb create mode 100644 app/views/cms/sections/kinds/_rich_text.html.erb create mode 100644 app/views/layouts/cms/public.html.erb create mode 100644 config/importmap.rb create mode 100644 config/locales/activerecord.cms.en.yml create mode 100644 config/locales/en.yml create mode 100644 lib/generators/cms/install/install_generator.rb create mode 100644 lib/generators/cms/install/templates/create_cms_tables.rb create mode 100644 lib/generators/cms/install/templates/initializer.rb create mode 100644 lib/generators/cms/views/views_generator.rb create mode 100644 lib/tasks/version.rake create mode 100644 spec/cms/version_spec.rb create mode 100644 spec/cms_app/Rakefile create mode 100644 spec/cms_app/app/assets/images/.keep create mode 100644 spec/cms_app/app/assets/stylesheets/application.css create mode 100644 spec/cms_app/app/controllers/application_controller.rb create mode 100644 spec/cms_app/app/controllers/concerns/.keep create mode 100644 spec/cms_app/app/helpers/application_helper.rb create mode 100644 spec/cms_app/app/javascript/application.js create mode 100644 spec/cms_app/app/javascript/controllers/application.js create mode 100644 spec/cms_app/app/javascript/controllers/index.js create mode 100644 spec/cms_app/app/javascripts/application.js create mode 100644 spec/cms_app/app/jobs/application_job.rb create mode 100644 spec/cms_app/app/mailers/application_mailer.rb create mode 100644 spec/cms_app/app/models/application_record.rb create mode 100644 spec/cms_app/app/models/concerns/.keep create mode 100644 spec/cms_app/app/views/layouts/application.html.erb create mode 100644 spec/cms_app/app/views/layouts/mailer.html.erb create mode 100644 spec/cms_app/app/views/layouts/mailer.text.erb create mode 100644 spec/cms_app/app/views/pwa/manifest.json.erb create mode 100644 spec/cms_app/app/views/pwa/service-worker.js create mode 100755 spec/cms_app/bin/dev create mode 100755 spec/cms_app/bin/rails create mode 100755 spec/cms_app/bin/rake create mode 100755 spec/cms_app/bin/setup create mode 100644 spec/cms_app/config.ru create mode 100644 spec/cms_app/config/application.rb create mode 100644 spec/cms_app/config/boot.rb create mode 100644 spec/cms_app/config/database.yml create mode 100644 spec/cms_app/config/environment.rb create mode 100644 spec/cms_app/config/environments/development.rb create mode 100644 spec/cms_app/config/environments/production.rb create mode 100644 spec/cms_app/config/environments/test.rb create mode 100644 spec/cms_app/config/importmap.rb create mode 100644 spec/cms_app/config/initializers/assets.rb create mode 100644 spec/cms_app/config/initializers/content_security_policy.rb create mode 100644 spec/cms_app/config/initializers/filter_parameter_logging.rb create mode 100644 spec/cms_app/config/initializers/inflections.rb create mode 100644 spec/cms_app/config/locales/en.yml create mode 100644 spec/cms_app/config/puma.rb create mode 100644 spec/cms_app/config/routes.rb create mode 100644 spec/cms_app/config/storage.yml create mode 100644 spec/cms_app/db/migrate/20260225000000_create_cms_tables.rb create mode 100644 spec/cms_app/db/migrate/20260225000001_create_active_storage_tables.rb create mode 100644 spec/cms_app/db/schema.rb create mode 100644 spec/cms_app/db/seed.rb create mode 100644 spec/cms_app/public/400.html create mode 100644 spec/cms_app/public/404.html create mode 100644 spec/cms_app/public/406-unsupported-browser.html create mode 100644 spec/cms_app/public/422.html create mode 100644 spec/cms_app/public/500.html create mode 100644 spec/cms_app/public/icon.png create mode 100644 spec/cms_app/public/icon.svg create mode 100644 spec/cms_app/spec/factories/api_keys.rb create mode 100644 spec/cms_app/spec/factories/form_fields.rb create mode 100644 spec/cms_app/spec/factories/images.rb create mode 100644 spec/cms_app/spec/factories/page_translations.rb create mode 100644 spec/cms_app/spec/factories/pages.rb create mode 100644 spec/cms_app/spec/factories/sections.rb create mode 100644 spec/cms_app/spec/factories/sites.rb create mode 100644 spec/cms_app/spec/factories/webhooks.rb create mode 100644 spec/cms_app/spec/models/cms/api_key_spec.rb create mode 100644 spec/cms_app/spec/models/cms/page_search_spec.rb create mode 100644 spec/cms_app/spec/models/cms/page_spec.rb create mode 100644 spec/cms_app/spec/models/cms/page_tree_spec.rb create mode 100644 spec/cms_app/spec/models/cms/section/block_base_spec.rb create mode 100644 spec/cms_app/spec/models/cms/section/blocks/built_in_blocks_spec.rb create mode 100644 spec/cms_app/spec/models/cms/section/kind_registry_spec.rb create mode 100644 spec/cms_app/spec/models/cms/webhook_spec.rb create mode 100644 spec/cms_app/spec/requests/cms/admin/pages_spec.rb create mode 100644 spec/cms_app/spec/requests/cms/admin/sections_spec.rb create mode 100644 spec/cms_app/spec/requests/cms/admin/sites_spec.rb create mode 100644 spec/cms_app/spec/requests/cms/api/v1/pages_spec.rb create mode 100644 spec/cms_app/spec/requests/cms/api/v1/sites_spec.rb create mode 100644 spec/cms_app/spec/requests/cms/form_submissions_spec.rb create mode 100644 spec/cms_app/spec/requests/cms/pages_spec.rb create mode 100644 spec/cms_app/spec/services/cms/page_resolver_spec.rb create mode 100644 spec/cms_app/spec/support/factory_bot.rb create mode 100644 spec/rails_helper.rb create mode 100644 spec/spec_helper.rb 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? %> + + + + + + + + + + + + <% @api_keys.each do |key| %> + + + + + + + + <% end %> + +
<%= t(".name") %><%= t(".active") %><%= t(".last_used") %><%= t(".created") %>
<%= 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") } %> +
+<% 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 %> + + + + + + + + + + + <% @documents.each do |doc| %> + + + + + + + <% end %> + +
<%= t(".title_heading") %><%= t(".file") %><%= t(".description") %><%= t(".actions") %>
<%= 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 %> 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 %> + + + + + + + + + + + + <% @fields.each do |field| %> + + + + + + + + <% end %> + +
<%= t(".label") %><%= t(".field_name") %><%= t(".kind") %><%= t(".required") %><%= t(".actions") %>
<%= 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 %> 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 %> + + + + + <% @fields.each do |field| %> + + <% end %> + + + + + <% @submissions.each do |sub| %> + + + <% @fields.each do |field| %> + + <% end %> + + + <% end %> + +
<%= t(".submitted_at") %><%= field.label %><%= t(".actions") %>
<%= cms_datetime(sub.created_at) %><%= sub.data[field.field_name] %> + <%= link_to t(".delete"), + admin_page_form_submission_path(@page, sub), + data: { turbo_method: :delete, turbo_confirm: t(".delete_confirm") } %> +
+<% 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 %> + + + + + + + + + + + + + <% @page_rows.each do |page, depth| %> + + + + + + + + + <% end %> + +
<%= t(".title_heading") %><%= t(".slug") %><%= t(".template") %><%= t(".status") %><%= t(".nav") %><%= t(".actions") %>
<%= 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 %> 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? %> +
+
    + <% @section.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% 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" %> +
+ <%= label_tag "section_image_ids", t(".select_images"), class: "cms-form__label" %> + <%= hidden_field_tag "section[image_ids][]", "" %> + <% cms_section_image_options(@section.site).each do |label, value| %> +
+ <%= check_box_tag "section[image_ids][]", value, Array(@section.image_ids).map(&:to_s).include?(value.to_s) %> + <%= label_tag nil, label %> +
+ <% end %> + <%= image_library_actions %> +
+ <% 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 %> + + + + + + + + + + + + <% @sections.each do |section| %> + + + + + + + + <% end %> + +
<%= t(".kind") %><%= t(".title_heading") %><%= t(".usage") %><%= t(".status") %><%= t(".actions") %>
<%= 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 %> 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 %> + + + + + + + + + + + + <% @deliveries.each do |delivery| %> + + + + + + + + <% end %> + +
<%= 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") %>
<%= 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 %> 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? %> + + + + + + + + + + + <% @webhooks.each do |webhook| %> + + + + + + + <% end %> + +
<%= t(".url") %><%= t(".events") %><%= t(".active") %>
<%= 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") } %> +
+<% 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| %> + + + + + <% end %> +
<%= field.label %><%= @submission.data[field.field_name] %>
+ +

+ <%= 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 %> + +
+ <% if @site.logo.attached? %> +

+ <%= image_tag cms_attachment_path(@site.logo), alt: @site.name, style: "max-width: 220px; height: auto;" %> +

+ <% end %> + + +
+ +
+ <%= render_page_template(@page) %> +
+ +
+ <% @footer_pages.each do |page| %> + <%= link_to page.display_title, public_site_page_path_for(@site, page) %> + <% end %> +
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 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 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: "Το %