reactive_component 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 +13 -0
- data/LICENSE.txt +21 -0
- data/app/channels/reactive_component/channel.rb +73 -0
- data/app/controllers/reactive_component/actions_controller.rb +28 -0
- data/app/javascript/reactive_component/controllers/reactive_renderer_controller.js +224 -0
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +99 -0
- data/config/importmap.rb +4 -0
- data/config/routes.rb +5 -0
- data/lib/reactive_component/compiler.rb +221 -0
- data/lib/reactive_component/data_evaluator.rb +145 -0
- data/lib/reactive_component/engine.rb +19 -0
- data/lib/reactive_component/erb_extractor.rb +610 -0
- data/lib/reactive_component/version.rb +5 -0
- data/lib/reactive_component/wrapper.rb +62 -0
- data/lib/reactive_component.rb +218 -0
- metadata +137 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby2js"
|
|
4
|
+
|
|
5
|
+
module ReactiveComponent
|
|
6
|
+
module ErbExtractor
|
|
7
|
+
include Ruby2JS::Filter::SEXP
|
|
8
|
+
|
|
9
|
+
def initialize(*args)
|
|
10
|
+
super
|
|
11
|
+
@extracted_expressions = {}
|
|
12
|
+
@extracted_raw_fields = Set.new
|
|
13
|
+
@block_context_stack = []
|
|
14
|
+
@key_counter = 0
|
|
15
|
+
@source_to_key = {} # source string -> assigned key (scalar dedup)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set_options(options)
|
|
19
|
+
super
|
|
20
|
+
@extraction_output = @options[:extraction]
|
|
21
|
+
@nestable_checker = @options[:nestable_checker]
|
|
22
|
+
@nested_counter = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Intercept .each blocks to track block variable context.
|
|
26
|
+
# We override process() rather than on_block because the ERB filter
|
|
27
|
+
# sits above us in the MRO for on_block, so our override never gets
|
|
28
|
+
# called. By hooking process(), we push context BEFORE the normal
|
|
29
|
+
# handler chain (ERB -> Functions) runs, so process_erb_send_append
|
|
30
|
+
# sees the block context when processing the body.
|
|
31
|
+
def process(node)
|
|
32
|
+
return super unless node.respond_to?(:type)
|
|
33
|
+
|
|
34
|
+
# Pre-extract server-evaluable send nodes before other filters can
|
|
35
|
+
# transform them. The Functions filter processes certain patterns
|
|
36
|
+
# (e.g. respond_to? -> "in" operator) in its own process method,
|
|
37
|
+
# bypassing on_send where ErbExtractor normally does extraction.
|
|
38
|
+
if @erb_bufvar && node.type == :send && server_evaluable?(node) && !contains_lvar?(node)
|
|
39
|
+
unless in_block_context? && contains_block_var?(node)
|
|
40
|
+
if in_block_context? && current_block_context[:collection_source] == rebuild_source(node) && current_block_context[:collection_key].nil?
|
|
41
|
+
key = record_collection_extraction(node)
|
|
42
|
+
current_block_context[:collection_key] = key
|
|
43
|
+
else
|
|
44
|
+
key = record_extraction(node)
|
|
45
|
+
end
|
|
46
|
+
return s(:lvar, key.to_sym)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return super unless node.type == :block
|
|
51
|
+
|
|
52
|
+
call, args = node.children
|
|
53
|
+
return super unless call.type == :send
|
|
54
|
+
|
|
55
|
+
target, method = call.children
|
|
56
|
+
return super unless method == :each
|
|
57
|
+
|
|
58
|
+
block_var = args.children.first&.children&.first
|
|
59
|
+
return super unless block_var
|
|
60
|
+
|
|
61
|
+
collection_source = server_evaluable?(target) ? rebuild_source(target) : nil
|
|
62
|
+
|
|
63
|
+
@block_context_stack.push(
|
|
64
|
+
var: block_var,
|
|
65
|
+
computed: {},
|
|
66
|
+
collection_source: collection_source,
|
|
67
|
+
collection_key: nil
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# For bare ivar/const targets (e.g., @labels.each, STATUS_FILTERS.each),
|
|
71
|
+
# the Functions filter converts the block to for...of without dispatching
|
|
72
|
+
# to on_send/on_ivar, so the collection is never extracted. Pre-extract
|
|
73
|
+
# here and rewrite the block node so the JS references the extracted variable.
|
|
74
|
+
if (target.type == :ivar || target.type == :const) && collection_source
|
|
75
|
+
key = record_collection_extraction(target)
|
|
76
|
+
current_block_context[:collection_key] = key
|
|
77
|
+
new_call = s(:send, s(:lvar, key.to_sym), :each)
|
|
78
|
+
node = s(:block, new_call, args, node.children[2])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
result = super(node)
|
|
82
|
+
context = @block_context_stack.pop
|
|
83
|
+
flush_block_computed(context) if context[:collection_key]
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Hook called by ERB filter for expressions inside <%= %> tags.
|
|
88
|
+
# Despite the name, the node may be a :const (bare constant access).
|
|
89
|
+
def process_erb_send_append(send_node)
|
|
90
|
+
# Bare constant access (e.g., LabelBadgeComponent::COLORS)
|
|
91
|
+
if send_node.respond_to?(:type) && send_node.type == :const
|
|
92
|
+
key = record_extraction(send_node)
|
|
93
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
|
|
94
|
+
s(:send, nil, :String, s(:lvar, key.to_sym)))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
target, method, *args = send_node.children
|
|
98
|
+
|
|
99
|
+
# raw(expr) -- extract inner expression, mark as raw
|
|
100
|
+
if target.nil? && method == :raw && args.length == 1
|
|
101
|
+
inner = args.first
|
|
102
|
+
if extractable?(inner)
|
|
103
|
+
key = record_extraction(inner, raw: true)
|
|
104
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, s(:lvar, key.to_sym))
|
|
105
|
+
end
|
|
106
|
+
return defined?(super) ? super : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# tag.span(content, class: "...") -- build tag in JS
|
|
110
|
+
if tag_builder?(target)
|
|
111
|
+
return process_tag_builder_append(send_node)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Nestable component: compile to JS function instead of server-rendering HTML
|
|
115
|
+
if @nestable_checker && render_component_call?(send_node)
|
|
116
|
+
new_call = send_node.children[2]
|
|
117
|
+
const_node = new_call.children[0]
|
|
118
|
+
class_name = rebuild_source(const_node)
|
|
119
|
+
inside_block = in_block_context? && contains_block_var?(send_node)
|
|
120
|
+
klass = @nestable_checker.call(class_name, inside_block: inside_block)
|
|
121
|
+
if klass
|
|
122
|
+
if inside_block
|
|
123
|
+
# Nested component inside a collection: per-item data + JS render function
|
|
124
|
+
key = record_block_nested_component(send_node, class_name)
|
|
125
|
+
block_var = current_block_context[:var]
|
|
126
|
+
prop = s(:send, s(:lvar, block_var), :[], s(:str, key))
|
|
127
|
+
fn_name = :"_render_#{class_name.underscore}"
|
|
128
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
|
|
129
|
+
s(:send, nil, fn_name, prop))
|
|
130
|
+
else
|
|
131
|
+
key = record_nested_component(send_node, class_name)
|
|
132
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
|
|
133
|
+
s(:send, nil, :"_render_#{key}", s(:lvar, key.to_sym)))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Inside a .each block: expressions referencing the block variable
|
|
139
|
+
# become per-item computed fields. Must be checked before ivar_chain?
|
|
140
|
+
# because expressions like @message.labels.include?(label) have an
|
|
141
|
+
# ivar chain receiver but depend on the block variable.
|
|
142
|
+
if in_block_context? && contains_block_var?(send_node)
|
|
143
|
+
raw = html_producing?(send_node)
|
|
144
|
+
key = record_block_computed(send_node, raw: raw)
|
|
145
|
+
block_var = current_block_context[:var]
|
|
146
|
+
prop = s(:send, s(:lvar, block_var), :[], s(:str, key))
|
|
147
|
+
if raw
|
|
148
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, prop)
|
|
149
|
+
else
|
|
150
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
|
|
151
|
+
s(:send, nil, :String, prop))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Fallback: any remaining expression that doesn't reference block
|
|
156
|
+
# variables becomes a server-computed variable.
|
|
157
|
+
unless lvar_chain?(send_node) || contains_lvar?(send_node)
|
|
158
|
+
raw = html_producing?(send_node)
|
|
159
|
+
key = record_extraction(send_node, raw: raw)
|
|
160
|
+
if raw
|
|
161
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, s(:lvar, key.to_sym))
|
|
162
|
+
else
|
|
163
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
|
|
164
|
+
s(:send, nil, :String, s(:lvar, key.to_sym)))
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
defined?(super) ? super : nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Hook called by ERB filter for block expressions inside <%= expr do %>...<% end %>.
|
|
172
|
+
# Handles render(Component.new(...)) { block } by extracting as raw server-evaluated HTML.
|
|
173
|
+
def process_erb_block_append(block_node)
|
|
174
|
+
call_node, _args, body = block_node.children
|
|
175
|
+
|
|
176
|
+
if render_component_call?(call_node)
|
|
177
|
+
block_html = extract_block_html(body)
|
|
178
|
+
call_source = rebuild_source(call_node)
|
|
179
|
+
full_source = block_html ?
|
|
180
|
+
"#{call_source} { #{block_html.inspect}.html_safe }" :
|
|
181
|
+
call_source
|
|
182
|
+
key = record_extraction(nil, raw: true, source_override: full_source)
|
|
183
|
+
return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, s(:lvar, key.to_sym))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
defined?(super) ? super : nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Catches server-evaluable expressions in non-output context
|
|
190
|
+
# (if/unless conditions, ternaries, collection targets).
|
|
191
|
+
def on_send(node)
|
|
192
|
+
return super unless @erb_bufvar
|
|
193
|
+
|
|
194
|
+
if server_evaluable?(node) && !contains_lvar?(node)
|
|
195
|
+
source = rebuild_source(node)
|
|
196
|
+
|
|
197
|
+
# Collection being iterated: assign a unique key per loop
|
|
198
|
+
if in_block_context? && current_block_context[:collection_source] == source && current_block_context[:collection_key].nil?
|
|
199
|
+
key = record_collection_extraction(node)
|
|
200
|
+
current_block_context[:collection_key] = key
|
|
201
|
+
else
|
|
202
|
+
key = record_extraction(node)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return s(:lvar, key.to_sym)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
super
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Catches bare constant access in non-output context
|
|
212
|
+
# (e.g., if SomeConstant::VALUE in conditions).
|
|
213
|
+
def on_const(node)
|
|
214
|
+
return super unless @erb_bufvar
|
|
215
|
+
|
|
216
|
+
key = record_extraction(node)
|
|
217
|
+
s(:lvar, key.to_sym)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# --- Tag builder ---
|
|
223
|
+
|
|
224
|
+
def process_tag_builder_append(send_node)
|
|
225
|
+
_target, method, *args = send_node.children
|
|
226
|
+
tag_name = method.to_s
|
|
227
|
+
|
|
228
|
+
# Separate positional args from keyword hash
|
|
229
|
+
positional = args.dup
|
|
230
|
+
hash_arg = (ast_node?(positional.last) && positional.last.type == :hash) ? positional.pop : nil
|
|
231
|
+
content_node = positional.first
|
|
232
|
+
|
|
233
|
+
content_expr = content_node ? process_tag_arg(content_node) : s(:str, "")
|
|
234
|
+
|
|
235
|
+
if hash_arg
|
|
236
|
+
attrs_expr = process_tag_attrs(hash_arg)
|
|
237
|
+
call = s(:send, nil, :_tag, s(:str, tag_name), content_expr, attrs_expr)
|
|
238
|
+
else
|
|
239
|
+
call = s(:send, nil, :_tag, s(:str, tag_name), content_expr)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# _tag returns raw HTML (handles its own escaping)
|
|
243
|
+
s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, call)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def process_tag_arg(node)
|
|
247
|
+
return process(node) unless ast_node?(node)
|
|
248
|
+
|
|
249
|
+
if node.type == :array
|
|
250
|
+
return s(:array, *node.children.map { |child| process_tag_arg(child) })
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if ivar_chain?(node)
|
|
254
|
+
key = record_extraction(node)
|
|
255
|
+
return s(:lvar, key.to_sym)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if node.type == :send && node.children[0].nil? && contains_ivar?(node)
|
|
259
|
+
key = record_extraction(node)
|
|
260
|
+
return s(:lvar, key.to_sym)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if in_block_context? && contains_block_var?(node)
|
|
264
|
+
key = record_block_computed(node)
|
|
265
|
+
block_var = current_block_context[:var]
|
|
266
|
+
return s(:send, s(:lvar, block_var), :[], s(:str, key))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
process(node)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def process_tag_attrs(hash_node)
|
|
273
|
+
pairs = hash_node.children.map do |pair|
|
|
274
|
+
next pair unless ast_node?(pair) && pair.type == :pair
|
|
275
|
+
key_node, value_node = pair.children
|
|
276
|
+
js_key = (ast_node?(key_node) && key_node.type == :sym) ? s(:str, key_node.children[0].to_s) : key_node
|
|
277
|
+
processed_value = process_tag_arg(value_node)
|
|
278
|
+
s(:pair, js_key, processed_value)
|
|
279
|
+
end
|
|
280
|
+
s(:hash, *pairs)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# --- Render component detection ---
|
|
284
|
+
|
|
285
|
+
# Detects render(SomeConst.new(...)) pattern
|
|
286
|
+
def render_component_call?(node)
|
|
287
|
+
return false unless node&.type == :send
|
|
288
|
+
target, method, *args = node.children
|
|
289
|
+
return false unless target.nil? && method == :render && args.length == 1
|
|
290
|
+
arg = args.first
|
|
291
|
+
arg&.type == :send && arg.children[1] == :new
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Walks Erubi-processed block body to extract static HTML strings
|
|
295
|
+
# from buffer append operations (_buf << "html" or _buf += "html")
|
|
296
|
+
def extract_block_html(body)
|
|
297
|
+
return nil unless body
|
|
298
|
+
strings = []
|
|
299
|
+
collect_buffer_strings(body, strings)
|
|
300
|
+
strings.empty? ? nil : strings.join
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def collect_buffer_strings(node, strings)
|
|
304
|
+
return unless ast_node?(node)
|
|
305
|
+
case node.type
|
|
306
|
+
when :begin
|
|
307
|
+
node.children.each { |child| collect_buffer_strings(child, strings) }
|
|
308
|
+
when :op_asgn, :send
|
|
309
|
+
node.children.each do |child|
|
|
310
|
+
next unless ast_node?(child)
|
|
311
|
+
collect_str_content(child, strings)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def collect_str_content(node, strings)
|
|
317
|
+
case node.type
|
|
318
|
+
when :str
|
|
319
|
+
strings << node.children[0]
|
|
320
|
+
when :dstr
|
|
321
|
+
node.children.each { |c| strings << c.children[0] if ast_node?(c) && c.type == :str }
|
|
322
|
+
when :send
|
|
323
|
+
# handle .freeze wrapper: str("...").freeze or dstr(...).freeze
|
|
324
|
+
if node.children[1] == :freeze && ast_node?(node.children[0])
|
|
325
|
+
collect_str_content(node.children[0], strings)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# --- Key generation ---
|
|
331
|
+
|
|
332
|
+
def next_key
|
|
333
|
+
key = "v#{@key_counter}"
|
|
334
|
+
@key_counter += 1
|
|
335
|
+
key
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# --- Block context tracking ---
|
|
339
|
+
|
|
340
|
+
def in_block_context?
|
|
341
|
+
!@block_context_stack.empty?
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def current_block_context
|
|
345
|
+
@block_context_stack.last
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def contains_block_var?(node)
|
|
349
|
+
return false unless in_block_context?
|
|
350
|
+
contains_specific_lvar?(node, current_block_context[:var])
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def contains_specific_lvar?(node, var_name)
|
|
354
|
+
return false unless ast_node?(node)
|
|
355
|
+
return true if node.type == :lvar && node.children[0] == var_name
|
|
356
|
+
node.children.any? { |child| ast_node?(child) && contains_specific_lvar?(child, var_name) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def record_block_computed(node, raw: false)
|
|
360
|
+
source = rebuild_source(node)
|
|
361
|
+
computed = current_block_context[:computed]
|
|
362
|
+
|
|
363
|
+
# Dedup within this block: same source reuses same key
|
|
364
|
+
existing = computed.find { |_, info| info[:source] == source }
|
|
365
|
+
return existing[0] if existing
|
|
366
|
+
|
|
367
|
+
key = next_key
|
|
368
|
+
computed[key] = { source: source, raw: raw }
|
|
369
|
+
key
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def flush_block_computed(context)
|
|
373
|
+
return unless @extraction_output
|
|
374
|
+
key = context[:collection_key]
|
|
375
|
+
return unless key
|
|
376
|
+
|
|
377
|
+
@extraction_output[:collection_computed] ||= {}
|
|
378
|
+
@extraction_output[:collection_computed][key] = {
|
|
379
|
+
block_var: context[:var].to_s,
|
|
380
|
+
expressions: context[:computed]
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# --- Nested component recording ---
|
|
385
|
+
|
|
386
|
+
def record_block_nested_component(send_node, class_name)
|
|
387
|
+
new_call = send_node.children[2]
|
|
388
|
+
hash_node = new_call.children[2]
|
|
389
|
+
|
|
390
|
+
kwargs = {}
|
|
391
|
+
if hash_node && ast_node?(hash_node) && hash_node.type == :hash
|
|
392
|
+
hash_node.children.each do |pair|
|
|
393
|
+
next unless ast_node?(pair) && pair.type == :pair
|
|
394
|
+
kwarg_name = pair.children[0].children[0].to_s
|
|
395
|
+
kwarg_source = rebuild_source(pair.children[1])
|
|
396
|
+
kwargs[kwarg_name] = kwarg_source
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
key = next_key
|
|
401
|
+
computed = current_block_context[:computed]
|
|
402
|
+
computed[key] = {
|
|
403
|
+
source: nil,
|
|
404
|
+
raw: true,
|
|
405
|
+
nested_component: { class_name: class_name, kwargs: kwargs }
|
|
406
|
+
}
|
|
407
|
+
key
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def record_nested_component(send_node, class_name)
|
|
411
|
+
key = "_nc#{@nested_counter}"
|
|
412
|
+
@nested_counter += 1
|
|
413
|
+
|
|
414
|
+
new_call = send_node.children[2] # Component.new(...)
|
|
415
|
+
hash_node = new_call.children[2] # kwargs hash
|
|
416
|
+
|
|
417
|
+
kwargs = {}
|
|
418
|
+
if hash_node && ast_node?(hash_node) && hash_node.type == :hash
|
|
419
|
+
hash_node.children.each do |pair|
|
|
420
|
+
next unless ast_node?(pair) && pair.type == :pair
|
|
421
|
+
kwarg_name = pair.children[0].children[0].to_s
|
|
422
|
+
kwarg_source = rebuild_source(pair.children[1])
|
|
423
|
+
kwargs[kwarg_name] = kwarg_source
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
@extraction_output[:nested_components] ||= {}
|
|
428
|
+
@extraction_output[:nested_components][key] = {
|
|
429
|
+
class_name: class_name,
|
|
430
|
+
kwargs: kwargs
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
key
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# --- AST inspection helpers ---
|
|
437
|
+
|
|
438
|
+
def ivar_chain?(node)
|
|
439
|
+
return false unless node && ast_node?(node)
|
|
440
|
+
return true if node.type == :ivar
|
|
441
|
+
return false unless node.type == :send
|
|
442
|
+
target = node.children[0]
|
|
443
|
+
target && ivar_chain?(target)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def ivar_chain_to_name(node)
|
|
447
|
+
parts = []
|
|
448
|
+
current = node
|
|
449
|
+
while current && ast_node?(current) && current.type == :send
|
|
450
|
+
parts.unshift(current.children[1].to_s.delete_suffix("?"))
|
|
451
|
+
current = current.children[0]
|
|
452
|
+
end
|
|
453
|
+
parts.join("_")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def const_chain?(node)
|
|
457
|
+
return false unless node && ast_node?(node)
|
|
458
|
+
return true if node.type == :const
|
|
459
|
+
return false unless node.type == :send
|
|
460
|
+
target = node.children[0]
|
|
461
|
+
target && (const_chain?(target) || ivar_chain?(target))
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# An expression that can't run in JS and must be server-evaluated.
|
|
465
|
+
# Covers ivar chains, const chains, and bare helpers referencing ivars.
|
|
466
|
+
def server_evaluable?(node)
|
|
467
|
+
return false unless node && ast_node?(node)
|
|
468
|
+
return false if lvar_only?(node)
|
|
469
|
+
ivar_chain?(node) || const_chain?(node) ||
|
|
470
|
+
(node.type == :send && node.children[0].nil? && !pure_lvar_args?(node))
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def extractable?(node)
|
|
474
|
+
return false unless ast_node?(node)
|
|
475
|
+
ivar_chain?(node) || const_chain?(node) ||
|
|
476
|
+
(node.type == :send && node.children[0].nil? && contains_ivar?(node))
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def contains_ivar?(node)
|
|
480
|
+
return false unless ast_node?(node)
|
|
481
|
+
return true if node.type == :ivar
|
|
482
|
+
node.children.any? { |child| ast_node?(child) && contains_ivar?(child) }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def contains_const?(node)
|
|
486
|
+
return false unless ast_node?(node)
|
|
487
|
+
return true if node.type == :const
|
|
488
|
+
node.children.any? { |child| ast_node?(child) && contains_const?(child) }
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def contains_lvar?(node)
|
|
492
|
+
return false unless ast_node?(node)
|
|
493
|
+
return true if node.type == :lvar
|
|
494
|
+
node.children.any? { |child| ast_node?(child) && contains_lvar?(child) }
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Returns true if the node is purely lvar-based (no ivars, no consts)
|
|
498
|
+
def lvar_only?(node)
|
|
499
|
+
return false unless node && ast_node?(node)
|
|
500
|
+
return true if node.type == :lvar
|
|
501
|
+
return false if node.type == :ivar || node.type == :const
|
|
502
|
+
return false unless node.type == :send
|
|
503
|
+
!contains_ivar?(node) && !contains_const?(node)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Returns true if a bare method call's arguments only reference lvars/literals
|
|
507
|
+
def pure_lvar_args?(node)
|
|
508
|
+
return true unless node.type == :send
|
|
509
|
+
_target, _method, *args = node.children
|
|
510
|
+
args.none? { |arg| ast_node?(arg) && (contains_ivar?(arg) || contains_const?(arg)) }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def lvar_chain?(node)
|
|
514
|
+
return false unless node && ast_node?(node)
|
|
515
|
+
return true if node.type == :lvar
|
|
516
|
+
return false unless node.type == :send
|
|
517
|
+
node.children[0] && lvar_chain?(node.children[0])
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
HTML_PRODUCING_METHODS = %i[content_tag link_to button_to image_tag render].to_set.freeze
|
|
521
|
+
|
|
522
|
+
def html_producing?(node)
|
|
523
|
+
return false unless node.type == :send
|
|
524
|
+
target, method = node.children
|
|
525
|
+
return true if tag_builder?(target)
|
|
526
|
+
return true if target.nil? && HTML_PRODUCING_METHODS.include?(method)
|
|
527
|
+
false
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def tag_builder?(node)
|
|
531
|
+
node&.type == :send && node.children == [nil, :tag]
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# --- Source reconstruction ---
|
|
535
|
+
|
|
536
|
+
def rebuild_source(node)
|
|
537
|
+
return "" unless ast_node?(node)
|
|
538
|
+
case node.type
|
|
539
|
+
when :ivar then node.children[0].to_s
|
|
540
|
+
when :lvar then node.children[0].to_s
|
|
541
|
+
when :const
|
|
542
|
+
parent, name = node.children
|
|
543
|
+
parent ? "#{rebuild_source(parent)}::#{name}" : name.to_s
|
|
544
|
+
when :str then node.children[0].inspect
|
|
545
|
+
when :int, :float then node.children[0].to_s
|
|
546
|
+
when :true then "true"
|
|
547
|
+
when :false then "false"
|
|
548
|
+
when :nil then "nil"
|
|
549
|
+
when :sym then ":#{node.children[0]}"
|
|
550
|
+
when :hash
|
|
551
|
+
node.children.map { |pair| rebuild_source(pair) }.join(", ")
|
|
552
|
+
when :pair
|
|
553
|
+
key, value = node.children
|
|
554
|
+
val_str = rebuild_source(value)
|
|
555
|
+
val_str = "{ #{val_str} }" if ast_node?(value) && value.type == :hash
|
|
556
|
+
if key.type == :sym
|
|
557
|
+
"#{key.children[0]}: #{val_str}"
|
|
558
|
+
else
|
|
559
|
+
"#{rebuild_source(key)} => #{val_str}"
|
|
560
|
+
end
|
|
561
|
+
when :send
|
|
562
|
+
target, method, *args = node.children
|
|
563
|
+
recv = target ? rebuild_source(target) : nil
|
|
564
|
+
args_src = args.map { |a| rebuild_source(a) }.join(", ")
|
|
565
|
+
method_str = method.to_s
|
|
566
|
+
if recv
|
|
567
|
+
args.empty? ? "#{recv}.#{method_str}" : "#{recv}.#{method_str}(#{args_src})"
|
|
568
|
+
else
|
|
569
|
+
"#{method_str}(#{args_src})"
|
|
570
|
+
end
|
|
571
|
+
else ""
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# --- Extraction recording ---
|
|
576
|
+
|
|
577
|
+
# Scalar: same source reuses same key.
|
|
578
|
+
def record_extraction(node, raw: false, source_override: nil)
|
|
579
|
+
source = source_override || rebuild_source(node)
|
|
580
|
+
|
|
581
|
+
if @source_to_key.key?(source)
|
|
582
|
+
key = @source_to_key[source]
|
|
583
|
+
@extracted_raw_fields << key if raw
|
|
584
|
+
return key
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
key = next_key
|
|
588
|
+
@source_to_key[source] = key
|
|
589
|
+
@extracted_expressions[key] = source
|
|
590
|
+
@extracted_raw_fields << key if raw
|
|
591
|
+
flush_extraction_output
|
|
592
|
+
key
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Collection: always unique (no dedup), each loop gets its own key.
|
|
596
|
+
def record_collection_extraction(node)
|
|
597
|
+
source = rebuild_source(node)
|
|
598
|
+
key = next_key
|
|
599
|
+
@extracted_expressions[key] = source
|
|
600
|
+
flush_extraction_output
|
|
601
|
+
key
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def flush_extraction_output
|
|
605
|
+
return unless @extraction_output
|
|
606
|
+
@extraction_output[:expressions] = @extracted_expressions.dup
|
|
607
|
+
@extraction_output[:raw_fields] = @extracted_raw_fields.dup
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactiveComponent
|
|
4
|
+
module Wrapper
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def wrap(component_class, record, inner_html, stream: nil, client_state: nil, strategy: nil, component_name: nil, params: nil, template_id: nil)
|
|
8
|
+
dom_id_val = component_class.dom_id_for(record)
|
|
9
|
+
|
|
10
|
+
attrs = [
|
|
11
|
+
%(id="#{dom_id_val}"),
|
|
12
|
+
%(data-controller="reactive-renderer"),
|
|
13
|
+
%(data-reactive-renderer-template-id-value="#{template_id || component_class.template_element_id}")
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
if stream
|
|
17
|
+
signed = Turbo::StreamsChannel.signed_stream_name(stream)
|
|
18
|
+
attrs << %(data-reactive-renderer-stream-value="#{signed}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if component_class._live_actions.any?
|
|
22
|
+
attrs << %(data-reactive-renderer-action-url-value="#{ReactiveComponent::Engine.routes.url_helpers.reactive_component_actions_path}")
|
|
23
|
+
attrs << %(data-reactive-renderer-action-token-value="#{component_class.live_action_token(record)}")
|
|
24
|
+
attrs << %(data-reactive-renderer-field-map-value="#{ERB::Util.html_escape(component_class.expression_field_map.to_json)}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if client_state
|
|
28
|
+
attrs << %(data-reactive-renderer-state-value="#{ERB::Util.html_escape(client_state.to_json)}")
|
|
29
|
+
initial_data = component_class.build_data(record, **client_state.symbolize_keys)
|
|
30
|
+
attrs << %(data-reactive-renderer-data-value="#{ERB::Util.html_escape(initial_data.to_json)}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if strategy
|
|
34
|
+
attrs << %(data-reactive-renderer-strategy-value="#{strategy}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if component_name
|
|
38
|
+
attrs << %(data-reactive-renderer-component-value="#{component_name}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if params
|
|
42
|
+
attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if ReactiveComponent.debug
|
|
46
|
+
debug_label = "#{component_class.name.underscore.humanize} ##{dom_id_val}"
|
|
47
|
+
attrs << %(data-reactive-debug="#{debug_label}")
|
|
48
|
+
attrs << %(class="reactive-debug-wrapper")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
%(<div #{attrs.join(" ")}>#{inner_html}</div>).html_safe
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def find_stream_for(component_class, record)
|
|
55
|
+
config = component_class._broadcast_config
|
|
56
|
+
return nil unless config
|
|
57
|
+
|
|
58
|
+
stream = config[:stream]
|
|
59
|
+
stream.is_a?(Proc) ? stream.call(record) : stream
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|