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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module BulkActionTableHelper
5
+ # Render the bulk action table delete dialog
6
+ # @param controller_name [String] The Stimulus controller name
7
+ # @return [String] Rendered HTML for the delete dialog
8
+ def render_bulk_action_table_delete_dialog(controller_name: "ruby-cms--bulk-action-table")
9
+ render partial: "ruby_cms/admin/shared/bulk_action_table_delete_dialog",
10
+ locals: { controller_name: }
11
+ end
12
+
13
+ # Render bulk actions bar
14
+ # @param controller_name [String]
15
+ # @param item_name [String]
16
+ # @param bulk_actions_url [String]
17
+ # @param bulk_action_buttons [Array<Hash>]
18
+ def render_bulk_actions_bar(
19
+ controller_name: "ruby-cms--bulk-action-table",
20
+ item_name: "item",
21
+ bulk_actions_url: nil,
22
+ bulk_action_buttons: []
23
+ )
24
+ content_tag :div,
25
+ data: { "#{controller_name}_target": "bulkBar" },
26
+ class: "flex-shrink-0 hidden border border-gray-200
27
+ bg-white px-4 py-2 shadow-md" do
28
+ content_tag :div, class: "flex items-center justify-between max-w-full" do
29
+ render_bulk_selection_info(controller_name:, item_name:) +
30
+ render_bulk_action_buttons(controller_name:, bulk_action_buttons:, bulk_actions_url:)
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def render_bulk_selection_info(controller_name:, item_name:)
38
+ content_tag :div, class: "flex items-center space-x-3" do
39
+ selected_count_span(controller_name:, item_name:) +
40
+ select_all_button(controller_name:) +
41
+ clear_selection_button
42
+ end
43
+ end
44
+
45
+ def selected_count_span(controller_name:, item_name:)
46
+ content_tag(:span,
47
+ "0 #{item_name}(s) selected:",
48
+ data: { "#{controller_name}_target": "selectedCount" },
49
+ class: "text-sm font-medium text-gray-700")
50
+ end
51
+
52
+ def select_all_button(controller_name:)
53
+ content_tag(:button,
54
+ "Select all",
55
+ type: "button",
56
+ data: {
57
+ "#{controller_name}_target": "selectAllButton",
58
+ action: "click->#{controller_name}#selectAll"
59
+ },
60
+ class: "text-sm
61
+ text-gray-600
62
+ hover:text-gray-900
63
+ hover:underline
64
+ font-medium
65
+ transition-colors
66
+ duration-150")
67
+ end
68
+
69
+ def clear_selection_button
70
+ content_tag(:button,
71
+ "Clear selection",
72
+ type: "button",
73
+ data: { action: "click->ruby-cms--bulk-action-table#clearSelection" },
74
+ class: "text-sm
75
+ text-gray-600
76
+ hover:text-gray-900
77
+ hover:underline
78
+ font-medium
79
+ transition-colors
80
+ duration-150")
81
+ end
82
+
83
+ def render_bulk_action_buttons(controller_name:, bulk_action_buttons:, bulk_actions_url:)
84
+ content_tag(:div, class: "flex items-center space-x-2") do
85
+ safe_join(
86
+ bulk_action_buttons.map {|cfg| render_bulk_action_button(cfg, controller_name) } +
87
+ [render_bulk_delete_button(controller_name: controller_name, url: bulk_actions_url)]
88
+ )
89
+ end
90
+ end
91
+
92
+ def render_bulk_action_button(button_config, controller_name)
93
+ label = button_label(button_config)
94
+ data_attrs = build_button_data_attrs(button_config, controller_name, label)
95
+ button_class = build_button_class(button_config)
96
+
97
+ content_tag(:button, label, type: "button", data: data_attrs, class: button_class)
98
+ end
99
+
100
+ def button_label(cfg)
101
+ cfg[:label] || cfg[:text] || cfg[:name]&.humanize || "Button"
102
+ end
103
+
104
+ def build_button_data_attrs(cfg, controller_name, label)
105
+ attrs = {
106
+ action: "click->#{controller_name}#showActionDialog",
107
+ action_name: cfg[:name] || cfg[:action_name],
108
+ action_url: cfg[:url]&.to_s
109
+ }
110
+ if %w[redirect].include?(cfg[:action_type]) || cfg[:action] == "redirect"
111
+ attrs[:action_type] =
112
+ "redirect"
113
+ end
114
+ if cfg[:confirm].present?
115
+ attrs[:action_confirm] = cfg[:confirm]
116
+ attrs[:action_label] = label
117
+ end
118
+ attrs
119
+ end
120
+
121
+ def build_button_class(cfg)
122
+ base = "inline-flex
123
+ items-center justify-center rounded-md text-sm
124
+ font-medium ring-offset-background transition-colors
125
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
126
+ focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
127
+ border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
128
+ cfg[:class].present? ? "#{base} #{cfg[:class]}" : base
129
+ end
130
+
131
+ def render_bulk_delete_button(controller_name:, url:)
132
+ content_tag(:button,
133
+ "Delete Selected",
134
+ type: "button",
135
+ data: {
136
+ action: "click->#{controller_name}#showActionDialog",
137
+ action_name: "delete",
138
+ action_label: "Delete Selected",
139
+ action_confirm: "Are you sure you want to delete the selected items?
140
+ This action cannot be undone.",
141
+ action_url: url&.to_s
142
+ },
143
+ class: "inline-flex items-center justify-center rounded-md text-sm
144
+ font-medium ring-offset-background transition-colors focus-visible:outline-none
145
+ focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
146
+ disabled:pointer-events-none disabled:opacity-50 border border-input
147
+ bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2
148
+ text-red-600 hover:text-red-700 border-red-300 hover:border-red-400")
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module ContentBlocksHelper
5
+ # Renders a content block by key. Rendering uses the block's content_type from the DB
6
+ # (text, rich_text, image, link, list).
7
+ # Usage:
8
+ # content_block("hero_title")
9
+ # content_block("hero_title", "Welcome") # key + default when block missing
10
+ # content_block("hero_title", default: "Welcome") # same via keyword
11
+ # Wraps in a span with data-content-key, data-block-id, and .content-block for editor hooks.
12
+ def content_block(key, default_or_nil=nil, locale: nil, fallback: nil, default: nil,
13
+ translation_namespace: nil, **options)
14
+ # Support content_block("key", "Default") and content_block("key", default: "Default")
15
+ default = default_or_nil.kind_of?(Hash) ? default : (default_or_nil || default)
16
+ cache_opts = options.delete(:cache)
17
+ wrap = options.delete(:wrap)
18
+ wrap = true if wrap.nil?
19
+
20
+ cache_key = cache_key_for_content_block(key, cache_opts)
21
+
22
+ if cache_opts && cache_key
23
+ Rails.cache.fetch(cache_key) do
24
+ render_content_block(key, locale, default, fallback, translation_namespace, options,
25
+ wrap:)
26
+ end
27
+ else
28
+ render_content_block(key, locale, default, fallback, translation_namespace, options, wrap:)
29
+ end
30
+ end
31
+
32
+ alias cms_content_block content_block
33
+
34
+ # Returns plain text (no HTML wrapper)
35
+ def content_block_text(key, locale: nil, default: nil, fallback: nil,
36
+ translation_namespace: nil)
37
+ locale = normalize_locale(locale)
38
+ block = find_content_block(key, locale)
39
+
40
+ content = content_for_block_text(
41
+ block, default, fallback, key, translation_namespace, locale
42
+ )
43
+
44
+ strip_html_tags(content)
45
+ end
46
+
47
+ alias cms_content_block_text content_block_text
48
+
49
+ # Returns array of strings from list-type content block
50
+ def content_block_list_items(key, locale: nil, fallback: nil)
51
+ locale = normalize_locale(locale)
52
+ block = find_content_block(key, locale)
53
+ return Array(fallback) if block.blank?
54
+
55
+ raw = block.content.to_s
56
+ items = parse_list_items(raw)
57
+ items.presence || Array(fallback)
58
+ end
59
+
60
+ alias cms_content_block_list_items content_block_list_items
61
+
62
+ private
63
+
64
+ def normalize_locale(locale)
65
+ (locale || I18n.locale.to_s).to_s
66
+ end
67
+
68
+ def find_content_block(key, locale)
69
+ hash = content_blocks_from_context
70
+ return find_block_in_hash(hash, key, locale) if hash
71
+
72
+ ::ContentBlock.published.find_by_key_and_locale(key, locale:)
73
+ end
74
+
75
+ def content_blocks_from_context
76
+ instance_variable_get(:@content_blocks)
77
+ end
78
+
79
+ def find_block_in_hash(hash, key, locale)
80
+ hash.values.find {|b| b.key == key.to_s && b.locale == locale } ||
81
+ hash.values.find {|b| b.key == key.to_s }
82
+ end
83
+
84
+ def render_content_block(key, locale, default, fallback, translation_namespace, options,
85
+ wrap: true)
86
+ locale = normalize_locale(locale)
87
+ block = find_content_block(key, locale)
88
+
89
+ if wrap
90
+ render_wrapped_content_block(block, key, locale, default, fallback, translation_namespace, options)
91
+ else
92
+ render_unwrapped_content_block(block, key, locale, default, fallback, translation_namespace)
93
+ end
94
+ end
95
+
96
+ def render_wrapped_content_block(block, key, locale, default, fallback, translation_namespace, options)
97
+ content = content_for_block(block, default, fallback, key, translation_namespace, locale)
98
+ wrapper_options = options.dup
99
+ wrapper_options[:tag] = :div if block&.content_type.to_s == "rich_text"
100
+ render_block_wrapper(content, key, wrapper_options)
101
+ end
102
+
103
+ def render_unwrapped_content_block(block, key, locale, default, fallback, translation_namespace)
104
+ content = content_for_block_text(block, default, fallback, key, translation_namespace, locale)
105
+ strip_html_tags(content)
106
+ end
107
+
108
+ def render_block_wrapper(content, key, options)
109
+ tag_name = options.delete(:tag) || :span
110
+ tag.public_send(tag_name, content, class: build_css_class(options),
111
+ data: build_data_attributes(key, options))
112
+ end
113
+
114
+ def build_css_class(options)
115
+ ["ruby_cms-content-block", "content-block", options.delete(:class)].compact.join(" ")
116
+ end
117
+
118
+ def build_data_attributes(key, options)
119
+ { content_key: key, block_id: key.to_s }.merge(options.delete(:data).to_h)
120
+ end
121
+
122
+ def content_for_block(block, default, fallback, key, translation_namespace, locale)
123
+ if block.present? && !block_effectively_blank?(block)
124
+ render_content_by_type(block)
125
+ else
126
+ resolve_fallback(default, fallback, key,
127
+ translation_namespace, locale)
128
+ end
129
+ end
130
+
131
+ def content_for_block_text(block, default, fallback, key, translation_namespace, locale)
132
+ if block.present? && !block_effectively_blank?(block)
133
+ render_text_content_by_type(block)
134
+ else
135
+ resolve_fallback(default, fallback,
136
+ key, translation_namespace, locale)
137
+ end
138
+ end
139
+
140
+ # Uses block.content_type from the DB (text, rich_text, image, link, list).
141
+ def render_content_by_type(block)
142
+ case block.content_type
143
+ when "rich_text" then render_rich_text_content(block)
144
+ when "image" then content_block_image(block)
145
+ when "link" then content_block_link(block)
146
+ when "list" then content_block_list(block)
147
+ else block.content.to_s
148
+ end
149
+ end
150
+
151
+ def render_text_content_by_type(block)
152
+ case block.content_type
153
+ when "rich_text" then render_rich_text_as_text(block)
154
+ when "image", "link" then block_title_or_content(block)
155
+ when "list" then render_list_as_text(block)
156
+ else block.content.to_s
157
+ end
158
+ end
159
+
160
+ # Body-only HTML so content stays inside wrapper (no layout div.trix-content / comments).
161
+ def render_rich_text_content(block)
162
+ return block.content.to_s unless action_text_available?(block)
163
+ return block.content.to_s if block.content.present? && !rich_content_body_present?(block)
164
+
165
+ html = rich_content_body_html_for_view(block)
166
+ sanitize_rich_text_html(html.presence || block.content.to_s)
167
+ end
168
+
169
+ def rich_content_body_html_for_view(block)
170
+ return "" unless block.rich_content.respond_to?(:body) && block.rich_content.body.present?
171
+
172
+ b = block.rich_content.body
173
+ out = b.respond_to?(:to_html) ? b.to_html : b.to_s
174
+ out.to_s.strip.presence || ""
175
+ end
176
+
177
+ def render_rich_text_as_text(block)
178
+ return block.content.to_s unless action_text_available?(block)
179
+
180
+ if rich_content_body_present?(block)
181
+ block.rich_content.to_plain_text
182
+ elsif block.content.present?
183
+ block.content.to_s
184
+ else
185
+ safe_rich_text_to_plain_text(block)
186
+ end
187
+ end
188
+
189
+ def action_text_available?(block)
190
+ block.class.respond_to?(:action_text_available?) &&
191
+ block.class.action_text_available? &&
192
+ block.respond_to?(:rich_content)
193
+ end
194
+
195
+ def rich_content_body_present?(block)
196
+ block.rich_content.respond_to?(:body) && block.rich_content.body.present?
197
+ end
198
+
199
+ def safe_rich_text_to_plain_text(block)
200
+ block.rich_content.to_plain_text
201
+ rescue StandardError
202
+ block.content.to_s
203
+ end
204
+
205
+ def render_list_as_text(block)
206
+ parse_list_items(block.content.to_s).join(", ")
207
+ end
208
+
209
+ def content_block_list(block)
210
+ items = parse_list_items(block.content.to_s)
211
+ return block.content.to_s if items.blank?
212
+
213
+ tag.ul(safe_join(items.map {|i| tag.li(i) }))
214
+ end
215
+
216
+ def parse_list_items(raw)
217
+ parsed = JSON.parse(raw)
218
+ parsed.kind_of?(Array) ? parsed.map(&:to_s) : split_list_lines(raw)
219
+ rescue JSON::ParserError, TypeError
220
+ split_list_lines(raw)
221
+ end
222
+
223
+ def split_list_lines(raw)
224
+ raw.split("\n").map(&:strip).compact_blank
225
+ end
226
+
227
+ def content_block_image(block)
228
+ return block.content.to_s unless block.respond_to?(:image) && block.image.attached?
229
+
230
+ image_tag(block.image, alt: block.title.presence || block.key)
231
+ end
232
+
233
+ def content_block_link(block)
234
+ url = block.content.to_s.strip
235
+ return block.content.to_s if url.blank? || url.start_with?("javascript:", "data:")
236
+
237
+ link_to(block.title.presence || url, url)
238
+ end
239
+
240
+ def block_title_or_content(block)
241
+ block.title.presence || block.content.to_s
242
+ end
243
+
244
+ # Treat "blank" blocks like missing blocks so we can fall back to defaults/translations.
245
+ # This avoids confusing situations where an empty/unpublished DB record overrides a
246
+ # perfectly good I18n fallback (common during initial setup or partial edits).
247
+ def block_effectively_blank?(block)
248
+ case block.content_type.to_s
249
+ when "image" then image_block_blank?(block)
250
+ when "rich_text" then rich_text_block_blank?(block)
251
+ else other_block_blank?(block)
252
+ end
253
+ rescue StandardError
254
+ false
255
+ end
256
+
257
+ def image_block_blank?(block)
258
+ return block.content.to_s.blank? unless block.respond_to?(:image)
259
+
260
+ !block.image.attached?
261
+ end
262
+
263
+ def rich_text_block_blank?(block)
264
+ return block.content.to_s.strip.blank? unless action_text_available?(block) && rich_content_body_present?(block)
265
+
266
+ rich_content_body_html_for_view(block).to_s.strip.blank?
267
+ end
268
+
269
+ def other_block_blank?(block)
270
+ block.content.to_s.strip.blank? && block.title.to_s.strip.blank?
271
+ end
272
+
273
+ def resolve_fallback(default, fallback, key, translation_namespace, locale)
274
+ return fallback.to_s if fallback.present?
275
+ return default.to_s if default.present?
276
+
277
+ translation = find_translation_fallback(key, translation_namespace, locale)
278
+ translation || key.to_s.humanize
279
+ end
280
+
281
+ def find_translation_fallback(key, translation_namespace, locale)
282
+ return unless respond_to?(:t)
283
+
284
+ I18n.with_locale(locale) do
285
+ namespace = translation_namespace || translation_namespace_from_config
286
+ try_namespaced_translation(namespace, key) || try_root_translation(key)
287
+ end
288
+ end
289
+
290
+ def translation_namespace_from_config
291
+ Rails.application.config.ruby_cms.content_blocks_translation_namespace.presence ||
292
+ "content_blocks"
293
+ rescue StandardError
294
+ "content_blocks"
295
+ end
296
+
297
+ def try_namespaced_translation(namespace, key)
298
+ return if namespace.blank?
299
+
300
+ namespaced_key = "#{namespace}.#{key}"
301
+ translation = t(namespaced_key, default: nil)
302
+ return nil if translation.blank? || translation == namespaced_key
303
+
304
+ translation.to_s
305
+ rescue I18n::MissingTranslationData
306
+ nil
307
+ end
308
+
309
+ def try_root_translation(key)
310
+ translation = t(key, default: nil)
311
+ return nil if translation.blank? || translation == key.to_s
312
+
313
+ translation.to_s
314
+ rescue I18n::MissingTranslationData
315
+ nil
316
+ end
317
+
318
+ def strip_html_tags(content)
319
+ if respond_to?(:strip_tags)
320
+ strip_tags(content.to_s)
321
+ else
322
+ content.to_s.gsub(/<[^>]*>/, "").strip
323
+ end
324
+ end
325
+
326
+ # Allow nested content blocks inside rich text to stay selectable in the visual editor
327
+ # by preserving the data attributes the preview click handler relies on.
328
+ def sanitize_rich_text_html(html)
329
+ str = html.to_s
330
+ return "" if str.blank?
331
+
332
+ base_tags = rails_sanitizer_allowed(:tags)
333
+ base_attrs = rails_sanitizer_allowed(:attributes)
334
+
335
+ extra_attrs = %w[
336
+ data-content-key
337
+ data-block-id
338
+ data-content-target
339
+ ]
340
+
341
+ sanitize_with_extra_attrs(str, base_tags, base_attrs, extra_attrs) || sanitize(str)
342
+ end
343
+
344
+ def sanitize_with_extra_attrs(str, base_tags, base_attrs, extra_attrs)
345
+ return nil unless base_tags && base_attrs
346
+
347
+ sanitize(
348
+ str,
349
+ tags: base_tags,
350
+ attributes: (base_attrs + extra_attrs).uniq
351
+ )
352
+ end
353
+
354
+ def rails_sanitizer_allowed(kind)
355
+ return nil unless defined?(Rails::Html::SafeListSanitizer)
356
+
357
+ sanitizer = Rails::Html::SafeListSanitizer
358
+ if kind == :tags && sanitizer.respond_to?(:allowed_tags)
359
+ sanitizer.allowed_tags.to_a
360
+ elsif kind == :attributes && sanitizer.respond_to?(:allowed_attributes)
361
+ sanitizer.allowed_attributes.to_a
362
+ end
363
+ rescue StandardError
364
+ nil
365
+ end
366
+
367
+ def cache_key_for_content_block(key, cache_opts)
368
+ return nil unless cache_opts
369
+
370
+ block = ::ContentBlock.published.find_by(key: key.to_s)
371
+ part = block ? block.cache_key : "nil"
372
+ ["ruby_cms", "content_block", key.to_s, part].join("/")
373
+ end
374
+ end
375
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module SettingsHelper
5
+ TAB_CONFIG = {
6
+ "general" => { icon: "🏠", fallback_label: "General" },
7
+ "navigation" => { icon: "🧭", fallback_label: "Navigation" },
8
+ "pagination" => { icon: "📄", fallback_label: "Pagination" },
9
+ "analytics" => { icon: "📈", fallback_label: "Analytics" },
10
+ "dashboard" => { icon: "🗂️", fallback_label: "Dashboard" },
11
+ "content" => { icon: "🧱", fallback_label: "Content" }
12
+ }.freeze
13
+
14
+ def settings_tab_config(category)
15
+ TAB_CONFIG[category.to_s] || { icon: "⚙️", fallback_label: category.to_s.humanize }
16
+ end
17
+
18
+ def settings_tab_label(category)
19
+ cfg = settings_tab_config(category)
20
+ t("ruby_cms.admin.settings.categories.#{category}.label", default: cfg[:fallback_label])
21
+ end
22
+
23
+ def settings_tab_description(category)
24
+ t("ruby_cms.admin.settings.categories.#{category}.description", default: "")
25
+ end
26
+
27
+ def setting_label(entry)
28
+ key = entry.key.to_s
29
+
30
+ # Keep familiar labels for nav and pagination keys.
31
+ key = key.delete_prefix("nav_show_")
32
+ key = key.delete_suffix("_per_page")
33
+
34
+ key.tr("_", " ").humanize
35
+ end
36
+
37
+ def render_setting_field(entry:, value:, tab:)
38
+ case entry.type.to_sym
39
+ when :integer
40
+ render_integer_setting_field(entry:, value:, tab:)
41
+ when :boolean
42
+ render_boolean_setting_field(entry:, value:, tab:)
43
+ when :json
44
+ render_json_setting_field(entry:, value:, tab:)
45
+ else
46
+ render_string_setting_field(entry:, value:, tab:)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def input_base_classes
53
+ "w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 " \
54
+ "shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200"
55
+ end
56
+
57
+ def textarea_base_classes
58
+ "w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 " \
59
+ "shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-200"
60
+ end
61
+
62
+ def render_integer_setting_field(entry:, value:, tab:)
63
+ min, max = integer_bounds_for(entry)
64
+
65
+ number_field_tag(
66
+ "preferences[#{entry.key}]",
67
+ value,
68
+ id: setting_input_id(entry),
69
+ class: input_base_classes,
70
+ min: min,
71
+ max: max,
72
+ data: autosave_data(entry.key, tab)
73
+ )
74
+ end
75
+
76
+ def render_boolean_setting_field(entry:, value:, tab:)
77
+ checked = ActiveModel::Type::Boolean.new.cast(value)
78
+ hidden = hidden_field_tag("preferences[#{entry.key}]", "false")
79
+ checkbox = check_box_tag(
80
+ "preferences[#{entry.key}]",
81
+ "true",
82
+ checked,
83
+ id: setting_input_id(entry),
84
+ class: "peer sr-only",
85
+ data: autosave_data(entry.key, tab)
86
+ )
87
+ label = content_tag(
88
+ :label,
89
+ "",
90
+ for: setting_input_id(entry),
91
+ class: "relative inline-flex h-6 w-11 cursor-pointer items-center rounded-full " \
92
+ "bg-gray-200 transition-colors peer-checked:bg-teal-600"
93
+ ) do
94
+ content_tag(
95
+ :span,
96
+ "",
97
+ class: "inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition " \
98
+ "translate-x-0.5 peer-checked:translate-x-5"
99
+ )
100
+ end
101
+
102
+ content_tag(:div, class: "inline-flex items-center") { hidden + checkbox + label }
103
+ end
104
+
105
+ def render_json_setting_field(entry:, value:, tab:)
106
+ formatted = if value.kind_of?(Hash) || value.kind_of?(Array)
107
+ JSON.pretty_generate(value)
108
+ else
109
+ value.to_s
110
+ end
111
+
112
+ text_area_tag(
113
+ "preferences[#{entry.key}]",
114
+ formatted,
115
+ id: setting_input_id(entry),
116
+ class: textarea_base_classes,
117
+ rows: 4,
118
+ data: autosave_data(entry.key, tab)
119
+ )
120
+ end
121
+
122
+ def render_string_setting_field(entry:, value:, tab:)
123
+ text_field_tag(
124
+ "preferences[#{entry.key}]",
125
+ value,
126
+ id: setting_input_id(entry),
127
+ class: input_base_classes,
128
+ data: autosave_data(entry.key, tab)
129
+ )
130
+ end
131
+
132
+ def autosave_data(key, tab)
133
+ {
134
+ controller: "ruby-cms--auto-save-preference",
135
+ action: "change->ruby-cms--auto-save-preference#save",
136
+ ruby_cms__auto_save_preference_preference_key_value: key.to_s,
137
+ ruby_cms__auto_save_preference_settings_url_value: ruby_cms_admin_settings_path,
138
+ ruby_cms__auto_save_preference_tab_value: tab.to_s
139
+ }
140
+ end
141
+
142
+ def setting_input_id(entry)
143
+ "pref_#{entry.key}"
144
+ end
145
+
146
+ def integer_bounds_for(entry)
147
+ key = entry.key.to_s
148
+
149
+ if key.end_with?("_per_page")
150
+ min = RubyCms::Settings.get(:pagination_min_per_page, default: 5).to_i
151
+ max = RubyCms::Settings.get(:pagination_max_per_page, default: 200).to_i
152
+ return [min, [max, min].max]
153
+ end
154
+
155
+ [nil, nil]
156
+ rescue StandardError
157
+ [nil, nil]
158
+ end
159
+ end
160
+ end