reactive_views 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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +49 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +134 -0
  5. data/app/controllers/reactive_views/bundles_controller.rb +47 -0
  6. data/app/frontend/reactive_views/boot.ts +215 -0
  7. data/config/routes.rb +10 -0
  8. data/lib/generators/reactive_views/install/install_generator.rb +645 -0
  9. data/lib/generators/reactive_views/install/templates/application.html.erb.tt +19 -0
  10. data/lib/generators/reactive_views/install/templates/application.js.tt +9 -0
  11. data/lib/generators/reactive_views/install/templates/boot.ts.tt +225 -0
  12. data/lib/generators/reactive_views/install/templates/example_component.tsx.tt +4 -0
  13. data/lib/generators/reactive_views/install/templates/initializer.rb.tt +7 -0
  14. data/lib/generators/reactive_views/install/templates/vite.config.mts.tt +78 -0
  15. data/lib/generators/reactive_views/install/templates/vite.json.tt +22 -0
  16. data/lib/reactive_views/cache_store.rb +269 -0
  17. data/lib/reactive_views/component_resolver.rb +243 -0
  18. data/lib/reactive_views/configuration.rb +71 -0
  19. data/lib/reactive_views/controller_props.rb +43 -0
  20. data/lib/reactive_views/css_strategy.rb +179 -0
  21. data/lib/reactive_views/engine.rb +14 -0
  22. data/lib/reactive_views/error_overlay.rb +1390 -0
  23. data/lib/reactive_views/full_page_renderer.rb +158 -0
  24. data/lib/reactive_views/helpers.rb +209 -0
  25. data/lib/reactive_views/props_builder.rb +42 -0
  26. data/lib/reactive_views/props_inference.rb +89 -0
  27. data/lib/reactive_views/railtie.rb +93 -0
  28. data/lib/reactive_views/renderer.rb +484 -0
  29. data/lib/reactive_views/resolver.rb +66 -0
  30. data/lib/reactive_views/ssr_process.rb +274 -0
  31. data/lib/reactive_views/tag_transformer.rb +523 -0
  32. data/lib/reactive_views/temp_file_manager.rb +81 -0
  33. data/lib/reactive_views/template_handler.rb +52 -0
  34. data/lib/reactive_views/version.rb +5 -0
  35. data/lib/reactive_views.rb +58 -0
  36. data/lib/tasks/reactive_views.rake +104 -0
  37. data/node/ssr/server.mjs +965 -0
  38. data/package-lock.json +516 -0
  39. data/package.json +14 -0
  40. metadata +322 -0
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "erb"
5
+
6
+ module ReactiveViews
7
+ # Renders full-page TSX.ERB templates via ERB→TSX→SSR pipeline
8
+ class FullPageRenderer
9
+ require "securerandom"
10
+
11
+ class << self
12
+ # Render a full-page TSX.ERB template
13
+ #
14
+ # @param controller [ActionController::Base] The controller instance
15
+ # @param template_full_path [String] Full path to .tsx.erb template
16
+ # @return [String] SSR HTML
17
+ def render(controller, template_full_path:)
18
+ # Step 1: Render ERB to produce TSX
19
+ tsx_content = render_erb(controller, template_full_path)
20
+
21
+ # Determine extension from path
22
+ extension = template_full_path.to_s.end_with?(".jsx.erb") ? "jsx" : "tsx"
23
+
24
+ render_content(controller, tsx_content, extension: extension, identifier: template_full_path)
25
+ end
26
+
27
+ # Render from raw content (used by TemplateHandler)
28
+ #
29
+ # @param controller [ActionController::Base] The controller instance
30
+ # @param content [String] The TSX/JSX content
31
+ # @param extension [String] 'tsx' or 'jsx'
32
+ # @return [String] SSR HTML
33
+ def render_content(controller, content, extension: "tsx", identifier: nil)
34
+ sanitized_content = sanitize_jsx_content(content)
35
+
36
+ temp_file = TempFileManager.write(
37
+ sanitized_content,
38
+ identifier: identifier || controller.controller_name,
39
+ extension: extension
40
+ )
41
+
42
+ props = PropsBuilder.build(controller.view_context, sanitized_content, extension: extension)
43
+
44
+ render_result = Renderer.render_path_with_metadata(temp_file.path, props)
45
+ html = render_result[:html]
46
+ bundle_key = render_result[:bundle_key]
47
+
48
+ # Check for error marker and show fullscreen overlay in development
49
+ if html.to_s.start_with?("___REACTIVE_VIEWS_ERROR___")
50
+ error_message = html.sub("___REACTIVE_VIEWS_ERROR___", "").sub(/___\z/, "")
51
+ component_name = identifier ? File.basename(identifier.to_s, ".*") : "FullPage"
52
+ return ErrorOverlay.generate_fullscreen(
53
+ component_name: component_name,
54
+ props: props,
55
+ errors: [ { message: error_message } ]
56
+ )
57
+ end
58
+
59
+ return html if bundle_key.nil?
60
+
61
+ wrap_with_hydration(html, props, bundle_key)
62
+ ensure
63
+ temp_file&.delete
64
+ end
65
+
66
+ private
67
+
68
+ def render_erb(controller, template_path)
69
+ # Create a view context with the controller's instance variables
70
+ view_context = controller.view_context
71
+
72
+ # CRITICAL: Set the lookup context to use :tsx format so partials resolve correctly
73
+ # This ensures that when ERB processes <%= render "users/filters" %>, it looks for _filters.tsx.erb
74
+ # Only modify lookup_context if it exists (may be a mock in tests)
75
+ if view_context.respond_to?(:lookup_context) && view_context.lookup_context
76
+ original_formats = view_context.lookup_context.formats.dup
77
+ original_handlers = view_context.lookup_context.handlers.dup
78
+
79
+ begin
80
+ # Set formats to include :tsx so partials are found
81
+ view_context.lookup_context.formats = %i[tsx html]
82
+ # Ensure :tsx handler is in the lookup
83
+ view_context.lookup_context.handlers = %i[tsx erb raw html builder ruby]
84
+
85
+ # Read the template source
86
+ template_source = File.read(template_path)
87
+
88
+ # Render ERB with [:tsx] format so partials resolve correctly
89
+ erb_handler = ActionView::Template.handler_for_extension("erb")
90
+
91
+ # Create a temporary template object
92
+ # Note: template_path must be a String, not Pathname
93
+ template = ActionView::Template.new(
94
+ template_source,
95
+ template_path.to_s,
96
+ erb_handler,
97
+ locals: [],
98
+ format: :tsx,
99
+ variant: nil,
100
+ virtual_path: nil
101
+ )
102
+
103
+ # Render the template in the view context
104
+ template.render(view_context, {})
105
+ ensure
106
+ # Restore original lookup context
107
+ view_context.lookup_context.formats = original_formats
108
+ view_context.lookup_context.handlers = original_handlers
109
+ end
110
+ else
111
+ # Fallback for mocks or when lookup_context is not available
112
+ # Read the template source
113
+ template_source = File.read(template_path)
114
+
115
+ # Render ERB with [:tsx] format so partials resolve correctly
116
+ erb_handler = ActionView::Template.handler_for_extension("erb")
117
+
118
+ # Create a temporary template object
119
+ template = ActionView::Template.new(
120
+ template_source,
121
+ template_path.to_s,
122
+ erb_handler,
123
+ locals: [],
124
+ format: :tsx,
125
+ variant: nil,
126
+ virtual_path: nil
127
+ )
128
+
129
+ # Render the template in the view context
130
+ template.render(view_context, {})
131
+ end
132
+ end
133
+
134
+ def sanitize_jsx_content(content)
135
+ return "" if content.nil?
136
+
137
+ source = content.respond_to?(:to_str) ? content.to_str : content.to_s
138
+ return source unless source.include?("<!--")
139
+
140
+ source.gsub(/<!--\s*(BEGIN|END)\s+app\/views.*?-->\s*/m, "")
141
+ end
142
+
143
+ def wrap_with_hydration(html, props, bundle_key)
144
+ uuid = SecureRandom.uuid
145
+ metadata = {
146
+ props: props || {},
147
+ bundle: bundle_key
148
+ }
149
+ metadata_json = JSON.generate(metadata).gsub("</", "<\\/")
150
+
151
+ container = %(<div data-reactive-page="true" data-page-uuid="#{uuid}">#{html}</div>)
152
+ props_script = %(<script type="application/json" data-page-uuid="#{uuid}">#{metadata_json}</script>)
153
+
154
+ "#{container}#{props_script}"
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveViewsHelper
4
+ # Single helper that includes everything needed for ReactiveViews to work.
5
+ # This abstracts away Vite implementation details for a better developer experience.
6
+ #
7
+ # Usage in your application layout:
8
+ # <%= reactive_views_script_tag %>
9
+ #
10
+ # This helper automatically includes:
11
+ # - Vite client (for HMR in development)
12
+ # - Vite JavaScript entrypoint (which imports the boot script)
13
+ # - SSR URL meta tag for client runtime
14
+ #
15
+ # In production, it serves precompiled assets from the Vite manifest.
16
+ def reactive_views_script_tag
17
+ output = []
18
+
19
+ # Advertise SSR URL for the client runtime
20
+ # In production, this should point to the SSR service
21
+ ssr_url = resolve_ssr_url
22
+ output << tag.meta(name: "reactive-views-ssr-url", content: ssr_url)
23
+
24
+ # Production mode: serve precompiled assets
25
+ if production_mode?
26
+ output << production_script_tags
27
+ else
28
+ # Development/test mode: use Vite dev server
29
+ output << development_script_tags
30
+ end
31
+
32
+ safe_join(output.flatten.compact, "\n")
33
+ end
34
+
35
+ # Returns the asset host URL for CDN deployments
36
+ # Can be configured via:
37
+ # - ReactiveViews.config.asset_host
38
+ # - ENV['ASSET_HOST']
39
+ # - Rails.application.config.asset_host
40
+ def reactive_views_asset_host
41
+ ReactiveViews.config.asset_host ||
42
+ ENV["ASSET_HOST"] ||
43
+ (defined?(Rails) && Rails.application.config.asset_host)
44
+ end
45
+
46
+ # @deprecated Use {#reactive_views_script_tag} instead.
47
+ # This helper is maintained for backward compatibility but will be removed in future versions.
48
+ def reactive_views_boot
49
+ warn "[DEPRECATION] `reactive_views_boot` is deprecated. Please use `reactive_views_script_tag` instead."
50
+ javascript_include_tag(
51
+ ReactiveViews.config.boot_module_path || "/vite/assets/reactive_views_boot.js",
52
+ defer: true,
53
+ crossorigin: "anonymous"
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def production_mode?
60
+ return false unless defined?(Rails)
61
+
62
+ Rails.env.production?
63
+ end
64
+
65
+ def resolve_ssr_url
66
+ # In production, use the Rails proxy endpoint for same-origin requests
67
+ # This allows the SSR server to run on localhost without being publicly exposed
68
+ if production_mode?
69
+ # Return the Rails-mounted proxy path (relative URL)
70
+ "/reactive_views"
71
+ else
72
+ # In development, allow direct access for debugging
73
+ # Allow configuration override, then environment variable, then default
74
+ ReactiveViews.config.ssr_url ||
75
+ ENV.fetch("REACTIVE_VIEWS_SSR_URL", "http://localhost:5175")
76
+ end
77
+ end
78
+
79
+ def production_script_tags
80
+ output = []
81
+
82
+ begin
83
+ # In production, vite_rails handles manifest resolution
84
+ # The vite_javascript_tag will automatically use the manifest
85
+ if respond_to?(:vite_javascript_tag)
86
+ output << vite_javascript_tag("application")
87
+ else
88
+ # Fallback: manually resolve from manifest
89
+ output << manual_production_script_tag
90
+ end
91
+ rescue StandardError => e
92
+ log_helper_error("production_script_tags", e)
93
+ # Return a helpful error comment in development-like environments
94
+ output << "<!-- ReactiveViews: Error loading production assets: #{e.message} -->".html_safe
95
+ end
96
+
97
+ output
98
+ end
99
+
100
+ def development_script_tags
101
+ output = []
102
+
103
+ # In Rails test env, vite_rails typically prefers manifest mode and will raise if
104
+ # the test manifest isn't built. Our own system specs run with a Vite dev server,
105
+ # so we emit direct dev-server module URLs instead of relying on vite_rails helpers.
106
+ if defined?(Rails) && Rails.env.test?
107
+ vite_port = ENV.fetch("RV_VITE_PORT", "5174")
108
+ vite_base = "vite-test"
109
+ # Use 127.0.0.1 to avoid IPv6/localhost resolution differences in headless browsers.
110
+ dev_host = "http://127.0.0.1:#{vite_port}"
111
+
112
+ output << tag.script(type: "module", src: "#{dev_host}/#{vite_base}/@vite/client")
113
+ # @vitejs/plugin-react expects the react-refresh preamble to be installed.
114
+ preamble = <<~JS
115
+ import RefreshRuntime from "#{dev_host}/#{vite_base}/@react-refresh";
116
+ RefreshRuntime.injectIntoGlobalHook(window);
117
+ window.$RefreshReg$ = () => {};
118
+ window.$RefreshSig$ = () => (type) => type;
119
+ window.__vite_plugin_react_preamble_installed__ = true;
120
+ JS
121
+ output << content_tag(:script, preamble.html_safe, type: "module")
122
+ output << tag.script(type: "module", src: "#{dev_host}/#{vite_base}/entrypoints/application.js")
123
+ return output
124
+ end
125
+
126
+ # Include Vite client tag for HMR in development
127
+ output << vite_client_tag if respond_to?(:vite_client_tag)
128
+
129
+ # Ensure React Refresh preamble is installed BEFORE any TSX runs (development only, NOT in test)
130
+ # NOTE: vite_rails proxies Vite at a base path (e.g., /vite-dev/), so we need the full path
131
+ begin
132
+ if defined?(Rails) && Rails.env.development?
133
+ vite_base = resolve_vite_base_path
134
+
135
+ preamble = <<~JS
136
+ import RefreshRuntime from "/#{vite_base}/@react-refresh";
137
+ RefreshRuntime.injectIntoGlobalHook(window);
138
+ window.$RefreshReg$ = () => {};
139
+ window.$RefreshSig$ = () => (type) => type;
140
+ window.__vite_plugin_react_preamble_installed__ = true;
141
+ JS
142
+ output << content_tag(:script, preamble.html_safe, type: "module")
143
+ end
144
+ rescue StandardError => e
145
+ log_helper_error("react_refresh_preamble", e)
146
+ end
147
+
148
+ # Include the Vite JavaScript entrypoint
149
+ # The boot script is imported in application.js and bundled by Vite
150
+ output << vite_javascript_tag("application") if respond_to?(:vite_javascript_tag)
151
+
152
+ output
153
+ end
154
+
155
+ def resolve_vite_base_path
156
+ ViteRuby.config.public_output_dir
157
+ rescue StandardError
158
+ "vite-dev"
159
+ end
160
+
161
+ def manual_production_script_tag
162
+ # Fallback for when vite_rails helpers are not available
163
+ # This reads the manifest directly and generates the appropriate script tag
164
+ manifest_path = Rails.root.join("public", "vite", ".vite", "manifest.json")
165
+
166
+ unless File.exist?(manifest_path)
167
+ # Try alternate manifest location
168
+ manifest_path = Rails.root.join("public", "vite", "manifest.json")
169
+ end
170
+
171
+ unless File.exist?(manifest_path)
172
+ raise ReactiveViews::AssetManifestNotFoundError,
173
+ "Vite manifest not found. Run 'bin/vite build' to precompile assets."
174
+ end
175
+
176
+ manifest = JSON.parse(File.read(manifest_path))
177
+ entry = manifest["app/javascript/entrypoints/application.js"] || manifest["application.js"]
178
+
179
+ unless entry
180
+ raise ReactiveViews::AssetEntryNotFoundError,
181
+ "Application entry not found in Vite manifest. Check your vite.config.ts entry points."
182
+ end
183
+
184
+ asset_path = build_asset_path(entry["file"])
185
+
186
+ # Include CSS if present
187
+ css_tags = (entry["css"] || []).map do |css_file|
188
+ tag.link(rel: "stylesheet", href: build_asset_path(css_file))
189
+ end
190
+
191
+ script_tag = tag.script(src: asset_path, type: "module", crossorigin: "anonymous")
192
+
193
+ safe_join([ css_tags, script_tag ].flatten, "\n")
194
+ end
195
+
196
+ def build_asset_path(file)
197
+ host = reactive_views_asset_host
198
+ path = "/vite/#{file}"
199
+
200
+ host ? "#{host.chomp('/')}#{path}" : path
201
+ end
202
+
203
+ def log_helper_error(method_name, error)
204
+ return unless defined?(Rails) && Rails.logger
205
+
206
+ Rails.logger.error("[ReactiveViews] Helper error: #{method_name} failed - #{error.message}")
207
+ Rails.logger.debug(error.backtrace.join("\n")) if Rails.logger.debug?
208
+ end
209
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module ReactiveViews
6
+ class PropsBuilder
7
+ class << self
8
+ def build(view_context, content, extension: "tsx")
9
+ controller = view_context.controller
10
+
11
+ # Collect instance variables
12
+ assigns = view_context.assigns.deep_symbolize_keys
13
+
14
+ # Merge with explicit reactive_view_props
15
+ explicit_props = if controller.respond_to?(:reactive_view_props, true)
16
+ controller.send(:reactive_view_props)
17
+ else
18
+ {}
19
+ end
20
+
21
+ all_props = assigns.merge(explicit_props)
22
+
23
+ return all_props unless ReactiveViews.config.props_inference_enabled
24
+
25
+ # Infer which props the component actually needs
26
+ inferred_keys = PropsInference.infer_props(content, extension: extension)
27
+
28
+ # If inference succeeded, filter to only inferred keys ∪ explicit keys
29
+ if inferred_keys.any?
30
+ inferred_set = inferred_keys.map(&:to_sym).to_set
31
+ explicit_set = explicit_props.keys.to_set
32
+ allowed_keys = inferred_set | explicit_set
33
+
34
+ all_props.select { |key, _| allowed_keys.include?(key) }
35
+ else
36
+ # On inference failure, pass all props
37
+ all_props
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "net/http"
5
+ require "json"
6
+ require "uri"
7
+
8
+ module ReactiveViews
9
+ # Props inference client to extract prop keys from TSX component signatures
10
+ class PropsInference
11
+ class InferenceError < StandardError; end
12
+
13
+ class << self
14
+ def cache
15
+ inference_cache
16
+ end
17
+
18
+ # Infer prop keys from TSX component signature
19
+ #
20
+ # @param tsx_content [String] The TSX/JSX source code
21
+ # @param extension [String] The source extension ('tsx' or 'jsx')
22
+ # @return [Array<String>] Array of prop keys, or empty array on failure
23
+ def infer_props(tsx_content, extension: "tsx")
24
+ return [] unless ReactiveViews.config.props_inference_enabled
25
+
26
+ # Generate cache key from content digest
27
+ content_digest = Digest::SHA256.hexdigest(tsx_content)
28
+ ttl = ReactiveViews.config.props_inference_cache_ttl_seconds
29
+ cache_store = inference_cache
30
+
31
+ if ttl && (cached = cache_store.read(content_digest))
32
+ return cached
33
+ end
34
+
35
+ # Make inference request
36
+ keys = make_inference_request(tsx_content, content_digest, extension)
37
+
38
+ # Cache the result
39
+ cache_store.write(content_digest, keys, ttl: ttl) if ttl
40
+
41
+ keys
42
+ rescue StandardError => e
43
+ log_error("Props inference failed: #{e.message}")
44
+ [] # Return empty array on failure
45
+ end
46
+
47
+ private
48
+
49
+ def inference_cache
50
+ ReactiveViews.config.cache_for(:props_inference)
51
+ end
52
+
53
+ def make_inference_request(tsx_content, content_digest, extension)
54
+ # Ensure SSR process is running (auto-spawns in production if not manually configured)
55
+ SsrProcess.ensure_running
56
+
57
+ uri = URI.parse("#{ReactiveViews.config.ssr_url}/infer-props")
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.read_timeout = ReactiveViews.config.ssr_timeout
60
+
61
+ request = Net::HTTP::Post.new(uri.path, "Content-Type" => "application/json")
62
+ request.body = JSON.generate({
63
+ tsxContent: tsx_content,
64
+ contentHash: content_digest,
65
+ extension: (extension || "tsx").to_s
66
+ })
67
+
68
+ response = http.request(request)
69
+
70
+ raise InferenceError, "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
71
+
72
+ result = JSON.parse(response.body)
73
+ result["keys"] || []
74
+ rescue JSON::ParserError => e
75
+ raise InferenceError, "Invalid JSON response: #{e.message}"
76
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
77
+ raise InferenceError, "Cannot connect to SSR server: #{e.message}"
78
+ rescue Net::ReadTimeout
79
+ raise InferenceError, "Inference request timed out"
80
+ end
81
+
82
+ def log_error(message)
83
+ return unless defined?(Rails) && Rails.logger
84
+
85
+ Rails.logger.error("[ReactiveViews::PropsInference] #{message}")
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load vite_rails to ensure ViteRails constant is available
4
+ begin
5
+ require "vite_rails"
6
+ rescue LoadError
7
+ # If vite_rails is not installed, the helper will show an appropriate error
8
+ end
9
+
10
+ module ReactiveViews
11
+ class Railtie < Rails::Railtie
12
+ # Load rake tasks
13
+ rake_tasks do
14
+ load File.expand_path("../tasks/reactive_views.rake", __dir__)
15
+ end
16
+
17
+ initializer "reactive_views.helpers" do
18
+ ActiveSupport.on_load(:action_view) do
19
+ include ReactiveViewsHelper
20
+ end
21
+ end
22
+
23
+ initializer "reactive_views.controller_props" do
24
+ ActiveSupport.on_load(:action_controller) do
25
+ require_relative "controller_props"
26
+ include ReactiveViews::ControllerProps
27
+
28
+ helper_method :reactive_view_props if respond_to?(:helper_method)
29
+ end
30
+ end
31
+
32
+ # Register :tsx and :jsx MIME types and handler
33
+ initializer "reactive_views.register_template_handlers", before: :load_config_initializers do
34
+ # Register :tsx and :jsx as aliases for HTML so lookups and formats behave
35
+ begin
36
+ Mime::Type.register_alias "text/html", :tsx unless Mime::Type.lookup_by_extension(:tsx)
37
+ Mime::Type.register_alias "text/html", :jsx unless Mime::Type.lookup_by_extension(:jsx)
38
+ rescue StandardError
39
+ # no-op if Mime::Type is unavailable
40
+ end
41
+
42
+ ActiveSupport.on_load(:action_view) do
43
+ require_relative "template_handler"
44
+ ActionView::Template.register_template_handler :tsx, ReactiveViews::TemplateHandler
45
+ ActionView::Template.register_template_handler :jsx, ReactiveViews::TemplateHandler
46
+ end
47
+ end
48
+
49
+ # Register custom resolver to support HTML -> TSX -> JSX lookup
50
+ initializer "reactive_views.setup_resolver", after: :load_config_initializers do
51
+ Rails.logger&.info("[ReactiveViews] Initializing resolver...")
52
+ ActiveSupport.on_load(:action_controller) do
53
+ require_relative "resolver"
54
+
55
+ # Add the resolver for app/views
56
+ views_path = Rails.root.join("app", "views")
57
+ Rails.logger&.info("[ReactiveViews] Views path: #{views_path}")
58
+ if Dir.exist?(views_path)
59
+ Rails.logger&.info("[ReactiveViews] Prepending resolver for #{views_path}")
60
+ prepend_view_path ReactiveViews::Resolver.new(views_path.to_s)
61
+ else
62
+ Rails.logger&.warn("[ReactiveViews] Views path does not exist!")
63
+ end
64
+ end
65
+ end
66
+
67
+ # Hook into ActionView to transform component tags in rendered HTML
68
+ initializer "reactive_views.transform_components", after: :load_config_initializers do
69
+ ActiveSupport.on_load(:action_controller) do
70
+ ActionController::Base.class_eval do
71
+ around_action :transform_reactive_views_components
72
+
73
+ private
74
+
75
+ def transform_reactive_views_components
76
+ yield # Let controller exceptions bubble up naturally to rescue_from handlers
77
+
78
+ # Only transform HTML responses
79
+ if response.content_type&.include?("text/html") && response.body.present?
80
+ begin
81
+ response.body = ReactiveViews::TagTransformer.transform(response.body)
82
+ rescue StandardError => e
83
+ # Log transformation errors but don't break the response
84
+ Rails.logger&.error("[ReactiveViews] Transformation error: #{e.message}")
85
+ Rails.logger&.error(e.backtrace.join("\n")) if e.backtrace
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end