reactive_component 0.1.0 → 0.6.2

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.
@@ -0,0 +1,586 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'set'
5
+ require 'prism'
6
+
7
+ module ReactiveComponent
8
+ # Erubi Ruby → the client render function, in one pass over Prism's tree.
9
+ #
10
+ # Every Ruby expression the template contains is lifted to a server-evaluated
11
+ # data key as it is met (scalar `vN`, per-item `item.vN`, or a nested
12
+ # component), and the extraction metadata is written to `extraction` for the
13
+ # DataEvaluator. What is emitted as JavaScript is only the template's
14
+ # skeleton — literals, data reads, if/unless/ternary, boolean and comparison
15
+ # operators, `.each` as for..of, and the `_tag*`/`_render_*` helpers. It is a
16
+ # whitelist, not a converter: anything else raises CompileError naming the
17
+ # source, never a best-effort translation.
18
+ module Transpiler
19
+ module_function
20
+
21
+ def call(erb_ruby, extraction:, nestable_checker: nil)
22
+ result = Prism.parse(erb_ruby)
23
+ raise CompileError, result.errors.map(&:message).join('; ') if result.failure?
24
+
25
+ Emitter.new(extraction: extraction, nestable_checker: nestable_checker).render(result.value)
26
+ end
27
+
28
+ class Emitter
29
+ HTML_PRODUCING_METHODS = %i[content_tag link_to button_to image_tag render].to_set.freeze
30
+ BINARY = { :== => '===', :!= => '!==', :< => '<', :> => '>', :<= => '<=', :>= => '>=',
31
+ :+ => '+', :- => '-', :* => '*', :/ => '/', :% => '%' }.freeze
32
+ IDENT = /\A[A-Za-z_$][\w$]*\z/
33
+ BUFFERS = %i[_buf _erbout].freeze
34
+ TO_S = %i[to_s toString].freeze
35
+
36
+ Block = Struct.new(:var, :computed, :collection_key)
37
+
38
+ def initialize(extraction:, nestable_checker:)
39
+ @extraction = extraction
40
+ @nestable_checker = nestable_checker
41
+ @expressions = {}
42
+ @raw_fields = Set.new
43
+ @source_to_key = {}
44
+ @key_counter = 0
45
+ @nested_counter = 0
46
+ @blocks = []
47
+ @params = Set.new
48
+ @locals = Set.new
49
+ end
50
+
51
+ # The shape the compiler's wrapper stripping and escaping pass expect:
52
+ # `function render({ a, v0 }) {\n …\n}`.
53
+ def render(program)
54
+ statements = program.statements.body
55
+ first = statements.first
56
+ unless first.is_a?(Prism::LocalVariableWriteNode) && BUFFERS.include?(first.name)
57
+ raise CompileError, 'expected an Erubi program starting with the buffer assignment'
58
+ end
59
+
60
+ @buf = first.name
61
+ body = statements.drop(1).map { |node| stmt(node) }.reject(&:empty?)
62
+ flush
63
+ lines = ["let #{@buf} = \"\";", *body]
64
+ "function render({ #{@params.sort.join(', ')} }) {\n#{indent(lines.join("\n"))}\n return #{@buf}\n}"
65
+ end
66
+
67
+ private
68
+
69
+ # --- statements ---
70
+
71
+ def stmt(node)
72
+ case node
73
+ when Prism::StatementsNode then node.body.map { |child| stmt(child) }.reject(&:empty?).join("\n")
74
+ when Prism::ParenthesesNode then node.body ? stmt(node.body) : ''
75
+ when Prism::CallNode then call_stmt(node)
76
+ when Prism::IfNode then if_stmt(node.predicate, node.statements, node.subsequent)
77
+ when Prism::UnlessNode then if_stmt(node.predicate, node.statements, node.else_clause, negate: true)
78
+ when Prism::LocalVariableWriteNode
79
+ @locals << node.name
80
+ "let #{node.name} = #{expr(node.value)};"
81
+ # a literal as a statement is a brace-block body — `tag.div { "x" }` —
82
+ # whose value is the content
83
+ when Prism::StringNode, Prism::InterpolatedStringNode then "#{@buf} += #{expr(node)};"
84
+ when Prism::NilNode then ''
85
+ else "#{expr(node)};"
86
+ end
87
+ end
88
+
89
+ def call_stmt(node)
90
+ if buf?(node.receiver)
91
+ case node.name
92
+ when :<< then return append(node.arguments&.arguments&.first)
93
+ when :append= then return append_hook(node.arguments&.arguments&.first)
94
+ when :to_s, :toString then return '' # Erubi's postamble
95
+ end
96
+ end
97
+ return each_stmt(node) if node.name == :each && node.block.is_a?(Prism::BlockNode)
98
+ if node.block.is_a?(Prism::BlockNode) && !tag_builder?(node.receiver) && !render_component_call?(node)
99
+ raise CompileError, "only `.each` loops compile to the client (got `.#{node.name}`)"
100
+ end
101
+
102
+ "#{expr(node)};"
103
+ end
104
+
105
+ def if_stmt(predicate, then_branch, else_branch, negate: false)
106
+ cond = negate ? "!#{group(expr(predicate))}" : expr(predicate)
107
+ then_branch = then_branch.statements if then_branch.is_a?(Prism::ElseNode)
108
+ else_branch = else_branch.statements if else_branch.is_a?(Prism::ElseNode)
109
+ return if_stmt(predicate, else_branch, nil, negate: !negate) if then_branch.nil? && else_branch
110
+
111
+ out = "if (#{cond}) {\n#{indent(then_branch ? stmt(then_branch) : '')}\n}"
112
+ out += " else {\n#{indent(stmt(else_branch))}\n}" if else_branch
113
+ out
114
+ end
115
+
116
+ def each_stmt(node)
117
+ var = block_var(node.block)
118
+ receiver = node.receiver
119
+ collection_key = nil
120
+ collection = if server_evaluable?(receiver) && !contains_lvar?(receiver)
121
+ collection_key = record_collection_extraction(receiver)
122
+ elsif in_block? && contains_block_var?(receiver)
123
+ raise CompileError,
124
+ "nested loops are not supported (`#{receiver.slice}.each` inside `#{current_block.var}`)"
125
+ else
126
+ expr(receiver)
127
+ end
128
+
129
+ @blocks.push(Block.new(var, {}, collection_key))
130
+ body = node.block.body ? stmt(node.block.body) : ''
131
+ flush_block_computed(@blocks.pop)
132
+ "for (let #{var} of #{collection}) {\n#{indent(body)}\n}"
133
+ end
134
+
135
+ def block_var(block)
136
+ params = block.parameters&.parameters
137
+ param = params.requireds.first if params
138
+ param&.name or raise CompileError, 'an `.each` block needs a block variable'
139
+ end
140
+
141
+ # --- `<%= %>` ---
142
+
143
+ # `_buf << 'literal'.freeze` or `_buf << (expr).to_s`
144
+ def append(arg)
145
+ return '' if arg.nil?
146
+
147
+ arg = arg.receiver if arg.is_a?(Prism::CallNode) && arg.name == :freeze && arg.receiver
148
+ return output(unwrap(arg.receiver)) if arg.is_a?(Prism::CallNode) && TO_S.include?(arg.name) && arg.arguments.nil?
149
+
150
+ output(unwrap(arg))
151
+ end
152
+
153
+ # `_buf.append= expr do … end` — Erubi's block-expression form
154
+ def append_hook(arg)
155
+ return '' if arg.nil?
156
+
157
+ output(unwrap(arg))
158
+ end
159
+
160
+ def output(inner)
161
+ return block_append(inner) if inner.is_a?(Prism::CallNode) && inner.block.is_a?(Prism::BlockNode)
162
+
163
+ case inner
164
+ when Prism::StringNode then emit_append(expr(inner))
165
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then emit_append("String(#{extract(inner)})")
166
+ when Prism::InstanceVariableReadNode then emit_append("escapeHTML(#{client_ivar(inner)})")
167
+ when Prism::LocalVariableReadNode then emit_append("escapeHTML(#{local(inner)})")
168
+ when Prism::CallNode then call_output(inner)
169
+ else emit_append("String(#{expr(inner)})")
170
+ end
171
+ end
172
+
173
+ def call_output(node)
174
+ return raw_output(node.arguments.arguments.first) if raw_call?(node)
175
+ return emit_append(tag_call(node)) if tag_builder?(node.receiver)
176
+
177
+ nested = nested_component_output(node)
178
+ return nested if nested
179
+
180
+ if in_block? && contains_block_var?(node)
181
+ key = record_block_computed(node, raw: html_producing?(node))
182
+ return emit_append(html_producing?(node) ? item_key(key) : "String(#{item_key(key)})")
183
+ end
184
+ unless lvar_chain?(node) || contains_lvar?(node)
185
+ key = extract(node, raw: html_producing?(node))
186
+ return emit_append(html_producing?(node) ? key : "String(#{key})")
187
+ end
188
+
189
+ emit_append("String(#{expr(node)})")
190
+ end
191
+
192
+ # `raw(expr)` — an explicit declaration of server-computed HTML
193
+ def raw_output(inner)
194
+ return emit_append(item_key(record_block_computed(inner, raw: true))) if in_block? && contains_block_var?(inner)
195
+ return emit_append(extract(inner, raw: true)) unless contains_lvar?(inner)
196
+
197
+ raise CompileError, "`raw(#{inner.slice})` depends on a local the server cannot see"
198
+ end
199
+
200
+ # `<%= tag.div(attrs) do %>…<% end %>` splits into open / body / close so the
201
+ # body stays reactive; `<%= render(X.new) do %>…<% end %>` is rendered on
202
+ # the server as one raw string.
203
+ def block_append(node)
204
+ if tag_builder?(node.receiver)
205
+ attrs = keyword_hash(node)
206
+ open = "_tag_open(#{JSON.generate(node.name.to_s)}, #{attrs ? hash_expr(attrs) : 'null'})"
207
+ body = node.block.body ? stmt(node.block.body) : ''
208
+ return [emit_append(open), body, emit_append(JSON.generate("</#{node.name}>"))].reject(&:empty?).join("\n")
209
+ end
210
+ return emit_append(extract_render_block(node)) if render_component_call?(node)
211
+
212
+ raise CompileError, "`.#{node.name}` with a block reached the client — " \
213
+ 'only `.each`, tag builders and `render` take a block'
214
+ end
215
+
216
+ def extract_render_block(node)
217
+ call_source = node.slice[0, node.slice.rindex(node.block.slice)].rstrip
218
+ content = block_html(node.block.body)
219
+ source = if content.nil? then call_source
220
+ elsif content.start_with?('[') then "#{call_source} { (#{content}).html_safe }"
221
+ else "#{call_source} { #{content.inspect}.html_safe }"
222
+ end
223
+ record_extraction(source, raw: true)
224
+ end
225
+
226
+ # The block body as a Ruby string expression: static chunks and the
227
+ # source of each dynamic `<%= %>`.
228
+ def block_html(body)
229
+ return nil unless body
230
+
231
+ parts = []
232
+ body.body.each do |node|
233
+ next unless node.is_a?(Prism::CallNode) && buf?(node.receiver) && node.name == :<<
234
+
235
+ arg = node.arguments.arguments.first
236
+ arg = arg.receiver if arg.is_a?(Prism::CallNode) && arg.name == :freeze && arg.receiver
237
+ if arg.is_a?(Prism::StringNode)
238
+ parts << [:static, arg.unescaped]
239
+ elsif arg.is_a?(Prism::CallNode) && TO_S.include?(arg.name)
240
+ parts << [:dynamic, unwrap(arg.receiver).slice]
241
+ end
242
+ end
243
+ return nil if parts.empty?
244
+ return parts.map(&:last).join if parts.all? { |kind, _| kind == :static }
245
+
246
+ "[#{parts.map { |kind, text| kind == :static ? text.inspect : "(#{text}).to_s" }.join(', ')}].join"
247
+ end
248
+
249
+ def nested_component_output(node)
250
+ return nil unless @nestable_checker && render_component_call?(node)
251
+
252
+ new_call = node.arguments.arguments.first
253
+ class_name = new_call.receiver.slice
254
+ inside_block = in_block? && contains_block_var?(node)
255
+ return nil unless @nestable_checker.call(class_name, inside_block: inside_block)
256
+
257
+ kwargs = component_kwargs(new_call)
258
+ if inside_block
259
+ key = next_key
260
+ current_block.computed[key] = { source: nil, raw: true, nested_component: { class_name: class_name, kwargs: kwargs } }
261
+ emit_append("_render_#{class_name.underscore}(#{item_key(key)})")
262
+ else
263
+ key = "_nc#{@nested_counter}"
264
+ @nested_counter += 1
265
+ (@extraction[:nested_components] ||= {})[key] = { class_name: class_name, kwargs: kwargs }
266
+ @params << key
267
+ emit_append("_render_#{key}(#{key})")
268
+ end
269
+ end
270
+
271
+ def component_kwargs(new_call)
272
+ hash = keyword_hash(new_call)
273
+ return {} unless hash
274
+
275
+ hash.elements.filter_map do |pair|
276
+ next unless pair.is_a?(Prism::AssocNode)
277
+
278
+ [pair.key.unescaped.to_s, pair.value.slice]
279
+ end.to_h
280
+ end
281
+
282
+ # --- expressions ---
283
+
284
+ def expr(node)
285
+ case node
286
+ when Prism::ParenthesesNode
287
+ raise CompileError, 'a statement sequence cannot be used as a value' unless node.body&.body&.size == 1
288
+
289
+ expr(node.body.body.first)
290
+ when Prism::StringNode then JSON.generate(node.unescaped)
291
+ when Prism::SymbolNode then JSON.generate(node.unescaped.to_s)
292
+ when Prism::IntegerNode, Prism::FloatNode then node.value.to_s
293
+ when Prism::TrueNode then 'true'
294
+ when Prism::FalseNode then 'false'
295
+ when Prism::NilNode then 'null'
296
+ when Prism::InstanceVariableReadNode then client_ivar(node)
297
+ when Prism::LocalVariableReadNode then local(node)
298
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then extract(node)
299
+ when Prism::AndNode then "(#{expr(node.left)} && #{expr(node.right)})"
300
+ when Prism::OrNode then "(#{expr(node.left)} || #{expr(node.right)})"
301
+ when Prism::IfNode then ternary(expr(node.predicate), node.statements, node.subsequent)
302
+ when Prism::UnlessNode then ternary("!#{group(expr(node.predicate))}", node.statements, node.else_clause)
303
+ when Prism::ArrayNode then "[#{node.elements.map { |child| expr(child) }.join(', ')}]"
304
+ when Prism::HashNode, Prism::KeywordHashNode then hash_expr(node)
305
+ when Prism::InterpolatedStringNode then interpolated(node)
306
+ when Prism::CallNode then call_expr(node)
307
+ else raise CompileError, "unsupported in a client template: #{node.class.name.delete_prefix('Prism::')}"
308
+ end
309
+ end
310
+
311
+ def ternary(cond, then_branch, else_branch)
312
+ then_branch = then_branch.statements if then_branch.is_a?(Prism::ElseNode)
313
+ else_branch = else_branch.statements if else_branch.is_a?(Prism::ElseNode)
314
+ "(#{cond} ? #{branch_expr(then_branch)} : #{branch_expr(else_branch)})"
315
+ end
316
+
317
+ def branch_expr(statements)
318
+ return '""' if statements.nil? || statements.body.empty?
319
+ raise CompileError, 'a branch used as a value must be a single expression' unless statements.body.size == 1
320
+
321
+ expr(statements.body.first)
322
+ end
323
+
324
+ def call_expr(node)
325
+ return lifted(node) if liftable?(node)
326
+ return expr(node.receiver) if %i[html_safe freeze].include?(node.name) && node.arguments.nil? && node.receiver
327
+ return expr(node.arguments.arguments.first) if raw_call?(node)
328
+ return tag_call(node) if tag_builder?(node.receiver) && node.block.nil?
329
+ raise CompileError, "only `.each` loops compile to the client (got `.#{node.name}`)" if node.block.is_a?(Prism::BlockNode)
330
+
331
+ receiver = node.receiver
332
+ args = node.arguments&.arguments || []
333
+ raise CompileError, "`#{node.name}` reached the client — helpers must be evaluated on the server" if receiver.nil?
334
+
335
+ case node.name
336
+ when :! then "!#{group(expr(receiver))}"
337
+ when *BINARY.keys then "(#{expr(receiver)} #{BINARY[node.name]} #{expr(args.first)})"
338
+ when :[] then "#{expr(receiver)}[#{expr(args.first)}]"
339
+ else
340
+ raise CompileError,
341
+ "`.#{node.name}` reached the client — only data reads and operators compile; evaluate it on the server"
342
+ end
343
+ end
344
+
345
+ # A read the server must resolve: an ivar/const/helper chain with no
346
+ # locals (a scalar key), or anything touching the loop variable (a typed
347
+ # per-item key — `"false"` is truthy in JS, so never stringified).
348
+ def liftable?(node)
349
+ return true if in_block? && contains_block_var?(node)
350
+
351
+ server_evaluable?(node) && !contains_lvar?(node)
352
+ end
353
+
354
+ def lifted(node)
355
+ return item_key(record_block_computed(node, typed: true)) if in_block? && contains_block_var?(node)
356
+
357
+ extract(node)
358
+ end
359
+
360
+ # `"#{@x}!"` is the server's like any ivar expression; only a purely
361
+ # local interpolation stays a template literal.
362
+ def interpolated(node)
363
+ return extract(node) unless contains_lvar?(node)
364
+ return lifted(node) if liftable?(node)
365
+
366
+ parts = node.parts.map do |part|
367
+ case part
368
+ when Prism::StringNode then part.unescaped.gsub(/[`\\]|\$\{/) { |m| "\\#{m}" }
369
+ when Prism::EmbeddedStatementsNode then "${#{expr(part.statements.body.first)}}"
370
+ else raise CompileError, "unsupported interpolation: #{part.class}"
371
+ end
372
+ end
373
+ "`#{parts.join}`"
374
+ end
375
+
376
+ def tag_call(node)
377
+ attrs = keyword_hash(node)
378
+ content = (node.arguments&.arguments || []).find { |arg| !arg.equal?(attrs) }
379
+ parts = [JSON.generate(node.name.to_s), content ? expr(content) : '""']
380
+ parts << hash_expr(attrs) if attrs
381
+ "_tag(#{parts.join(', ')})"
382
+ end
383
+
384
+ def hash_expr(node)
385
+ pairs = node.elements.map do |element|
386
+ case element
387
+ when Prism::AssocNode
388
+ key = element.key
389
+ name = key.is_a?(Prism::SymbolNode) || key.is_a?(Prism::StringNode) ? key.unescaped.to_s : nil
390
+ raise CompileError, "unsupported hash key: #{key.slice}" unless name
391
+
392
+ "#{name.match?(IDENT) ? name : JSON.generate(name)}: #{expr(element.value)}"
393
+ when Prism::AssocSplatNode
394
+ # `**@options`: the splat target is the server's — it may hold anything
395
+ value = element.value
396
+ spread = if in_block? && contains_block_var?(value) then item_key(record_block_computed(value))
397
+ elsif contains_lvar?(value) then expr(value)
398
+ else extract(value)
399
+ end
400
+ "...#{spread}"
401
+ else raise CompileError, "unsupported in a hash: #{element.class}"
402
+ end
403
+ end
404
+ "{#{pairs.join(', ')}}"
405
+ end
406
+
407
+ # --- identifiers ---
408
+
409
+ def client_ivar(node)
410
+ name = node.name.to_s.delete_prefix('@')
411
+ @params << name
412
+ name
413
+ end
414
+
415
+ def local(node)
416
+ if @blocks.any? { |block| block.var == node.name }
417
+ raise CompileError, "`#{node.name}` reads the loop variable `#{node.name}` in a way the client cannot " \
418
+ 'resolve — an item is shipped only as its extracted expressions. Move the ' \
419
+ 'expression into an output or condition the compiler can evaluate per item.'
420
+ end
421
+
422
+ @params << node.name.to_s unless @locals.include?(node.name)
423
+ node.name.to_s
424
+ end
425
+
426
+ def item_key(key) = "#{current_block.var}.#{key}"
427
+ def in_block? = !@blocks.empty?
428
+ def current_block = @blocks.last
429
+
430
+ # --- recording (the extraction contract the DataEvaluator consumes) ---
431
+
432
+ def extract(node, raw: false)
433
+ key = record_extraction(source_of(node), raw: raw)
434
+ @params << key
435
+ key
436
+ end
437
+
438
+ # Scalar: the same source reuses the same key.
439
+ def record_extraction(source, raw: false)
440
+ key = @source_to_key[source] ||= begin
441
+ k = next_key
442
+ @expressions[k] = source
443
+ k
444
+ end
445
+ @raw_fields << key if raw
446
+ key
447
+ end
448
+
449
+ # Collection: always unique — each loop gets its own key.
450
+ def record_collection_extraction(node)
451
+ key = next_key
452
+ @expressions[key] = source_of(node)
453
+ @params << key
454
+ key
455
+ end
456
+
457
+ def record_block_computed(node, raw: false, typed: false)
458
+ source = source_of(node)
459
+ computed = current_block.computed
460
+ existing = computed.find { |_, info| info[:source] == source && info.fetch(:typed, false) == typed }
461
+ return existing[0] if existing
462
+
463
+ key = next_key
464
+ computed[key] = { source: source, raw: raw, typed: typed }
465
+ key
466
+ end
467
+
468
+ def flush_block_computed(block)
469
+ return unless block.collection_key
470
+
471
+ (@extraction[:collection_computed] ||= {})[block.collection_key] =
472
+ { block_var: block.var.to_s, expressions: block.computed }
473
+ end
474
+
475
+ def flush
476
+ @extraction[:expressions] = @expressions.dup
477
+ @extraction[:raw_fields] = @raw_fields.dup
478
+ end
479
+
480
+ def next_key
481
+ key = "v#{@key_counter}"
482
+ @key_counter += 1
483
+ key
484
+ end
485
+
486
+ def source_of(node) = unwrap(node).slice
487
+
488
+ # --- predicates ---
489
+
490
+ def buf?(node) = node.is_a?(Prism::LocalVariableReadNode) && node.name == @buf
491
+ def raw_call?(node) = node.receiver.nil? && node.name == :raw && node.arguments&.arguments&.size == 1
492
+ def tag_builder?(node) = node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :tag && node.arguments.nil?
493
+
494
+ def render_component_call?(node)
495
+ return false unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
496
+
497
+ arg = node.arguments&.arguments&.first
498
+ node.arguments.arguments.size == 1 && arg.is_a?(Prism::CallNode) && arg.name == :new
499
+ end
500
+
501
+ def html_producing?(node)
502
+ tag_builder?(node.receiver) || (node.receiver.nil? && HTML_PRODUCING_METHODS.include?(node.name))
503
+ end
504
+
505
+ def keyword_hash(call)
506
+ call.arguments&.arguments&.find { |arg| arg.is_a?(Prism::KeywordHashNode) || arg.is_a?(Prism::HashNode) }
507
+ end
508
+
509
+ def unwrap(node)
510
+ node = node.body.body.first while node.is_a?(Prism::ParenthesesNode) && node.body&.body&.size == 1
511
+ node
512
+ end
513
+
514
+ def contains?(node, *classes)
515
+ return false unless node.is_a?(Prism::Node)
516
+ return true if classes.any? { |klass| node.is_a?(klass) }
517
+
518
+ node.compact_child_nodes.any? { |child| contains?(child, *classes) }
519
+ end
520
+
521
+ def contains_lvar?(node) = contains?(node, Prism::LocalVariableReadNode)
522
+ def contains_ivar?(node) = contains?(node, Prism::InstanceVariableReadNode)
523
+ def contains_const?(node) = contains?(node, Prism::ConstantReadNode, Prism::ConstantPathNode)
524
+
525
+ def contains_block_var?(node)
526
+ return false unless node.is_a?(Prism::Node)
527
+ return true if node.is_a?(Prism::LocalVariableReadNode) && node.name == current_block.var
528
+
529
+ node.compact_child_nodes.any? { |child| contains_block_var?(child) }
530
+ end
531
+
532
+ def ivar_chain?(node)
533
+ return true if node.is_a?(Prism::InstanceVariableReadNode)
534
+ return false unless node.is_a?(Prism::CallNode) && node.receiver
535
+
536
+ ivar_chain?(node.receiver)
537
+ end
538
+
539
+ def const_chain?(node)
540
+ return true if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
541
+ return false unless node.is_a?(Prism::CallNode) && node.receiver
542
+
543
+ const_chain?(node.receiver) || ivar_chain?(node.receiver)
544
+ end
545
+
546
+ # `helper`, and any chain rooted at one — `content.present?`: the
547
+ # receiver is the server's, so the whole chain is. Tag-builder chains are
548
+ # excluded: they become `_tag*` calls.
549
+ def self_call_chain?(node)
550
+ return false unless node.is_a?(Prism::CallNode) && !contains_lvar?(node)
551
+
552
+ root = node
553
+ root = root.receiver while root.is_a?(Prism::CallNode) && root.receiver
554
+ root.is_a?(Prism::CallNode) && root.receiver.nil? && !tag_builder?(root)
555
+ end
556
+
557
+ def lvar_chain?(node)
558
+ return true if node.is_a?(Prism::LocalVariableReadNode)
559
+ return false unless node.is_a?(Prism::CallNode) && node.receiver
560
+
561
+ lvar_chain?(node.receiver)
562
+ end
563
+
564
+ # Purely local-based: has a local and nothing the server owns.
565
+ def lvar_only?(node)
566
+ return true if node.is_a?(Prism::LocalVariableReadNode)
567
+ return false unless node.is_a?(Prism::CallNode) && node.receiver
568
+
569
+ contains_lvar?(node) && !contains_ivar?(node) && !contains_const?(node)
570
+ end
571
+
572
+ def server_evaluable?(node)
573
+ return false unless node.is_a?(Prism::Node)
574
+ return false if lvar_only?(node)
575
+
576
+ ivar_chain?(node) || const_chain?(node) || self_call_chain?(node)
577
+ end
578
+
579
+ # --- text ---
580
+
581
+ def emit_append(value) = "#{@buf} += #{value};"
582
+ def group(code) = code.start_with?('(') && code.end_with?(')') ? code : "(#{code})"
583
+ def indent(text) = text.gsub(/^/, ' ')
584
+ end
585
+ end
586
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveComponent
4
- VERSION = "0.1.0"
4
+ VERSION = '0.6.2'
5
5
  end
@@ -4,7 +4,8 @@ module ReactiveComponent
4
4
  module Wrapper
5
5
  module_function
6
6
 
7
- def wrap(component_class, record, inner_html, stream: nil, client_state: nil, strategy: nil, component_name: nil, params: nil, template_id: nil)
7
+ def wrap(component_class, record, inner_html, stream: nil, client_state: nil, strategy: nil, component_name: nil,
8
+ params: nil, template_id: nil)
8
9
  dom_id_val = component_class.dom_id_for(record)
9
10
 
10
11
  attrs = [
@@ -30,17 +31,11 @@ module ReactiveComponent
30
31
  attrs << %(data-reactive-renderer-data-value="#{ERB::Util.html_escape(initial_data.to_json)}")
31
32
  end
32
33
 
33
- if strategy
34
- attrs << %(data-reactive-renderer-strategy-value="#{strategy}")
35
- end
34
+ attrs << %(data-reactive-renderer-strategy-value="#{strategy}") if strategy
36
35
 
37
- if component_name
38
- attrs << %(data-reactive-renderer-component-value="#{component_name}")
39
- end
36
+ attrs << %(data-reactive-renderer-component-value="#{component_name}") if component_name
40
37
 
41
- if params
42
- attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}")
43
- end
38
+ attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}") if params
44
39
 
45
40
  if ReactiveComponent.debug
46
41
  debug_label = "#{component_class.name.underscore.humanize} ##{dom_id_val}"
@@ -48,12 +43,12 @@ module ReactiveComponent
48
43
  attrs << %(class="reactive-debug-wrapper")
49
44
  end
50
45
 
51
- %(<div #{attrs.join(" ")}>#{inner_html}</div>).html_safe
46
+ %(<div #{attrs.join(' ')}>#{inner_html}</div>).html_safe
52
47
  end
53
48
 
54
49
  def find_stream_for(component_class, record)
55
50
  config = component_class._broadcast_config
56
- return nil unless config
51
+ return record unless config
57
52
 
58
53
  stream = config[:stream]
59
54
  stream.is_a?(Proc) ? stream.call(record) : stream