railspress-engine 0.1.2 → 1.2.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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/README.md +195 -25
  4. data/app/assets/javascripts/railspress/admin.js +39 -0
  5. data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
  6. data/app/assets/stylesheets/application.css +0 -0
  7. data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
  8. data/app/assets/stylesheets/railspress/admin/base.css +25 -0
  9. data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
  10. data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
  11. data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
  12. data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
  13. data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
  14. data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
  15. data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
  16. data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
  17. data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
  18. data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
  19. data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
  20. data/app/assets/stylesheets/railspress/admin/page.css +111 -0
  21. data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
  22. data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
  23. data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
  24. data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
  25. data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
  26. data/app/assets/stylesheets/railspress/application.css +44 -13
  27. data/app/controllers/railspress/admin/base_controller.rb +6 -3
  28. data/app/controllers/railspress/admin/categories_controller.rb +1 -1
  29. data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
  30. data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
  31. data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
  32. data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
  33. data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
  34. data/app/controllers/railspress/admin/entities_controller.rb +157 -0
  35. data/app/controllers/railspress/admin/exports_controller.rb +55 -0
  36. data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
  37. data/app/controllers/railspress/admin/imports_controller.rb +63 -0
  38. data/app/controllers/railspress/admin/posts_controller.rb +58 -4
  39. data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
  40. data/app/controllers/railspress/admin/tags_controller.rb +1 -1
  41. data/app/controllers/railspress/application_controller.rb +1 -0
  42. data/app/helpers/railspress/admin_helper.rb +733 -0
  43. data/app/helpers/railspress/application_helper.rb +23 -0
  44. data/app/helpers/railspress/cms_helper.rb +319 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
  46. data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
  47. data/app/javascript/railspress/controllers/crop_controller.js +224 -0
  48. data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
  49. data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
  50. data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
  51. data/app/javascript/railspress/controllers/index.js +37 -0
  52. data/app/javascript/railspress/index.js +62 -0
  53. data/app/jobs/railspress/export_posts_job.rb +16 -0
  54. data/app/jobs/railspress/import_posts_job.rb +44 -0
  55. data/app/models/concerns/railspress/has_focal_point.rb +242 -0
  56. data/app/models/concerns/railspress/soft_deletable.rb +23 -0
  57. data/app/models/concerns/railspress/taggable.rb +23 -0
  58. data/app/models/railspress/content_element.rb +103 -0
  59. data/app/models/railspress/content_element_version.rb +32 -0
  60. data/app/models/railspress/content_group.rb +39 -0
  61. data/app/models/railspress/export.rb +67 -0
  62. data/app/models/railspress/focal_point.rb +70 -0
  63. data/app/models/railspress/import.rb +65 -0
  64. data/app/models/railspress/post.rb +102 -15
  65. data/app/models/railspress/post_export_processor.rb +162 -0
  66. data/app/models/railspress/post_import_processor.rb +382 -0
  67. data/app/models/railspress/tag.rb +10 -3
  68. data/app/models/railspress/tagging.rb +11 -0
  69. data/app/services/railspress/content_export_service.rb +122 -0
  70. data/app/services/railspress/content_import_service.rb +228 -0
  71. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  72. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  73. data/app/views/layouts/railspress/admin.html.erb +3 -1
  74. data/app/views/railspress/admin/categories/index.html.erb +11 -15
  75. data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
  76. data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
  77. data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
  78. data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
  79. data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
  80. data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
  81. data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
  82. data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
  83. data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
  84. data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
  85. data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
  86. data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
  87. data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
  88. data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
  89. data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
  90. data/app/views/railspress/admin/entities/_form.html.erb +53 -0
  91. data/app/views/railspress/admin/entities/edit.html.erb +4 -0
  92. data/app/views/railspress/admin/entities/index.html.erb +74 -0
  93. data/app/views/railspress/admin/entities/new.html.erb +4 -0
  94. data/app/views/railspress/admin/entities/show.html.erb +117 -0
  95. data/app/views/railspress/admin/exports/show.html.erb +62 -0
  96. data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
  97. data/app/views/railspress/admin/imports/show.html.erb +137 -0
  98. data/app/views/railspress/admin/posts/_form.html.erb +102 -28
  99. data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
  100. data/app/views/railspress/admin/posts/index.html.erb +47 -36
  101. data/app/views/railspress/admin/posts/show.html.erb +55 -19
  102. data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
  103. data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
  104. data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
  105. data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
  106. data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
  107. data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
  108. data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
  109. data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
  110. data/app/views/railspress/admin/tags/index.html.erb +12 -16
  111. data/config/brakeman.ignore +18 -0
  112. data/config/importmap.rb +23 -0
  113. data/config/routes.rb +62 -1
  114. data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
  115. data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
  116. data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
  117. data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
  118. data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
  119. data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
  120. data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
  121. data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
  122. data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
  123. data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
  124. data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
  125. data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
  126. data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
  127. data/lib/generators/railspress/entity/entity_generator.rb +89 -0
  128. data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
  129. data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
  130. data/lib/generators/railspress/install/install_generator.rb +51 -40
  131. data/lib/generators/railspress/install/templates/initializer.rb +29 -0
  132. data/lib/railspress/engine.rb +38 -0
  133. data/lib/railspress/entity.rb +239 -0
  134. data/lib/railspress/version.rb +1 -1
  135. data/lib/railspress.rb +198 -8
  136. data/lib/tasks/railspress_tasks.rake +49 -4
  137. metadata +215 -21
  138. data/MIT-LICENSE +0 -20
  139. data/app/assets/stylesheets/railspress/admin.css +0 -1207
  140. data/app/models/railspress/post_tag.rb +0 -8
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ # Stores field configuration for a single entity
5
+ #
6
+ # Stores class name as string for Rails reloader compatibility.
7
+ # Class is resolved lazily via constantize on each access, ensuring
8
+ # fresh class reference after code reload in development.
9
+ class EntityConfig
10
+ attr_writer :label
11
+
12
+ def initialize(model_class_name)
13
+ @model_class_name = model_class_name.to_s
14
+ @field_definitions = {}
15
+ @label = nil # Resolved lazily from model_class if not set
16
+ @types_resolved = false
17
+ end
18
+
19
+ # Lazily resolve class - gets fresh class after Rails reload
20
+ def model_class
21
+ @model_class_name.constantize
22
+ end
23
+
24
+ def add_field(name, options = {})
25
+ # Store the explicit type or mark for lazy detection
26
+ explicit_type = options[:as]
27
+ @field_definitions[name.to_sym] = {
28
+ type: explicit_type,
29
+ options: options.except(:as),
30
+ needs_detection: explicit_type.nil?
31
+ }
32
+
33
+ # Auto-wire virtual attributes for array types
34
+ if explicit_type.in?([ :list, :lines ])
35
+ define_array_accessors(name, explicit_type)
36
+ end
37
+ end
38
+
39
+ # Access fields with resolved types (lazy resolution)
40
+ def fields
41
+ resolve_field_types! unless @types_resolved
42
+ @field_definitions
43
+ end
44
+
45
+ def route_key
46
+ model_class.model_name.plural
47
+ end
48
+
49
+ def param_key
50
+ model_class.model_name.param_key
51
+ end
52
+
53
+ def singular_label
54
+ (label || model_class.model_name.human.pluralize).singularize
55
+ end
56
+
57
+ def label
58
+ @label || model_class.model_name.human.pluralize
59
+ end
60
+
61
+ private
62
+
63
+ # Auto-generate virtual attributes for :list and :lines fields
64
+ # This eliminates the need for a separate ArrayFields concern
65
+ def define_array_accessors(name, type)
66
+ separator = type == :list ? ", " : "\n"
67
+ delimiter = type == :list ? "," : /\r?\n/
68
+ dedupe = type == :list
69
+
70
+ model_class.class_eval do
71
+ # Nil guard - always return array
72
+ define_method(name) do
73
+ super() || []
74
+ end
75
+
76
+ # Virtual getter: array → string
77
+ define_method("#{name}_list") do
78
+ send(name).join(separator)
79
+ end
80
+
81
+ # Virtual setter: string → array
82
+ define_method("#{name}_list=") do |value|
83
+ parsed = value.to_s.split(delimiter).map(&:strip).reject(&:blank?)
84
+ parsed = parsed.uniq if dedupe
85
+ send("#{name}=", parsed)
86
+ end
87
+ end
88
+ end
89
+
90
+ def resolve_field_types!
91
+ @field_definitions.each do |name, field|
92
+ if field[:needs_detection]
93
+ field[:type] = detect_type(name)
94
+ field.delete(:needs_detection)
95
+ end
96
+ end
97
+ @types_resolved = true
98
+ end
99
+
100
+ def detect_type(name)
101
+ name_str = name.to_s
102
+
103
+ # Check for ActionText rich text (has_one :rich_text_#{name} association)
104
+ if model_class.reflect_on_association(:"rich_text_#{name_str}")
105
+ return :rich_text
106
+ end
107
+
108
+ # Check for ActiveStorage attachments
109
+ if model_class.respond_to?(:reflect_on_all_attachments)
110
+ attachment = model_class.reflect_on_all_attachments.find do |a|
111
+ a.name.to_s == name_str
112
+ end
113
+ if attachment
114
+ # Check if this attachment has focal point support
115
+ if attachment.macro == :has_one_attached &&
116
+ model_class.respond_to?(:focal_point_attachments) &&
117
+ model_class.focal_point_attachments.include?(name)
118
+ return :focal_point_image
119
+ end
120
+ return attachment.macro == :has_many_attached ? :attachments : :attachment
121
+ end
122
+ end
123
+
124
+ # Check column type from schema
125
+ column = model_class.columns_hash[name_str]
126
+ return :string unless column
127
+
128
+ case column.type
129
+ when :text then :text
130
+ when :integer then :integer
131
+ when :boolean then :boolean
132
+ when :datetime then :datetime
133
+ when :date then :date
134
+ when :decimal, :float then :decimal
135
+ else :string
136
+ end
137
+ end
138
+ end
139
+
140
+ # Concern to include in host models for CMS management
141
+ module Entity
142
+ extend ActiveSupport::Concern
143
+
144
+ included do
145
+ class_attribute :_railspress_config, instance_writer: false
146
+ # Store class name (string) for Rails reloader compatibility
147
+ self._railspress_config = EntityConfig.new(self.name)
148
+
149
+ # Default scopes available to all entities
150
+ scope :ordered, -> { order(created_at: :desc) }
151
+ scope :recent, -> { ordered.limit(10) }
152
+ end
153
+
154
+ # Default pagination - can be overridden in model
155
+ PER_PAGE = 20
156
+
157
+ class_methods do
158
+ # Simple pagination for index views
159
+ def page(page_number)
160
+ page_number = [ page_number.to_i, 1 ].max
161
+ offset((page_number - 1) * per_page_count).limit(per_page_count)
162
+ end
163
+
164
+ # Allow models to override PER_PAGE
165
+ def per_page_count
166
+ const_defined?(:PER_PAGE, false) ? const_get(:PER_PAGE) : Railspress::Entity::PER_PAGE
167
+ end
168
+
169
+ # Declare which fields should appear in the CMS
170
+ #
171
+ # @example Simple field list (types auto-detected from schema)
172
+ # railspress_fields :title, :description, :published
173
+ #
174
+ # @example With explicit type override
175
+ # railspress_fields :body, as: :rich_text
176
+ #
177
+ # @example Hash syntax for multiple typed fields
178
+ # railspress_fields title: :string, body: :rich_text
179
+ #
180
+ def railspress_fields(*names, **options)
181
+ # Handle positional args: railspress_fields :title, :description
182
+ if options[:as] && names.length == 1
183
+ # Single field with type: railspress_fields :body, as: :rich_text
184
+ _railspress_config.add_field(names.first, as: options[:as])
185
+ elsif names.any?
186
+ # Multiple fields, auto-detect types
187
+ names.each do |name|
188
+ _railspress_config.add_field(name)
189
+ end
190
+ end
191
+
192
+ # Handle hash syntax: railspress_fields title: :string, body: :rich_text
193
+ options.except(:as).each do |name, type|
194
+ _railspress_config.add_field(name, as: type)
195
+ end
196
+ end
197
+
198
+ # Set custom label for sidebar/headers
199
+ #
200
+ # @example
201
+ # railspress_label "Client Projects"
202
+ #
203
+ def railspress_label(label)
204
+ _railspress_config.label = label
205
+ end
206
+
207
+ # Access the entity configuration
208
+ def railspress_config
209
+ _railspress_config
210
+ end
211
+
212
+ # Columns to display in the admin index table
213
+ #
214
+ # Override hierarchy:
215
+ # 1. Model overrides this method directly (full control)
216
+ # 2. Model defines RAILSPRESS_INDEX_COLUMNS constant
217
+ # 3. Auto-detect from Railspress.default_index_columns
218
+ #
219
+ # @example Override with constant
220
+ # class Project < ApplicationRecord
221
+ # include Railspress::Entity
222
+ # RAILSPRESS_INDEX_COLUMNS = [:name, :client, :status, :created_at]
223
+ # end
224
+ #
225
+ # @example Override with method (for dynamic logic)
226
+ # def self.railspress_index_columns
227
+ # [:name, :client, admin? ? :budget : nil, :created_at].compact
228
+ # end
229
+ #
230
+ def railspress_index_columns
231
+ return self::RAILSPRESS_INDEX_COLUMNS if const_defined?(:RAILSPRESS_INDEX_COLUMNS, false)
232
+
233
+ Railspress.default_index_columns.select do |col|
234
+ new.respond_to?(col)
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
@@ -1,3 +1,3 @@
1
1
  module Railspress
2
- VERSION = "0.1.2"
2
+ VERSION = "1.2.0"
3
3
  end
data/lib/railspress.rb CHANGED
@@ -1,25 +1,41 @@
1
1
  require "railspress/version"
2
2
  require "railspress/engine"
3
+ require "railspress/entity"
3
4
  require "lexxy"
4
5
 
5
6
  module Railspress
7
+ class ConfigurationError < StandardError; end
8
+
6
9
  class Configuration
7
10
  attr_accessor :author_class_name,
8
11
  :current_author_method,
9
12
  :current_author_proc,
10
13
  :author_scope,
11
- :author_display_method
14
+ :author_display_method,
15
+ :words_per_minute,
16
+ :blog_path,
17
+ :default_index_columns,
18
+ :post_image_variants,
19
+ :inline_editing_check
12
20
 
13
- attr_reader :authors_enabled, :header_images_enabled
21
+ attr_reader :authors_enabled, :post_images_enabled, :focal_points_enabled, :cms_enabled, :image_contexts
14
22
 
15
23
  def initialize
16
24
  @authors_enabled = false
17
- @header_images_enabled = false
25
+ @post_images_enabled = false
26
+ @focal_points_enabled = false
27
+ @cms_enabled = false
28
+ @image_contexts = default_image_contexts
29
+ @post_image_variants = {}
30
+ @inline_editing_check = nil
18
31
  @author_class_name = "User"
19
32
  @current_author_method = :current_user
20
33
  @current_author_proc = nil
21
34
  @author_scope = nil
22
35
  @author_display_method = :name
36
+ @words_per_minute = 200
37
+ @blog_path = "/blog"
38
+ @default_index_columns = [ :id, :title, :name, :created_at ]
23
39
  end
24
40
 
25
41
  # Declarative setter: config.enable_authors
@@ -27,9 +43,137 @@ module Railspress
27
43
  @authors_enabled = true
28
44
  end
29
45
 
30
- # Declarative setter: config.enable_header_images
31
- def enable_header_images
32
- @header_images_enabled = true
46
+ # Declarative setter: config.enable_post_images
47
+ def enable_post_images
48
+ @post_images_enabled = true
49
+ end
50
+
51
+ # Declarative setter: config.enable_focal_points
52
+ def enable_focal_points
53
+ @focal_points_enabled = true
54
+ end
55
+
56
+ # Declarative setter: config.enable_cms
57
+ def enable_cms
58
+ @cms_enabled = true
59
+ end
60
+
61
+ # Validate configuration after the configure block completes.
62
+ # This keeps the configure block order-independent.
63
+ def validate!
64
+ if @inline_editing_check && !@cms_enabled
65
+ raise Railspress::ConfigurationError,
66
+ "Inline editing requires CMS. Add `config.enable_cms` to your initializer."
67
+ end
68
+ end
69
+
70
+ # Set custom image contexts
71
+ def image_contexts=(contexts)
72
+ @image_contexts = contexts.transform_keys(&:to_sym)
73
+ end
74
+
75
+ # Add a single image context
76
+ def add_image_context(name, aspect:, label: nil, sizes: [])
77
+ @image_contexts[name.to_sym] = {
78
+ aspect: aspect,
79
+ label: label || name.to_s.humanize,
80
+ sizes: sizes
81
+ }
82
+ end
83
+
84
+ # Remove an image context
85
+ def remove_image_context(name)
86
+ @image_contexts.delete(name.to_sym)
87
+ end
88
+
89
+ # Register a host model as a CMS-managed entity
90
+ #
91
+ # Accepts class, string, or symbol. String/symbol registration is preferred
92
+ # for Rails reloader compatibility in development.
93
+ #
94
+ # Registration is deferred until first access - this allows registration
95
+ # in initializers before models are loaded by Zeitwerk.
96
+ #
97
+ # @param identifier [Class, String, Symbol] The model to register
98
+ # @param options [Hash] Optional configuration
99
+ # @option options [String] :label Custom sidebar/header label
100
+ #
101
+ # @example String registration (preferred)
102
+ # config.register_entity "Project"
103
+ # config.register_entity "Portfolio", label: "Work Samples"
104
+ #
105
+ # @example Symbol registration
106
+ # config.register_entity :project
107
+ # config.register_entity :admin_portfolio, label: "Work Samples"
108
+ #
109
+ # @example Class registration (works but may have stale refs after reload)
110
+ # config.register_entity Project
111
+ #
112
+ def register_entity(identifier, options = {})
113
+ # Normalize to class name string
114
+ class_name = case identifier
115
+ when String then identifier
116
+ when Symbol then identifier.to_s.camelize
117
+ when Class then identifier.name
118
+ else
119
+ raise ArgumentError, "Expected String, Symbol, or Class, got #{identifier.class}"
120
+ end
121
+
122
+ # Compute route_key from class name (e.g., "Project" -> "projects")
123
+ route_key = class_name.underscore.pluralize
124
+
125
+ # Store just the class name and options - resolved fresh on each access
126
+ entity_registrations[route_key] = { class_name: class_name, options: options }
127
+ end
128
+
129
+ # Entity registrations: route_key => { class_name:, options: }
130
+ # We store class names (strings), not config objects, so Rails reloading works
131
+ def entity_registrations
132
+ @entity_registrations ||= {}
133
+ end
134
+
135
+ # Get all registered entities - resolves fresh on each call
136
+ def registered_entities
137
+ result = {}
138
+ entity_registrations.each do |route_key, registration|
139
+ config = resolve_entity(registration[:class_name], registration[:options])
140
+ result[route_key] = config if config
141
+ end
142
+ result
143
+ end
144
+
145
+ # Find entity config by route key - resolves fresh on each call
146
+ def entity_for(route_key)
147
+ registration = entity_registrations[route_key.to_s]
148
+ return nil unless registration
149
+
150
+ resolve_entity(registration[:class_name], registration[:options])
151
+ end
152
+
153
+ # Check if an entity is registered
154
+ def entity_registered?(route_key)
155
+ entity_registrations.key?(route_key.to_s)
156
+ end
157
+
158
+ private
159
+
160
+ def default_image_contexts
161
+ {
162
+ hero: { aspect: [ 16, 9 ], label: "Hero", sizes: [ 1920, 1280 ] },
163
+ card: { aspect: [ 4, 3 ], label: "Card", sizes: [ 800, 400 ] },
164
+ thumb: { aspect: [ 1, 1 ], label: "Thumbnail", sizes: [ 200 ] }
165
+ }
166
+ end
167
+
168
+ def resolve_entity(class_name, options)
169
+ klass = class_name.constantize
170
+ unless klass.included_modules.include?(Railspress::Entity)
171
+ raise ArgumentError, "#{class_name} must include Railspress::Entity"
172
+ end
173
+
174
+ config = klass.railspress_config
175
+ config.label = options[:label] if options[:label]
176
+ config
33
177
  end
34
178
  end
35
179
 
@@ -40,6 +184,7 @@ module Railspress
40
184
 
41
185
  def configure
42
186
  yield(configuration)
187
+ configuration.validate!
43
188
  end
44
189
 
45
190
  def reset_configuration!
@@ -51,8 +196,20 @@ module Railspress
51
196
  configuration.authors_enabled
52
197
  end
53
198
 
54
- def header_images_enabled?
55
- configuration.header_images_enabled
199
+ def post_images_enabled?
200
+ configuration.post_images_enabled
201
+ end
202
+
203
+ def focal_points_enabled?
204
+ configuration.focal_points_enabled
205
+ end
206
+
207
+ def cms_enabled?
208
+ configuration.cms_enabled
209
+ end
210
+
211
+ def image_contexts
212
+ configuration.image_contexts
56
213
  end
57
214
 
58
215
  def author_class
@@ -81,5 +238,38 @@ module Railspress
81
238
  def current_author_proc
82
239
  configuration.current_author_proc
83
240
  end
241
+
242
+ def words_per_minute
243
+ configuration.words_per_minute
244
+ end
245
+
246
+ def blog_path
247
+ configuration.blog_path
248
+ end
249
+
250
+ def default_index_columns
251
+ configuration.default_index_columns
252
+ end
253
+
254
+ def post_image_variants
255
+ configuration.post_image_variants
256
+ end
257
+
258
+ def inline_editing_check
259
+ configuration.inline_editing_check
260
+ end
261
+
262
+ # Entity registry convenience accessors
263
+ def registered_entities
264
+ configuration.registered_entities
265
+ end
266
+
267
+ def entity_for(route_key)
268
+ configuration.entity_for(route_key)
269
+ end
270
+
271
+ def entity_registered?(route_key)
272
+ configuration.entity_registered?(route_key)
273
+ end
84
274
  end
85
275
  end
@@ -1,4 +1,49 @@
1
- # desc "Explaining what the task does"
2
- # task :railspress do
3
- # # Task goes here
4
- # end
1
+ namespace :railspress do
2
+ desc "Migrate post_tags to polymorphic taggings table (one-time migration)"
3
+ task migrate_tags: :environment do
4
+ puts "Starting tag migration from post_tags to taggings..."
5
+
6
+ # Check if PostTag table exists
7
+ unless ActiveRecord::Base.connection.table_exists?(:railspress_post_tags)
8
+ puts "No post_tags table found. Nothing to migrate."
9
+ next
10
+ end
11
+
12
+ old_count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM railspress_post_tags").to_i
13
+
14
+ if old_count == 0
15
+ puts "No post_tags to migrate. Done!"
16
+ next
17
+ end
18
+
19
+ puts "Found #{old_count} post_tags to migrate"
20
+
21
+ ActiveRecord::Base.transaction do
22
+ # Use raw SQL to avoid dependency on PostTag model
23
+ results = ActiveRecord::Base.connection.select_all(
24
+ "SELECT post_id, tag_id FROM railspress_post_tags"
25
+ )
26
+
27
+ results.each do |row|
28
+ Railspress::Tagging.find_or_create_by!(
29
+ tag_id: row["tag_id"],
30
+ taggable_type: "Railspress::Post",
31
+ taggable_id: row["post_id"]
32
+ )
33
+ end
34
+
35
+ new_count = Railspress::Tagging.where(taggable_type: "Railspress::Post").count
36
+
37
+ if new_count != old_count
38
+ raise "Count mismatch! Expected #{old_count}, got #{new_count}. Rolling back."
39
+ end
40
+
41
+ puts "Successfully migrated #{new_count} taggings"
42
+ end
43
+
44
+ puts "\nMigration complete!"
45
+ puts "You can now safely drop the post_tags table."
46
+ puts "Run the drop migration or execute:"
47
+ puts " rails generate migration DropRailspressPostTags"
48
+ end
49
+ end