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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -0
- data/app/channels/reactive_component/channel.rb +10 -10
- data/app/controllers/reactive_component/actions_controller.rb +2 -2
- data/app/javascript/reactive_component/controllers/reactive_renderer_controller.js +25 -3
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +34 -0
- data/config/importmap.rb +2 -2
- data/config/routes.rb +1 -1
- data/lib/reactive_component/broadcastable.rb +41 -0
- data/lib/reactive_component/compiler.rb +110 -52
- data/lib/reactive_component/data_evaluator.rb +58 -30
- data/lib/reactive_component/engine.rb +10 -4
- data/lib/reactive_component/erubi.rb +30 -0
- data/lib/reactive_component/transpiler.rb +586 -0
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component/wrapper.rb +7 -12
- data/lib/reactive_component.rb +176 -43
- metadata +35 -25
- data/lib/reactive_component/erb_extractor.rb +0 -610
|
@@ -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
|
|
@@ -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,
|
|
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(
|
|
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
|
|
51
|
+
return record unless config
|
|
57
52
|
|
|
58
53
|
stream = config[:stream]
|
|
59
54
|
stream.is_a?(Proc) ? stream.call(record) : stream
|