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,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
|