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,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module ReactiveViews
6
+ class ComponentResolver
7
+ EXTENSIONS = %w[.tsx .jsx .ts .js].freeze
8
+ FILE_EVENT = "reactive_views.file_changed"
9
+ COMPONENT_EVENT = "reactive_views.component_changed"
10
+
11
+ class << self
12
+ def resolve(component_name, paths = nil)
13
+ setup_notifications!
14
+
15
+ # If caller already passed a concrete file path, accept it.
16
+ # Some production/performance specs render by absolute component path.
17
+ if component_name.is_a?(String) && component_name.include?("/")
18
+ expanded = File.expand_path(component_name)
19
+ return expanded if File.file?(expanded)
20
+ end
21
+
22
+ search_paths = normalize_paths(paths)
23
+ cache_key = build_cache_key(component_name, search_paths)
24
+
25
+ if (cached = cached_path(cache_key))
26
+ return cached
27
+ end
28
+
29
+ name_variants = generate_name_variants(component_name)
30
+
31
+ search_paths.each do |base_path|
32
+ next unless Dir.exist?(base_path)
33
+
34
+ name_variants.each do |variant|
35
+ EXTENSIONS.each do |ext|
36
+ direct_path = File.join(base_path, "#{variant}#{ext}")
37
+ if (matched = match_file(direct_path))
38
+ store_cache(cache_key, matched, component_name)
39
+ log_resolution_success(component_name, matched, variant)
40
+ return matched
41
+ end
42
+
43
+ index_path = File.join(base_path, variant, "index#{ext}")
44
+ if (matched = match_file(index_path))
45
+ store_cache(cache_key, matched, component_name)
46
+ log_resolution_success(component_name, matched, variant)
47
+ return matched
48
+ end
49
+
50
+ matches = Dir.glob(File.join(base_path, "**", "#{variant}#{ext}"), File::FNM_CASEFOLD)
51
+ if matches.any?
52
+ first_match = matches.first
53
+ store_cache(cache_key, first_match, component_name)
54
+ log_resolution_success(component_name, first_match, variant)
55
+ return first_match
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ log_resolution_failure(component_name)
62
+ nil
63
+ end
64
+
65
+ def invalidate(component_name: nil, path: nil)
66
+ cache_mutex.synchronize do
67
+ invalidate_by_path(path) if path
68
+ invalidate_by_component(component_name) if component_name
69
+ end
70
+ end
71
+
72
+ def clear_cache
73
+ cache_mutex.synchronize do
74
+ cache_store.clear
75
+ path_index.clear
76
+ end
77
+ end
78
+
79
+ # Generate all naming convention variants for a component name
80
+ # Supports: PascalCase, snake_case, camelCase, kebab-case
81
+ def generate_name_variants(name)
82
+ variants = []
83
+
84
+ variants << name
85
+ variants << to_snake_case(name)
86
+ variants << to_camel_case(name)
87
+ variants << to_kebab_case(name)
88
+
89
+ variants.uniq
90
+ end
91
+
92
+ # Convert PascalCase to snake_case
93
+ def to_snake_case(name)
94
+ name
95
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
96
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
97
+ .tr("-", "_")
98
+ .downcase
99
+ end
100
+
101
+ # Convert PascalCase to camelCase
102
+ def to_camel_case(name)
103
+ return name if name.empty?
104
+
105
+ name[0].downcase + name[1..]
106
+ end
107
+
108
+ # Convert PascalCase to kebab-case
109
+ def to_kebab_case(name)
110
+ name
111
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
112
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
113
+ .tr("_", "-")
114
+ .downcase
115
+ end
116
+
117
+ def log_resolution_success(component_name, path, variant)
118
+ return unless defined?(Rails) && Rails.logger
119
+
120
+ variant_info = variant != component_name ? " (as '#{variant}')" : ""
121
+ Rails.logger.debug("[ReactiveViews] Resolved #{component_name}#{variant_info} to #{path}")
122
+ end
123
+
124
+ def log_resolution_failure(component_name)
125
+ return unless defined?(Rails) && Rails.logger
126
+
127
+ Rails.logger.error("[ReactiveViews] Component '#{component_name}' not found in search paths.")
128
+ end
129
+
130
+ def match_file(path)
131
+ Dir.glob(path, File::FNM_CASEFOLD).find { |matched| File.file?(matched) }
132
+ end
133
+
134
+ private
135
+
136
+ def setup_notifications!
137
+ return unless defined?(ActiveSupport::Notifications)
138
+ return if @notifications_subscribed
139
+
140
+ ActiveSupport::Notifications.subscribe(FILE_EVENT) do |_name, _start, _finish, _id, payload|
141
+ invalidate(path: payload[:path]) if payload.is_a?(Hash) && payload[:path]
142
+ end
143
+
144
+ ActiveSupport::Notifications.subscribe(COMPONENT_EVENT) do |_name, _start, _finish, _id, payload|
145
+ invalidate(component_name: payload[:component]) if payload.is_a?(Hash) && payload[:component]
146
+ end
147
+
148
+ @notifications_subscribed = true
149
+ end
150
+
151
+ def normalize_paths(paths)
152
+ paths ||= ReactiveViews.config.component_views_paths + ReactiveViews.config.component_js_paths
153
+
154
+ paths.map do |path|
155
+ if path.is_a?(Pathname) || path.start_with?("/")
156
+ path.to_s
157
+ elsif defined?(Rails)
158
+ Rails.root.join(path).to_s
159
+ else
160
+ File.expand_path(path)
161
+ end
162
+ end
163
+ end
164
+
165
+ def cached_path(cache_key)
166
+ cache_mutex.synchronize do
167
+ entry = cache_store[cache_key]
168
+ return unless entry
169
+
170
+ path = entry[:path]
171
+ return unless File.exist?(path)
172
+
173
+ current_mtime = File.mtime(path)
174
+ if entry[:mtime] == current_mtime
175
+ path
176
+ else
177
+ remove_entry(cache_key, path)
178
+ nil
179
+ end
180
+ end
181
+ end
182
+
183
+ def store_cache(cache_key, path, component_name)
184
+ normalized_path = File.expand_path(path)
185
+ cache_mutex.synchronize do
186
+ cache_store[cache_key] = {
187
+ path: normalized_path,
188
+ mtime: File.mtime(normalized_path),
189
+ component_name: component_name
190
+ }
191
+
192
+ path_index[normalized_path] ||= Set.new
193
+ path_index[normalized_path] << cache_key
194
+ end
195
+ end
196
+
197
+ def remove_entry(cache_key, path)
198
+ cache_store.delete(cache_key)
199
+ normalized_path = File.expand_path(path)
200
+ if path_index[normalized_path]
201
+ path_index[normalized_path].delete(cache_key)
202
+ path_index.delete(normalized_path) if path_index[normalized_path].empty?
203
+ end
204
+ end
205
+
206
+ def invalidate_by_path(path)
207
+ normalized_path = File.expand_path(path)
208
+ keys = path_index.delete(normalized_path)
209
+ return unless keys
210
+
211
+ keys.each { |key| cache_store.delete(key) }
212
+ end
213
+
214
+ def invalidate_by_component(component_name)
215
+ return unless component_name
216
+
217
+ keys_to_remove = cache_store.each_with_object([]) do |(key, entry), list|
218
+ list << [ key, entry[:path] ] if entry && entry[:component_name] == component_name
219
+ end
220
+
221
+ keys_to_remove.each do |key, path|
222
+ remove_entry(key, path)
223
+ end
224
+ end
225
+
226
+ def build_cache_key(component_name, search_paths)
227
+ "#{component_name}::#{search_paths.join("||")}"
228
+ end
229
+
230
+ def cache_store
231
+ @cache_store ||= {}
232
+ end
233
+
234
+ def path_index
235
+ @path_index ||= {}
236
+ end
237
+
238
+ def cache_mutex
239
+ @cache_mutex ||= Mutex.new
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveViews
4
+ class Configuration
5
+ attr_accessor :enabled,
6
+ :ssr_url,
7
+ :component_views_paths,
8
+ :component_js_paths,
9
+ :ssr_cache_ttl_seconds,
10
+ :boot_module_path,
11
+ :ssr_timeout,
12
+ :batch_rendering_enabled,
13
+ :batch_timeout,
14
+ :tree_rendering_enabled,
15
+ :max_nesting_depth_warning,
16
+ :props_inference_enabled,
17
+ :props_inference_cache_ttl_seconds,
18
+ :full_page_enabled,
19
+ :cache_namespace,
20
+ # Asset host for CDN deployments
21
+ :asset_host
22
+
23
+ attr_reader :cache_store
24
+
25
+ # Alias for easier testing
26
+ alias component_paths component_views_paths
27
+ alias component_paths= component_views_paths=
28
+
29
+ def initialize
30
+ @enabled = true
31
+ # RV_SSR_URL is the primary env var, REACTIVE_VIEWS_SSR_URL is kept for backwards compatibility
32
+ @ssr_url = ENV.fetch("RV_SSR_URL") { ENV.fetch("REACTIVE_VIEWS_SSR_URL", "http://localhost:5175") }
33
+ @component_views_paths = [ "app/views/components" ]
34
+ @component_js_paths = [ "app/javascript/components" ]
35
+ @ssr_cache_ttl_seconds = nil
36
+ @boot_module_path = nil
37
+ @ssr_timeout = ENV.fetch("RV_SSR_TIMEOUT") { ENV.fetch("REACTIVE_VIEWS_SSR_TIMEOUT", 5) }.to_i
38
+ @batch_rendering_enabled = true
39
+ @batch_timeout = 10
40
+ @tree_rendering_enabled = true
41
+ @max_nesting_depth_warning = 3
42
+ @props_inference_enabled = true
43
+ @props_inference_cache_ttl_seconds = 300
44
+ @full_page_enabled = true
45
+ @cache_namespace = "reactive_views"
46
+ self.cache_store = :memory
47
+
48
+ # Asset host for CDN deployments
49
+ @asset_host = ENV["ASSET_HOST"]
50
+ end
51
+
52
+ def cache_store=(store)
53
+ @cache_store = CacheStore.build(store)
54
+ end
55
+
56
+ def cache_for(scope)
57
+ scope_name = scope.to_s
58
+ cache_store.namespaced("#{cache_namespace}:#{scope_name}")
59
+ end
60
+
61
+ # Returns true if SSR is available and enabled
62
+ def ssr_enabled?
63
+ enabled && ssr_url.present?
64
+ end
65
+
66
+ # Returns true if the application is in production mode
67
+ def production?
68
+ defined?(Rails) && Rails.env.production?
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveViews
4
+ # Controller mixin for explicit props to pass to full-page TSX rendering
5
+ module ControllerProps
6
+ # Deep-merges props to be passed to full-page TSX rendering alongside instance variables
7
+ # Can be called multiple times; values are deep-merged
8
+ #
9
+ # @param hash [Hash] Props to merge
10
+ # @return [Hash] Current merged props
11
+ #
12
+ # @example
13
+ # class UsersController < ApplicationController
14
+ # before_action -> { reactive_view_props(current_user: current_user) }
15
+ #
16
+ # def index
17
+ # @users = User.all
18
+ # reactive_view_props(page: { title: "Users" })
19
+ # end
20
+ # end
21
+ def reactive_view_props(hash = nil)
22
+ @_reactive_view_props ||= {}
23
+ @_reactive_view_props = deep_merge(@_reactive_view_props, hash.deep_symbolize_keys) if hash
24
+ @_reactive_view_props
25
+ end
26
+
27
+ # Alias for convenience
28
+ alias reactive_props reactive_view_props
29
+
30
+ private
31
+
32
+ # Deep merge two hashes
33
+ def deep_merge(hash, other_hash)
34
+ hash.merge(other_hash) do |_key, old_val, new_val|
35
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
36
+ deep_merge(old_val, new_val)
37
+ else
38
+ new_val
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveViews
4
+ # CSS isolation strategies and conflict detection utilities
5
+ # Helps prevent style conflicts between React components and Rails views
6
+ module CssStrategy
7
+ # Common class names that often cause conflicts between React and Rails
8
+ COMMON_CONFLICT_CLASSES = %w[
9
+ btn button card container row col form input label
10
+ header footer nav navbar sidebar menu modal alert
11
+ badge tooltip popover dropdown tab panel
12
+ table list item link icon text title
13
+ primary secondary success danger warning info
14
+ active disabled selected hidden visible
15
+ small medium large
16
+ ].freeze
17
+
18
+ # Recommendations for CSS isolation strategies
19
+ STRATEGIES = {
20
+ css_modules: {
21
+ name: "CSS Modules",
22
+ description: "Automatic class name scoping with .module.css files",
23
+ pros: [ "Automatic scoping", "Build-time guarantee", "IDE support" ],
24
+ cons: [ "Requires Vite/bundler setup", "Different syntax from regular CSS" ],
25
+ setup: <<~SETUP
26
+ 1. Name your CSS files with .module.css extension
27
+ 2. Import styles as objects: import styles from './Component.module.css'
28
+ 3. Use styles.className in your JSX
29
+ SETUP
30
+ },
31
+ tailwind_prefix: {
32
+ name: "Tailwind Prefix",
33
+ description: "Configure Tailwind with a prefix for React components",
34
+ pros: [ "Works with existing Tailwind setup", "Easy to configure" ],
35
+ cons: [ "Need to remember prefix", "Doesn't help with custom CSS" ],
36
+ setup: <<~SETUP
37
+ In tailwind.config.js for React components:
38
+ module.exports = {
39
+ prefix: 'rv-',
40
+ content: ['./app/views/components/**/*.tsx'],
41
+ }
42
+ SETUP
43
+ },
44
+ bem_convention: {
45
+ name: "BEM Convention",
46
+ description: "Use Block-Element-Modifier naming with component prefix",
47
+ pros: [ "No tooling required", "Clear naming", "Works everywhere" ],
48
+ cons: [ "Manual discipline required", "Verbose class names" ],
49
+ setup: <<~SETUP
50
+ Use component name as block: ComponentName__element--modifier
51
+ Example: Counter__button--primary
52
+ SETUP
53
+ },
54
+ shadow_dom: {
55
+ name: "Shadow DOM",
56
+ description: "Use Web Components with Shadow DOM for true isolation",
57
+ pros: [ "Complete CSS isolation", "Native browser feature" ],
58
+ cons: [ "Complex setup", "SSR challenges", "Less React-like" ],
59
+ setup: <<~SETUP
60
+ Wrap React components in custom elements with Shadow DOM.
61
+ Note: This requires additional setup and may affect hydration.
62
+ SETUP
63
+ }
64
+ }.freeze
65
+
66
+ class << self
67
+ # Check for potential CSS conflicts in component HTML
68
+ # Returns an array of detected conflicts with details
69
+ #
70
+ # @param html [String] The HTML content to analyze
71
+ # @param rails_classes [Array<String>] Known Rails/application CSS classes
72
+ # @return [Array<Hash>] Array of conflict details
73
+ def detect_conflicts(html, rails_classes: [])
74
+ conflicts = []
75
+ all_classes = extract_classes(html)
76
+
77
+ # Check against common conflict patterns
78
+ common_conflicts = all_classes & COMMON_CONFLICT_CLASSES
79
+ common_conflicts.each do |class_name|
80
+ conflicts << {
81
+ type: :common_name,
82
+ class_name: class_name,
83
+ message: "Class '#{class_name}' is a common name that may conflict with Rails styles",
84
+ severity: :warning
85
+ }
86
+ end
87
+
88
+ # Check against known Rails classes
89
+ rails_conflicts = all_classes & rails_classes
90
+ rails_conflicts.each do |class_name|
91
+ conflicts << {
92
+ type: :rails_conflict,
93
+ class_name: class_name,
94
+ message: "Class '#{class_name}' conflicts with a known Rails application class",
95
+ severity: :error
96
+ }
97
+ end
98
+
99
+ conflicts
100
+ end
101
+
102
+ # Extract all CSS class names from HTML content
103
+ #
104
+ # @param html [String] The HTML content
105
+ # @return [Array<String>] Unique class names found
106
+ def extract_classes(html)
107
+ return [] if html.blank?
108
+
109
+ # Match class attributes and extract individual class names
110
+ classes = []
111
+ html.scan(/class=["']([^"']+)["']/) do |match|
112
+ classes.concat(match[0].split(/\s+/))
113
+ end
114
+
115
+ # Also match className for JSX
116
+ html.scan(/className=["']([^"']+)["']/) do |match|
117
+ classes.concat(match[0].split(/\s+/))
118
+ end
119
+
120
+ classes.uniq.reject(&:blank?)
121
+ end
122
+
123
+ # Generate a scoped class name using the component name as prefix
124
+ #
125
+ # @param component_name [String] The component name (e.g., "Counter")
126
+ # @param class_name [String] The original class name
127
+ # @return [String] Scoped class name (e.g., "rv-counter-button")
128
+ def scoped_class(component_name, class_name)
129
+ prefix = component_name.to_s.underscore.tr("_", "-")
130
+ "rv-#{prefix}-#{class_name}"
131
+ end
132
+
133
+ # Check if a CSS file uses CSS Modules syntax
134
+ #
135
+ # @param file_path [String] Path to the CSS file
136
+ # @return [Boolean] True if the file uses CSS Modules
137
+ def uses_css_modules?(file_path)
138
+ return false unless File.exist?(file_path)
139
+
140
+ # CSS Modules files typically have .module.css extension
141
+ return true if file_path.end_with?(".module.css", ".module.scss")
142
+
143
+ # Also check for :local and :global selectors
144
+ content = File.read(file_path)
145
+ content.include?(":local(") || content.include?(":global(")
146
+ end
147
+
148
+ # Get recommended strategy based on project setup
149
+ #
150
+ # @param options [Hash] Project configuration options
151
+ # @return [Symbol] Recommended strategy key
152
+ def recommend_strategy(options = {})
153
+ return :css_modules if options[:vite] || options[:has_bundler]
154
+ return :tailwind_prefix if options[:tailwind]
155
+
156
+ :bem_convention
157
+ end
158
+
159
+ # Log CSS conflict warnings during development
160
+ def log_conflicts(conflicts, logger: nil)
161
+ return if conflicts.empty?
162
+
163
+ logger ||= (defined?(Rails) && Rails.logger)
164
+ return unless logger
165
+
166
+ conflicts.each do |conflict|
167
+ case conflict[:severity]
168
+ when :error
169
+ logger.error("[ReactiveViews CSS] #{conflict[:message]}")
170
+ when :warning
171
+ logger.warn("[ReactiveViews CSS] #{conflict[:message]}")
172
+ else
173
+ logger.info("[ReactiveViews CSS] #{conflict[:message]}")
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveViews
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ReactiveViews
6
+
7
+ # Mount the engine routes at /reactive_views
8
+ initializer "reactive_views.routes" do |app|
9
+ app.routes.prepend do
10
+ mount ReactiveViews::Engine, at: "/reactive_views"
11
+ end
12
+ end
13
+ end
14
+ end