ruby_cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "settings_registry"
4
+ require_relative "settings"
5
+
6
+ module RubyCms
7
+ class Engine < ::Rails::Engine
8
+ # Do not isolate namespace so we can use /admin and explicit table names.
9
+ # Engine models use unprefixed table names: content_blocks, preferences, permissions, user_permissions, visitor_errors.
10
+
11
+ config.ruby_cms = ActiveSupport::OrderedOptions.new
12
+
13
+ # Base controller for all /admin controllers. Must provide current_user and
14
+ # run require_authentication (or equivalent). Default: ApplicationController.
15
+ config.ruby_cms.admin_base_controller = "ApplicationController"
16
+ # Layout used for /admin pages. Default: "admin/admin" (app's layouts/admin/admin.html.erb).
17
+ config.ruby_cms.admin_layout = "admin/admin"
18
+ config.ruby_cms.user_class_name = "User"
19
+
20
+ # When true, allow user.admin? as bypass when no Permission records exist (bootstrap).
21
+ config.ruby_cms.bootstrap_admin_with_role = true
22
+
23
+ # Path to redirect to when unauthenticated or not permitted (e.g. "/" or "/session/new").
24
+ # main_app.root_path is not used by default because the host may not define a root route.
25
+ config.ruby_cms.unauthorized_redirect_path = "/"
26
+
27
+ # Callable to resolve "current user" from the request. Receives controller, returns user or nil.
28
+ # Default: ->(c) { c.respond_to?(:current_user) ? c.current_user : nil }
29
+ config.ruby_cms.current_user_resolver = lambda {|controller|
30
+ controller.respond_to?(:current_user) ? controller.current_user : nil
31
+ }
32
+
33
+ # Visual editor: allowlist of page_key => template path (e.g. "home" => "pages/home").
34
+ # Can be extended by Page model: Page.preview_templates_hash merges config with Page records.
35
+ config.ruby_cms.preview_templates = {}
36
+ # Proc to inject preview data: ->(page_key, view_context) { { @products => [] } }
37
+ config.ruby_cms.preview_data = ->(_page_key, _view) { {} }
38
+ # Optional: audit edits from the visual editor. ->(content_block_id, user_id, changes) { }
39
+ config.ruby_cms.audit_editor_edit = nil
40
+
41
+ # Content blocks: reserved key prefixes (e.g. "admin_") cannot be used.
42
+ config.ruby_cms.reserved_key_prefixes = %w[admin_]
43
+ # Content blocks: default translation namespace
44
+ # (e.g., "content_blocks" or "cms")
45
+ # When set, content_block helper will try translations
46
+ # at namespace.key before root-level key
47
+ # Example: If namespace is "content_blocks",
48
+ # it tries "content_blocks.home_hero_title" then "home_hero_title"
49
+ config.ruby_cms.content_blocks_translation_namespace = nil
50
+ # Image attachment: allowed content types and max size.
51
+ config.ruby_cms.image_content_types = %w[image/png image/jpeg image/gif image/webp]
52
+ # Keep this numeric so engine boot does not depend on core-ext load order.
53
+ config.ruby_cms.image_max_size = 5 * 1024 * 1024
54
+
55
+ # Ensure Ahoy is loaded before host's config/initializers/ahoy.rb runs
56
+ initializer "ruby_cms.require_ahoy", before: :load_config_initializers do
57
+ require "ahoy_matey"
58
+ end
59
+
60
+ initializer "ruby_cms.i18n" do |app|
61
+ app.config.i18n.load_path += Dir[config.root.join("config", "locales", "**", "*.yml")]
62
+ end
63
+
64
+ initializer "ruby_cms.helpers" do
65
+ ActiveSupport.on_load(:action_controller_base) do
66
+ helper RubyCms::ApplicationHelper
67
+ helper RubyCms::ContentBlocksHelper
68
+ helper RubyCms::SettingsHelper
69
+ helper RubyCms::BulkActionTableHelper
70
+ helper RubyCms::Admin::BulkActionTableHelper
71
+ helper RubyCms::Admin::AdminPageHelper
72
+ end
73
+ end
74
+
75
+ initializer "ruby_cms.assets", before: :load_config_initializers do |app|
76
+ # Add JavaScript controllers to asset pipeline (before importmap resolves)
77
+ app.config.assets.paths.unshift(config.root.join("app/javascript")) if app.config.respond_to?(:assets)
78
+ # Add stylesheets to asset pipeline
79
+ app.config.assets.paths << config.root.join("app/assets/stylesheets") if app.config.respond_to?(:assets)
80
+ end
81
+
82
+ initializer "ruby_cms.importmap", before: "importmap" do |app|
83
+ # For importmap: ensure engine's importmap is loaded
84
+ if app.config.respond_to?(:importmap)
85
+ app.config.importmap.paths << config.root.join("config/importmap.rb")
86
+ app.config.importmap.cache_sweepers << config.root.join("app/javascript")
87
+ end
88
+ end
89
+
90
+ initializer "ruby_cms.nav" do
91
+ Rails.application.config.to_prepare do
92
+ RubyCms::Engine.register_main_nav_items
93
+ RubyCms::Engine.register_settings_nav_items
94
+ end
95
+ end
96
+
97
+ initializer "ruby_cms.settings_import", after: :load_config_initializers do
98
+ RubyCms::Settings.import_initializer_values!
99
+ end
100
+
101
+ def self.register_main_nav_items
102
+ RubyCms.nav_register(
103
+ key: :dashboard,
104
+ label: "Dashboard",
105
+ path: lambda(&:ruby_cms_admin_root_path),
106
+ icon: dashboard_icon_path,
107
+ section: RubyCms::NAV_SECTION_MAIN,
108
+ order: 1
109
+ )
110
+ RubyCms.nav_register(
111
+ key: :visual_editor,
112
+ label: "Visual editor",
113
+ path: lambda(&:ruby_cms_admin_visual_editor_path),
114
+ icon: visual_editor_icon_path,
115
+ section: RubyCms::NAV_SECTION_MAIN,
116
+ order: 2
117
+ )
118
+ RubyCms.nav_register(
119
+ key: :content_blocks,
120
+ label: "Content blocks",
121
+ path: lambda(&:ruby_cms_admin_content_blocks_path),
122
+ icon: content_blocks_icon_path,
123
+ section: RubyCms::NAV_SECTION_MAIN,
124
+ order: 3
125
+ )
126
+ end
127
+
128
+ def self.dashboard_icon_path
129
+ # Heroicons HomeIcon (outline)
130
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
131
+ 'd="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3' \
132
+ 'm-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>'
133
+ end
134
+
135
+ def self.content_blocks_icon_path
136
+ # Heroicons DocumentDuplicateIcon (outline)
137
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
138
+ 'd="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414' \
139
+ 'a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 ' \
140
+ '0 002-2v-2"></path>'
141
+ end
142
+
143
+ def self.visual_editor_icon_path
144
+ # Heroicons PencilSquareIcon (outline)
145
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
146
+ 'd="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 ' \
147
+ '0 012.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>'
148
+ end
149
+
150
+ def self.register_settings_nav_items
151
+ RubyCms.nav_register(
152
+ key: :analytics,
153
+ label: "Analytics",
154
+ path: lambda(&:ruby_cms_admin_analytics_path),
155
+ section: RubyCms::NAV_SECTION_BOTTOM,
156
+ icon: analytics_icon_path,
157
+ permission: :manage_analytics,
158
+ order: 1
159
+ )
160
+ RubyCms.nav_register(
161
+ key: :permissions,
162
+ label: "Permissions",
163
+ path: lambda(&:ruby_cms_admin_permissions_path),
164
+ section: RubyCms::NAV_SECTION_BOTTOM,
165
+ icon: permissions_icon_path,
166
+ order: 2
167
+ )
168
+ RubyCms.nav_register(
169
+ key: :visitor_errors,
170
+ label: "Visitor errors",
171
+ path: lambda(&:ruby_cms_admin_visitor_errors_path),
172
+ section: RubyCms::NAV_SECTION_BOTTOM,
173
+ icon: visitor_errors_icon_path,
174
+ order: 3
175
+ )
176
+ RubyCms.nav_register(
177
+ key: :users,
178
+ label: "Users",
179
+ path: lambda(&:ruby_cms_admin_users_path),
180
+ section: RubyCms::NAV_SECTION_BOTTOM,
181
+ icon: users_icon_path,
182
+ order: 4
183
+ )
184
+ RubyCms.nav_register(
185
+ key: :settings,
186
+ label: "Settings",
187
+ path: lambda(&:ruby_cms_admin_settings_path),
188
+ section: RubyCms::NAV_SECTION_BOTTOM,
189
+ icon: settings_icon_path,
190
+ order: 5
191
+ )
192
+ end
193
+
194
+ def self.settings_icon_path
195
+ # Heroicons Cog6ToothIcon (outline)
196
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
197
+ 'd="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 ' \
198
+ '1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 ' \
199
+ '1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 ' \
200
+ '6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 ' \
201
+ '0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 ' \
202
+ '1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 ' \
203
+ '6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 ' \
204
+ '1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 ' \
205
+ '1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"></path>' \
206
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>'
207
+ end
208
+
209
+ def self.visitor_errors_icon_path
210
+ # Heroicons ExclamationTriangleIcon (outline)
211
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
212
+ 'd="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 ' \
213
+ '4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>'
214
+ end
215
+
216
+ def self.permissions_icon_path
217
+ # Heroicons ShieldCheckIcon (outline)
218
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
219
+ 'd="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01' \
220
+ '-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 ' \
221
+ '9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>'
222
+ end
223
+
224
+ def self.users_icon_path
225
+ # Heroicons UserGroupIcon (outline)
226
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
227
+ 'd="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 ' \
228
+ '00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>'
229
+ end
230
+
231
+ def self.analytics_icon_path
232
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
233
+ 'd="M3 3v18h18"></path>' \
234
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" ' \
235
+ 'd="M7 13l3-3 3 2 4-5"></path>'
236
+ end
237
+
238
+ def self.compile_admin_css(dest_path)
239
+ gem_root = begin
240
+ root
241
+ rescue StandardError
242
+ Pathname.new(File.expand_path("../..", __dir__))
243
+ end
244
+ RubyCms::CssCompiler.compile(gem_root, dest_path)
245
+ end
246
+
247
+ config.paths.add "db/migrate", with: "db/migrate"
248
+
249
+ rake_tasks do # rubocop:disable Metrics/BlockLength
250
+ namespace :ruby_cms do # rubocop:disable Metrics/BlockLength
251
+ desc "Create default permissions/settings and optionally grant manage_admin to admin users"
252
+ task seed_permissions: :environment do
253
+ RubyCms::Permission.ensure_defaults!
254
+ RubyCms::Settings.ensure_defaults!
255
+ RubyCms::Settings.import_initializer_values!
256
+ RubyCms::Engine.grant_admin_permissions_to_admin_users
257
+ end
258
+ desc "Import RubyCMS initializer values into DB settings once"
259
+ task import_initializer_settings: :environment do
260
+ result = RubyCms::Settings.import_initializer_values!
261
+ if result[:skipped]
262
+ puts "Initializer import skipped: #{result[:reason]}" # rubocop:disable Rails/Output
263
+ else
264
+ puts "Imported #{result[:imported_count]} initializer setting(s)." # rubocop:disable Rails/Output
265
+ end
266
+ end
267
+
268
+ desc "Interactively create or select the first admin user " \
269
+ "and grant full permissions (manage_admin, manage_permissions, " \
270
+ "manage_content_blocks, etc.)"
271
+ task setup_admin: :environment do
272
+ require "ruby_cms/cli"
273
+ RubyCms::RunSetupAdmin.call(shell: Thor::Shell::Basic.new)
274
+ end
275
+
276
+ desc "Grant manage_admin to a user by email. " \
277
+ "Usage: rails ruby_cms:grant_manage_admin email=user@example.com"
278
+ task :grant_manage_admin, [:email] => :environment do |_t, args|
279
+ email = RubyCms::Engine.extract_email_from_args(args)
280
+ RubyCms::Engine.validate_email_present(email)
281
+
282
+ RubyCms::Permission.ensure_defaults!
283
+ user = RubyCms::Engine.find_user_by_email(email)
284
+ RubyCms::Engine.validate_user_found(user, email)
285
+
286
+ RubyCms::Engine.grant_manage_admin_permission(user, email)
287
+ end
288
+
289
+ namespace :content_blocks do # rubocop:disable Metrics/BlockLength
290
+ desc "Export content blocks from database to YAML locale files"
291
+ task :export, %i[namespace locales_dir] => :environment do |_t, args|
292
+ require "ruby_cms/content_blocks_sync"
293
+
294
+ namespace = args[:namespace].presence
295
+ locales_dir = RubyCms::Engine.parse_locales_dir(args[:locales_dir])
296
+ flatten = ENV["flatten"] == "true"
297
+
298
+ sync = RubyCms::ContentBlocksSync.new(namespace:, locales_dir:)
299
+ summary = sync.export_to_yaml(only_published: true, flatten_keys: flatten)
300
+
301
+ RubyCms::Engine.display_export_summary(summary)
302
+ end
303
+
304
+ desc "Import content blocks from YAML locale files to database"
305
+ task :import, %i[locale namespace locales_dir] => :environment do |_t, args|
306
+ require "ruby_cms/content_blocks_sync"
307
+
308
+ locale = args[:locale].presence&.to_sym
309
+ namespace = args[:namespace].presence ||
310
+ Rails.application.config.ruby_cms.content_blocks_translation_namespace
311
+ locales_dir = RubyCms::Engine.parse_locales_dir(args[:locales_dir]) ||
312
+ Rails.root.join("config/locales")
313
+ import_options = RubyCms::Engine.parse_import_options
314
+
315
+ sync = RubyCms::ContentBlocksSync.new(namespace:, locales_dir:)
316
+ summary = sync.import_from_yaml(locale:, **import_options)
317
+
318
+ RubyCms::Engine.display_import_summary(summary)
319
+ end
320
+
321
+ desc "Sync content blocks: export DB to YAML, optionally import from YAML"
322
+ task :sync, %i[namespace locales_dir] => :environment do |_t, args|
323
+ require "ruby_cms/content_blocks_sync"
324
+
325
+ namespace = args[:namespace].presence
326
+ locales_dir = RubyCms::Engine.parse_locales_dir(args[:locales_dir])
327
+ import_after = ENV["import_after"] == "true"
328
+
329
+ sync = RubyCms::ContentBlocksSync.new(namespace:, locales_dir:)
330
+ result = sync.sync(import_after_export: import_after)
331
+
332
+ RubyCms::Engine.display_sync_summary(result, import_after)
333
+ end
334
+ end
335
+
336
+ namespace :css do
337
+ desc "Compile RubyCMS admin.css from component files (for gem development)"
338
+ task compile_gem: :environment do
339
+ dest = RubyCms::Engine.root.join("app/assets/stylesheets/ruby_cms/admin.css")
340
+ RubyCms::Engine.compile_admin_css(dest)
341
+ puts "✓ Compiled admin.css in gem" # rubocop:disable Rails/Output
342
+ end
343
+
344
+ desc "Compile RubyCMS CSS to host app (combines component files)"
345
+ task compile: :environment do
346
+ require "fileutils"
347
+ dest_dir = Rails.root.join("app/assets/stylesheets/ruby_cms")
348
+ FileUtils.mkdir_p(dest_dir)
349
+ dest = dest_dir.join("admin.css")
350
+ RubyCms::Engine.compile_admin_css(dest)
351
+ puts "✓ Compiled admin.css to #{dest}" # rubocop:disable Rails/Output
352
+ puts "✓ RubyCMS CSS compilation complete!" # rubocop:disable Rails/Output
353
+ end
354
+ end
355
+ end
356
+ end
357
+
358
+ def self.grant_admin_permissions_to_admin_users
359
+ return unless defined?(::User) && User.column_names.include?("admin")
360
+
361
+ permission_keys = %w[
362
+ manage_admin
363
+ manage_permissions
364
+ manage_content_blocks
365
+ manage_visitor_errors
366
+ manage_analytics
367
+ ]
368
+ permissions = RubyCms::Permission.where(key: permission_keys).index_by(&:key)
369
+ User.where(admin: true).find_each do |u|
370
+ permission_keys.each do |key|
371
+ perm = permissions[key]
372
+ next if perm.nil?
373
+
374
+ RubyCms::UserPermission.find_or_create_by!(user: u, permission: perm)
375
+ end
376
+ end
377
+ end
378
+
379
+ def self.extract_email_from_args(args)
380
+ args[:email] || ENV["email"] || ENV.fetch("EMAIL", nil)
381
+ end
382
+
383
+ def self.validate_email_present(email)
384
+ return if email.present?
385
+
386
+ warn "Usage: rails ruby_cms:grant_manage_admin " \
387
+ "email=user@example.com"
388
+ raise "Email is required"
389
+ end
390
+
391
+ def self.find_user_by_email(email)
392
+ user_class = Rails.application.config.ruby_cms.user_class_name
393
+ .constantize
394
+ find_user_by_email_address(user_class, email) ||
395
+ find_user_by_email_column(user_class, email)
396
+ end
397
+
398
+ def self.find_user_by_email_address(user_class, email)
399
+ return unless user_class.column_names.include?("email_address")
400
+
401
+ user_class.find_by(email_address: email)
402
+ end
403
+
404
+ def self.find_user_by_email_column(user_class, email)
405
+ return unless user_class.column_names.include?("email")
406
+
407
+ user_class.find_by(email:)
408
+ end
409
+
410
+ def self.validate_user_found(user, email)
411
+ return if user
412
+
413
+ warn "User not found: #{email}"
414
+ raise "User not found: #{email}"
415
+ end
416
+
417
+ def self.grant_manage_admin_permission(user, email)
418
+ RubyCms::Permission.ensure_defaults!
419
+ %w[
420
+ manage_admin manage_permissions manage_content_blocks manage_visitor_errors
421
+ manage_analytics
422
+ ].each do |key|
423
+ perm = RubyCms::Permission.find_by!(key:)
424
+ RubyCms::UserPermission.find_or_create_by!(user: user, permission: perm)
425
+ end
426
+ puts "Granted full admin permissions to #{email}" # rubocop:disable Rails/Output
427
+ end
428
+
429
+ def self.parse_locales_dir(locales_dir_arg)
430
+ return nil unless locales_dir_arg.presence
431
+
432
+ Pathname.new(locales_dir_arg)
433
+ end
434
+
435
+ def self.parse_import_options
436
+ {
437
+ create_missing: ENV["create_missing"] != "false",
438
+ update_existing: ENV["update_existing"] != "false",
439
+ published: ENV["published"] == "true"
440
+ }
441
+ end
442
+
443
+ def self.display_export_summary(summary)
444
+ if summary.empty?
445
+ puts "No content blocks found to export." # rubocop:disable Rails/Output
446
+ else
447
+ puts "Exported content blocks to locale files:" # rubocop:disable Rails/Output
448
+ summary.each do |locale, count|
449
+ # rubocop:disable Rails/Output
450
+ puts " #{locale}: #{count} block(s) updated " \
451
+ "in config/locales/#{locale}.yml"
452
+ # rubocop:enable Rails/Output
453
+ end
454
+ end
455
+ end
456
+
457
+ def self.display_import_summary(summary)
458
+ $stdout.puts "Import summary:"
459
+ $stdout.puts " Created: #{summary[:created]}"
460
+ $stdout.puts " Updated: #{summary[:updated]}"
461
+ $stdout.puts " Skipped: #{summary[:skipped]}"
462
+ return unless summary[:errors].any?
463
+
464
+ $stdout.puts " Errors:"
465
+ summary[:errors].each {|e| $stdout.puts " - #{e}" }
466
+ end
467
+
468
+ def self.display_sync_summary(result, import_after)
469
+ display_export_results(result[:export])
470
+ display_import_results(result[:import], import_after) if import_after
471
+ end
472
+
473
+ def self.display_export_results(export_data)
474
+ $stdout.puts "Sync complete!"
475
+ $stdout.puts "\nExport summary:"
476
+ export_data.each do |locale, count|
477
+ $stdout.puts " #{locale}: #{count} block(s) updated"
478
+ end
479
+ end
480
+
481
+ def self.display_import_results(import_data, import_after)
482
+ return unless import_after && import_data.any?
483
+
484
+ $stdout.puts "\nImport summary:"
485
+ $stdout.puts " Created: #{import_data[:created]}"
486
+ $stdout.puts " Updated: #{import_data[:updated]}"
487
+ $stdout.puts " Skipped: #{import_data[:skipped]}"
488
+ end
489
+
490
+ initializer "ruby_cms.load_migrations" do |app|
491
+ next unless app.config.respond_to?(:paths)
492
+
493
+ config.paths["db/migrate"].expanded.each do |path|
494
+ app.config.paths["db/migrate"] << path
495
+ end
496
+ end
497
+ end
498
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "settings_registry"
4
+
5
+ module RubyCms
6
+ module Settings
7
+ IMPORT_SENTINEL_KEY = "__internal_initializer_import_v1"
8
+
9
+ class << self
10
+ def get(key, default: nil)
11
+ k = key.to_s
12
+ pref = fetch_preference(k)
13
+ return pref.typed_value if pref
14
+
15
+ entry = RubyCms::SettingsRegistry.fetch(k)
16
+ return entry.default unless entry.nil?
17
+
18
+ default
19
+ end
20
+
21
+ def set(key, value)
22
+ raise "Settings table not available yet" unless preference_table_available?
23
+
24
+ k = key.to_s
25
+ entry = RubyCms::SettingsRegistry.fetch(k)
26
+ coerced = coerce_by_entry(value, entry)
27
+
28
+ RubyCms::Preference.set(k, coerced)
29
+ end
30
+
31
+ def ensure_defaults!
32
+ RubyCms::SettingsRegistry.seed_defaults!
33
+ RubyCms::Preference.ensure_defaults!
34
+ end
35
+
36
+ def all
37
+ return {} unless preference_table_available?
38
+
39
+ RubyCms::Preference.all_as_hash
40
+ end
41
+
42
+ # Imports initializer values for any key that exists in SettingsRegistry
43
+ # and is explicitly set on config.ruby_cms.
44
+ def import_initializer_values!(force: false)
45
+ return skipped_result("preferences table unavailable") unless preference_table_available?
46
+
47
+ ensure_defaults!
48
+
49
+ return skipped_result("already imported") if imported_from_initializer? && !force
50
+
51
+ config = ruby_cms_config
52
+ return skipped_result("ruby_cms config unavailable") if config.nil?
53
+
54
+ imported_keys = []
55
+
56
+ RubyCms::SettingsRegistry.each do |entry|
57
+ key = entry.key.to_sym
58
+ next unless config.respond_to?(key)
59
+
60
+ value = config.public_send(key)
61
+ next if value.nil?
62
+
63
+ set(key, value)
64
+ imported_keys << entry.key
65
+ end
66
+
67
+ mark_imported!(imported_keys)
68
+
69
+ {
70
+ skipped: false,
71
+ imported_count: imported_keys.size,
72
+ imported_keys: imported_keys
73
+ }
74
+ rescue StandardError => e
75
+ skipped_result(e.message)
76
+ end
77
+
78
+ def imported_from_initializer?
79
+ return false unless preference_table_available?
80
+
81
+ RubyCms::Preference.exists?(key: IMPORT_SENTINEL_KEY)
82
+ rescue StandardError
83
+ false
84
+ end
85
+
86
+ private
87
+
88
+ def skipped_result(reason)
89
+ { skipped: true, reason: reason, imported_count: 0 }
90
+ end
91
+
92
+ def ruby_cms_config
93
+ return nil unless defined?(Rails) && Rails.application.config.respond_to?(:ruby_cms)
94
+
95
+ Rails.application.config.ruby_cms
96
+ rescue StandardError
97
+ nil
98
+ end
99
+
100
+ def mark_imported!(imported_keys)
101
+ RubyCms::Preference.set(
102
+ IMPORT_SENTINEL_KEY,
103
+ {
104
+ version: 1,
105
+ imported_at: Time.current.iso8601,
106
+ imported_keys: imported_keys
107
+ }
108
+ )
109
+ end
110
+
111
+ def fetch_preference(key)
112
+ return nil unless preference_table_available?
113
+
114
+ RubyCms::Preference.find_by(key:)
115
+ rescue StandardError
116
+ nil
117
+ end
118
+
119
+ def preference_table_available?
120
+ return false unless defined?(ActiveRecord::Base) && defined?(RubyCms::Preference)
121
+
122
+ ActiveRecord::Base.connection.data_source_exists?("preferences")
123
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
124
+ false
125
+ end
126
+
127
+ def coerce_by_entry(value, entry)
128
+ return value if entry.nil?
129
+
130
+ case entry.type
131
+ when :integer
132
+ value.to_i
133
+ when :boolean
134
+ ActiveModel::Type::Boolean.new.cast(value)
135
+ when :json
136
+ value.kind_of?(String) ? JSON.parse(value) : value
137
+ else
138
+ value.to_s
139
+ end
140
+ rescue JSON::ParserError
141
+ value
142
+ end
143
+ end
144
+ end
145
+ end