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,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RubyCms
6
+ # Service class for syncing content blocks between database and YAML locale files
7
+ class ContentBlocksSync
8
+ class Error < StandardError; end
9
+
10
+ def initialize(namespace: nil, locales_dir: nil)
11
+ @namespace = namespace || default_namespace_from_config
12
+ @locales_dir = locales_dir || Rails.root.join("config/locales")
13
+ end
14
+
15
+ # Export all published content blocks to YAML files
16
+ # Creates/updates locale files for each locale configured in I18n.available_locales
17
+ # @param only_published [Boolean] If true, only export published blocks
18
+ # @param flatten_keys [Boolean] If true, flatten dot-separated keys into nested structure
19
+ # @return [Hash] Summary of exported blocks per locale
20
+ def export_to_yaml(only_published: true, flatten_keys: false)
21
+ scope = only_published ? RubyCms::ContentBlock.published : RubyCms::ContentBlock.all
22
+ blocks_by_locale = scope.order(:key).group_by(&:locale)
23
+
24
+ summary = {}
25
+ I18n.available_locales.each do |locale|
26
+ summary[locale] = export_locale_to_yaml(
27
+ locale, blocks_by_locale, flatten_keys:
28
+ )
29
+ end
30
+
31
+ summary
32
+ end
33
+
34
+ # Import content blocks from YAML locale files to database
35
+ # @param locale [Symbol, String] Specific locale to import, or nil for all locales
36
+ # @param create_missing [Boolean] Create content blocks that don't exist in DB
37
+ # @param update_existing [Boolean] Update existing content blocks
38
+ # @param published [Boolean] Set published status for imported blocks
39
+ # @return [Hash] Summary of imported/updated blocks
40
+ def import_from_yaml(locale: nil, create_missing: true, update_existing: true, published: false)
41
+ locales_to_process = locale ? [locale.to_sym] : I18n.available_locales
42
+ summary = { created: 0, updated: 0, skipped: 0, errors: [] }
43
+
44
+ locales_to_process.each do |loc|
45
+ import_locale_from_yaml(
46
+ loc,
47
+ summary,
48
+ create_missing:,
49
+ update_existing:,
50
+ published:
51
+ )
52
+ end
53
+
54
+ summary
55
+ end
56
+
57
+ # Sync: export database to YAML, then optionally import from YAML
58
+ # Useful for keeping both in sync
59
+ # @param import_after_export [Boolean] Import from YAML after exporting
60
+ # @return [Hash] Summary of operations
61
+ def sync(import_after_export: false)
62
+ result = { export: {}, import: {} }
63
+
64
+ # Export database to YAML
65
+ result[:export] = export_to_yaml
66
+
67
+ # Optionally import from YAML (useful for seeding from YAML)
68
+ result[:import] = import_from_yaml if import_after_export
69
+
70
+ result
71
+ end
72
+
73
+ private
74
+
75
+ def default_namespace_from_config
76
+ Rails.application.config.ruby_cms.content_blocks_translation_namespace
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ def export_locale_to_yaml(locale, blocks_by_locale, flatten_keys:)
82
+ locale_str = locale.to_s
83
+ locale_file = @locales_dir.join("#{locale}.yml")
84
+ blocks = blocks_by_locale[locale_str] || []
85
+ blocks_hash = blocks.index_by(&:key)
86
+ update_locale_file(locale_file, locale, blocks_hash, flatten_keys:)
87
+ end
88
+
89
+ def update_locale_file(locale_file, locale, blocks, flatten_keys: false)
90
+ existing_data = load_existing_locale_data(locale_file)
91
+ locale_root = ensure_locale_root(existing_data, locale)
92
+ target_hash = target_hash_for_locale(locale_root)
93
+
94
+ updated_count = update_target_hash(
95
+ target_hash,
96
+ blocks,
97
+ flatten_keys:
98
+ )
99
+
100
+ write_locale_file(locale_file, existing_data)
101
+ updated_count
102
+ end
103
+
104
+ def load_existing_locale_data(locale_file)
105
+ return {} unless locale_file.exist?
106
+
107
+ safe_yaml_load_file(locale_file) || {}
108
+ end
109
+
110
+ def ensure_locale_root(existing_data, locale)
111
+ existing_data[locale.to_s] ||= {}
112
+ end
113
+
114
+ def target_hash_for_locale(locale_root)
115
+ return locale_root if @namespace.blank?
116
+
117
+ locale_root[@namespace] ||= {}
118
+ locale_root[@namespace]
119
+ end
120
+
121
+ def update_target_hash(target_hash, blocks, flatten_keys:)
122
+ updated_count = 0
123
+
124
+ blocks.each do |key, block|
125
+ updated_count += apply_block_to_target_hash(
126
+ target_hash,
127
+ key,
128
+ block,
129
+ flatten_keys:
130
+ )
131
+ end
132
+
133
+ updated_count
134
+ end
135
+
136
+ def apply_block_to_target_hash(target_hash, key, block, flatten_keys:)
137
+ content = extract_content_from_block(block)
138
+ key_str = key.to_s
139
+
140
+ if flatten_keys && key_str.include?(".")
141
+ merge_nested_hash(target_hash, unflatten_key(key_str, content))
142
+ return 0
143
+ end
144
+
145
+ return 0 if target_hash[key_str] == content
146
+
147
+ target_hash[key_str] = content
148
+ 1
149
+ end
150
+
151
+ def write_locale_file(locale_file, existing_data)
152
+ File.write(locale_file, existing_data.to_yaml)
153
+ end
154
+
155
+ # Convert dot-separated key to nested hash structure
156
+ # Example: "hero.title" => { "hero" => { "title" => content } }
157
+ def unflatten_key(key, content)
158
+ parts = key.split(".")
159
+ result = {}
160
+ current = result
161
+
162
+ parts[0..-2].each do |part|
163
+ current[part] = {}
164
+ current = current[part]
165
+ end
166
+
167
+ current[parts.last] = content
168
+ result
169
+ end
170
+
171
+ # Merge nested hash into target hash
172
+ def merge_nested_hash(target, source)
173
+ source.each do |key, value|
174
+ if value.kind_of?(Hash) && target[key].kind_of?(Hash)
175
+ merge_nested_hash(target[key], value)
176
+ else
177
+ target[key] = value
178
+ end
179
+ end
180
+ end
181
+
182
+ def extract_content_from_block(block)
183
+ return rich_text_as_plain_text(block) if block.content_type == "rich_text"
184
+
185
+ block.content.to_s
186
+ end
187
+
188
+ def rich_text_as_plain_text(block)
189
+ return block.content.to_s unless block.respond_to?(:rich_content)
190
+ return block.content.to_s if block.rich_content.blank?
191
+
192
+ block.rich_content.to_plain_text.presence || block.content.to_s
193
+ end
194
+
195
+ def extract_blocks_from_locale(locale_data, locale)
196
+ locale_key = locale.to_s
197
+ return {} unless locale_data[locale_key]
198
+
199
+ if @namespace.present?
200
+ namespace_data = locale_data[locale_key][@namespace]
201
+ return {} unless namespace_data
202
+
203
+ flatten_hash(namespace_data)
204
+ else
205
+ # Filter out non-content-block keys (like activemodel, etc.)
206
+ filtered = locale_data[locale_key].reject {|k, _v| reserved_keys.include?(k.to_s) }
207
+ flatten_hash(filtered)
208
+ end
209
+ end
210
+
211
+ # Flatten nested hash structure to dot-separated keys
212
+ # Example: { hero: { title: "..." } } => { "hero.title" => "..." }
213
+ def flatten_hash(hash, prefix: nil)
214
+ result = {}
215
+ hash.each do |key, value|
216
+ merge_flattened_value(result, key, value, prefix)
217
+ end
218
+ result
219
+ end
220
+
221
+ def merge_flattened_value(result, key, value, prefix)
222
+ new_key = prefix ? "#{prefix}.#{key}" : key.to_s
223
+
224
+ if value.kind_of?(Hash)
225
+ result.merge!(flatten_hash(value, prefix: new_key))
226
+ return
227
+ end
228
+
229
+ return if value.kind_of?(Array)
230
+
231
+ result[new_key] = value.to_s
232
+ end
233
+
234
+ def reserved_keys
235
+ %w[activemodel activerecord date time number currency support]
236
+ end
237
+
238
+ def import_locale_from_yaml(loc, summary, create_missing:, update_existing:, published:)
239
+ locale_file = @locales_dir.join("#{loc}.yml")
240
+ return unless locale_file.exist?
241
+
242
+ locale_data = safe_yaml_load_file(locale_file)
243
+ blocks_data = extract_blocks_from_locale(locale_data, loc)
244
+
245
+ assign_blocks_data_to_summary(summary, blocks_data, loc, create_missing:, update_existing:,
246
+ published:)
247
+ rescue StandardError => e
248
+ summary[:errors] << "Error processing #{loc}: #{e.message}"
249
+ end
250
+
251
+ def safe_yaml_load_file(path)
252
+ # Locale YAML should deserialize to Hash/Array/String/etc. Avoid YAML.load_file (object deserialization).
253
+ opts = {
254
+ permitted_classes: [],
255
+ permitted_symbols: [],
256
+ aliases: true
257
+ }
258
+
259
+ return YAML.safe_load_file(path, **opts) if YAML.respond_to?(:safe_load_file)
260
+
261
+ YAML.safe_load_file(path, **opts)
262
+ end
263
+
264
+ def assign_blocks_data_to_summary(summary, blocks_data, locale, create_missing:, update_existing:, published:)
265
+ blocks_data.each do |key, content|
266
+ result = import_block(key, content, locale, create_missing:, update_existing:, published:)
267
+ summary[result[:action]] += 1
268
+ summary[:errors] << result[:error] if result[:error]
269
+ end
270
+ summary
271
+ end
272
+
273
+ def import_block(key, content, locale, create_missing:, update_existing:, published:)
274
+ block = RubyCms::ContentBlock.find_by(key: key, locale: locale.to_s)
275
+ return import_new_block(key, content, locale, published) if block.nil? && create_missing
276
+ return { action: :skipped, error: nil } if block.nil?
277
+ return { action: :skipped, error: nil } unless update_existing
278
+
279
+ update_existing_block(block, key, content, locale, published)
280
+ end
281
+
282
+ def import_new_block(key, content, locale, published)
283
+ block = RubyCms::ContentBlock.new(
284
+ key: key,
285
+ locale: locale.to_s,
286
+ content: content.to_s,
287
+ content_type: infer_content_type(content),
288
+ published: published
289
+ )
290
+
291
+ save_block(block, key, locale, action: :created, failure_verb: "create")
292
+ end
293
+
294
+ def update_existing_block(block, key, content, locale, published)
295
+ block.content = content.to_s
296
+ block.published = published if block.published != published
297
+ set_inferred_type_if_text(block, content)
298
+
299
+ save_block(block, key, locale, action: :updated, failure_verb: "update")
300
+ end
301
+
302
+ def set_inferred_type_if_text(block, content)
303
+ return unless block.content_type == "text"
304
+
305
+ block.content_type = infer_content_type(content)
306
+ end
307
+
308
+ def save_block(block, key, locale, action:, failure_verb:)
309
+ return { action: action, error: nil } if block.save
310
+
311
+ { action: :skipped, error: block_failure_message(block, key, locale, failure_verb) }
312
+ end
313
+
314
+ def block_failure_message(block, key, locale, failure_verb)
315
+ errors = block.errors.full_messages.join(", ")
316
+ "Failed to #{failure_verb} #{key} (#{locale}): #{errors}"
317
+ end
318
+
319
+ def infer_content_type(content)
320
+ # Simple heuristic: if content looks like HTML, use rich_text
321
+ content_str = content.to_s
322
+ if content_str.match?(/<[a-z][\s\S]*>/i)
323
+ "rich_text"
324
+ else
325
+ "text"
326
+ end
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RubyCms
6
+ # Compiles admin.css from component files (no Rails required).
7
+ # Used by Engine.compile_admin_css and rake tasks.
8
+ module CssCompiler
9
+ # Core shared styles MUST be loaded first, then specific components
10
+ COMPONENTS = %w[
11
+ shared
12
+ layout sidebar header cards dashboard buttons forms alerts
13
+ flash_toast modals content_blocks visitor_errors settings
14
+ bulk_action_table bulk_action_table_bar bulk_action_table_delete
15
+ visual_editor visual_editor_header visual_editor_preview visual_editor_modal
16
+ visual_editor_edit_mode analytics
17
+ mobile scrollbar utilities
18
+ ].freeze
19
+
20
+ def self.compile(gem_root, dest_path)
21
+ src_dir = Pathname(gem_root).join("app/assets/stylesheets/ruby_cms")
22
+ components_dir = src_dir.join("components")
23
+ header = <<~CSS
24
+
25
+ CSS
26
+ content = COMPONENTS.filter_map do |name|
27
+ file = components_dir.join("#{name}.css")
28
+ next nil unless file.exist?
29
+
30
+ "/* ===== Component: #{name} ===== */\n#{File.read(file)}\n"
31
+ end.compact.join("\n")
32
+ File.write(dest_path, header + content)
33
+ end
34
+ end
35
+ end