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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE.txt +22 -0
- data/README.md +134 -0
- data/app/controllers/reactive_views/bundles_controller.rb +47 -0
- data/app/frontend/reactive_views/boot.ts +215 -0
- data/config/routes.rb +10 -0
- data/lib/generators/reactive_views/install/install_generator.rb +645 -0
- data/lib/generators/reactive_views/install/templates/application.html.erb.tt +19 -0
- data/lib/generators/reactive_views/install/templates/application.js.tt +9 -0
- data/lib/generators/reactive_views/install/templates/boot.ts.tt +225 -0
- data/lib/generators/reactive_views/install/templates/example_component.tsx.tt +4 -0
- data/lib/generators/reactive_views/install/templates/initializer.rb.tt +7 -0
- data/lib/generators/reactive_views/install/templates/vite.config.mts.tt +78 -0
- data/lib/generators/reactive_views/install/templates/vite.json.tt +22 -0
- data/lib/reactive_views/cache_store.rb +269 -0
- data/lib/reactive_views/component_resolver.rb +243 -0
- data/lib/reactive_views/configuration.rb +71 -0
- data/lib/reactive_views/controller_props.rb +43 -0
- data/lib/reactive_views/css_strategy.rb +179 -0
- data/lib/reactive_views/engine.rb +14 -0
- data/lib/reactive_views/error_overlay.rb +1390 -0
- data/lib/reactive_views/full_page_renderer.rb +158 -0
- data/lib/reactive_views/helpers.rb +209 -0
- data/lib/reactive_views/props_builder.rb +42 -0
- data/lib/reactive_views/props_inference.rb +89 -0
- data/lib/reactive_views/railtie.rb +93 -0
- data/lib/reactive_views/renderer.rb +484 -0
- data/lib/reactive_views/resolver.rb +66 -0
- data/lib/reactive_views/ssr_process.rb +274 -0
- data/lib/reactive_views/tag_transformer.rb +523 -0
- data/lib/reactive_views/temp_file_manager.rb +81 -0
- data/lib/reactive_views/template_handler.rb +52 -0
- data/lib/reactive_views/version.rb +5 -0
- data/lib/reactive_views.rb +58 -0
- data/lib/tasks/reactive_views.rake +104 -0
- data/node/ssr/server.mjs +965 -0
- data/package-lock.json +516 -0
- data/package.json +14 -0
- 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
|