reactive_component 0.1.0 → 0.7.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.
@@ -1,31 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "prism"
4
- require "ruby2js"
5
- require "ruby2js/erubi"
6
- require "ruby2js/filter/erb"
7
- require "ruby2js/filter/functions"
8
- require_relative "erb_extractor"
3
+ require 'prism'
4
+ require_relative 'erubi'
5
+ require_relative 'transpiler'
9
6
 
10
7
  module ReactiveComponent
11
8
  module Compiler
12
- ESCAPE_FN_JS = <<~JS.freeze
9
+ ESCAPE_FN_JS = <<~JS
13
10
  function _escape(s) {
14
11
  return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
15
12
  }
13
+ // A variable or property read in `<%= %>` is emitted as escapeHTML(value);
14
+ // every other output is String(value), which `add_html_escaping` wraps.
15
+ function escapeHTML(s) { return _escape(String(s)); }
16
16
  JS
17
17
 
18
- TAG_FN_JS = <<~JS.freeze
19
- function _tag(name, content, attrs) {
20
- let html = '<' + name;
21
- if (attrs) {
22
- for (let [k, v] of Object.entries(attrs)) {
23
- if (v == null || v === false) continue;
24
- if (Array.isArray(v)) v = v.filter(Boolean).join(' ');
25
- html += ' ' + k + '="' + _escape(String(v)) + '"';
18
+ TAG_FN_JS = <<~JS
19
+ function _render_class(v) {
20
+ if (v == null || v === false) return '';
21
+ if (Array.isArray(v)) return v.map(_render_class).filter(Boolean).join(' ');
22
+ if (typeof v === 'object') {
23
+ return Object.entries(v).filter(([, on]) => on).map(([name]) => name).join(' ');
24
+ }
25
+ return String(v);
26
+ }
27
+ function _render_attrs(attrs) {
28
+ if (!attrs) return '';
29
+ let html = '';
30
+ for (let [k, v] of Object.entries(attrs)) {
31
+ if (v == null || v === false) continue;
32
+ if (k === 'class') {
33
+ const cls = _render_class(v);
34
+ if (cls) html += ' class="' + _escape(cls) + '"';
35
+ continue;
26
36
  }
37
+ if (v === true) { html += ' ' + k; continue; }
38
+ if (typeof v === 'object' && !Array.isArray(v)) {
39
+ for (let [dk, dv] of Object.entries(v)) {
40
+ if (dv == null || dv === false) continue;
41
+ const dashKey = String(dk).replace(/_/g, '-');
42
+ if (dv === true) { html += ' ' + k + '-' + dashKey; continue; }
43
+ html += ' ' + k + '-' + dashKey + '="' + _escape(String(dv)) + '"';
44
+ }
45
+ continue;
46
+ }
47
+ if (Array.isArray(v)) v = v.filter(Boolean).join(' ');
48
+ html += ' ' + k + '="' + _escape(String(v)) + '"';
27
49
  }
28
- return html + '>' + _escape(String(content)) + '</' + name + '>';
50
+ return html;
51
+ }
52
+ function _tag(name, content, attrs) {
53
+ return '<' + name + _render_attrs(attrs) + '>' +
54
+ (content != null ? _escape(String(content)) : '') +
55
+ '</' + name + '>';
56
+ }
57
+ function _tag_open(name, attrs) {
58
+ return '<' + name + _render_attrs(attrs) + '>';
59
+ }
60
+ function _tag_close(name) {
61
+ return '</' + name + '>';
29
62
  }
30
63
  JS
31
64
 
@@ -33,7 +66,7 @@ module ReactiveComponent
33
66
 
34
67
  def compile(component_class)
35
68
  erb_source = read_erb(component_class)
36
- erb_ruby = Ruby2JS::Erubi.new(erb_source).src
69
+ erb_ruby = ReactiveComponent::Erubi.new(erb_source).src
37
70
 
38
71
  extraction = { expressions: {}, raw_fields: Set.new }
39
72
 
@@ -43,30 +76,36 @@ module ReactiveComponent
43
76
  # Components with their own model attr are only nestable inside collection loops
44
77
  # (where we can call build_data per item), not as standalone nested components
45
78
  return nil if !inside_block && klass.respond_to?(:_live_model_attr) && klass._live_model_attr
46
- begin; read_erb(klass); klass; rescue; nil; end
79
+
80
+ begin
81
+ read_erb(klass)
82
+ klass
83
+ rescue StandardError
84
+ nil
85
+ end
47
86
  end
48
87
 
49
- js_function = Ruby2JS.convert(
50
- erb_ruby,
51
- filters: [:erb, :functions, ReactiveComponent::ErbExtractor],
52
- eslevel: 2022,
53
- extraction: extraction,
54
- nestable_checker: nestable_checker
55
- ).to_s
88
+ js_function = begin
89
+ ReactiveComponent::Transpiler.call(erb_ruby, extraction: extraction, nestable_checker: nestable_checker)
90
+ rescue ReactiveComponent::CompileError => e
91
+ raise ReactiveComponent::CompileError, "#{component_class.name}: #{e.message}"
92
+ end
56
93
 
57
94
  expressions = extraction[:expressions] || {}
58
95
  raw_fields = extraction[:raw_fields] || Set.new
59
96
  collection_computed = extraction[:collection_computed] || {}
60
97
  nested_components = extraction[:nested_components] || {}
61
98
 
62
- # Simple @ivars not consumed by extraction become JS params directly
63
- all_ivars = extract_ivar_names(erb_ruby)
64
- consumed_ivars = expressions.values
65
- .flat_map { |src| src.scan(/@(\w+)/).flatten }.to_set
66
- simple_ivars = (all_ivars - consumed_ivars).to_a.sort
99
+ # Every @ivar the template mentions needs its stripped form (`initials`
100
+ # for `@initials`) in the JS destructure, because the emitter reads those
101
+ # names directly alongside any extracted-expression vars. Don't subtract
102
+ # ivars that also appear inside extracted expressions — a template that
103
+ # uses both `<%= @initials %>` and `<%= @initials.present? %>` needs
104
+ # both `initials` and `v0` on the data object.
105
+ simple_ivars = extract_ivar_names(erb_ruby).to_a.sort
67
106
 
68
107
  # Compile nested component templates and embed as JS functions
69
- nested_functions_js = ""
108
+ nested_functions_js = ''
70
109
  embedded_classes = Set.new
71
110
 
72
111
  nested_components.each do |key, info|
@@ -84,7 +123,7 @@ module ReactiveComponent
84
123
  child_body = wrap_debug_return(child_body, debug_label)
85
124
  end
86
125
  nested_functions_js += "function _render_#{key}(data) {\n"
87
- nested_functions_js += child_body.gsub(/^/, " ") + "\n"
126
+ nested_functions_js += "#{child_body.gsub(/^/, ' ')}\n"
88
127
  nested_functions_js += "}\n"
89
128
  end
90
129
 
@@ -92,8 +131,10 @@ module ReactiveComponent
92
131
  collection_computed.each_value do |cc_info|
93
132
  (cc_info[:expressions] || {}).each_value do |expr_info|
94
133
  next unless expr_info[:nested_component]
134
+
95
135
  nc_class_name = expr_info[:nested_component][:class_name]
96
136
  next if embedded_classes.include?(nc_class_name)
137
+
97
138
  embedded_classes << nc_class_name
98
139
 
99
140
  child_class = nc_class_name.constantize
@@ -110,7 +151,7 @@ module ReactiveComponent
110
151
  child_body = wrap_debug_return(child_body, debug_label)
111
152
  end
112
153
  nested_functions_js += "function _render_#{fn_name}(data) {\n"
113
- nested_functions_js += child_body.gsub(/^/, " ") + "\n"
154
+ nested_functions_js += "#{child_body.gsub(/^/, ' ')}\n"
114
155
  nested_functions_js += "}\n"
115
156
  end
116
157
  end
@@ -118,7 +159,7 @@ module ReactiveComponent
118
159
  fields = (expressions.keys + simple_ivars + nested_components.keys).uniq.sort
119
160
  parent_raw_body = strip_function_wrapper(js_function)
120
161
  js_body = "#{ESCAPE_FN_JS}#{TAG_FN_JS}#{nested_functions_js}"
121
- js_body += "let { #{fields.join(", ")} } = data;\n"
162
+ js_body += "let { #{fields.join(', ')} } = data;\n"
122
163
  js_body += add_html_escaping(parent_raw_body, raw_fields)
123
164
 
124
165
  {
@@ -149,15 +190,17 @@ module ReactiveComponent
149
190
  collection_computed = compiled[:collection_computed] || {}
150
191
 
151
192
  compiled[:expressions].each do |var_name, ruby_source|
152
- data[var_name] = if collection_computed.key?(var_name)
153
- evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
154
- else
155
- evaluator.evaluate(ruby_source)
156
- end
193
+ value = if collection_computed.key?(var_name)
194
+ evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
195
+ else
196
+ evaluator.evaluate(ruby_source)
197
+ end
198
+ data[var_name] = ReactiveComponent.sanitize_for_broadcast(value, source: ruby_source)
157
199
  end
158
200
 
159
201
  compiled[:simple_ivars].each do |ivar_name|
160
- data[ivar_name] = kwargs[ivar_name.to_sym] if kwargs.key?(ivar_name.to_sym)
202
+ value = kwargs.key?(ivar_name.to_sym) ? kwargs[ivar_name.to_sym] : evaluator.evaluate("@#{ivar_name}")
203
+ data[ivar_name] = ReactiveComponent.sanitize_for_broadcast(value, source: "@#{ivar_name}")
161
204
  end
162
205
 
163
206
  data
@@ -167,44 +210,59 @@ module ReactiveComponent
167
210
  result = Prism.parse(erb_ruby)
168
211
  ivars = Set.new
169
212
  walk(result.value) do |node|
170
- ivars << node.name.to_s.delete_prefix("@") if node.is_a?(Prism::InstanceVariableReadNode)
213
+ ivars << node.name.to_s.delete_prefix('@') if node.is_a?(Prism::InstanceVariableReadNode)
171
214
  end
172
215
  ivars
173
216
  end
174
217
 
175
218
  def read_erb(component_class)
176
- erb_path = component_class.instance_method(:initialize)
177
- .source_location&.first
178
- &.sub(/\.rb$/, ".html.erb")
219
+ rb_path = component_class.instance_method(:initialize).source_location&.first
220
+ raise ArgumentError, "Cannot find source file for #{component_class}" unless rb_path
179
221
 
180
- raise ArgumentError, "Cannot find ERB template for #{component_class}" unless erb_path && File.exist?(erb_path)
222
+ erb_path = erb_path_for(rb_path)
223
+ raise ArgumentError, "Cannot find ERB template for #{component_class}" unless erb_path
181
224
 
182
225
  File.read(erb_path)
183
226
  end
184
227
 
228
+ # ViewComponent supports both flat (`foo_component.html.erb`) and sidecar
229
+ # (`foo_component/foo_component.html.erb`) template layouts. Try both.
230
+ def erb_path_for(rb_path)
231
+ flat = rb_path.sub(/\.rb\z/, '.html.erb')
232
+ return flat if File.exist?(flat)
233
+
234
+ base = File.basename(rb_path, '.rb')
235
+ sidecar = File.join(File.dirname(rb_path), base, "#{base}.html.erb")
236
+ return sidecar if File.exist?(sidecar)
237
+
238
+ nil
239
+ end
240
+
185
241
  def strip_function_wrapper(js_function)
186
242
  js_function
187
- .sub(/\Afunction render\(\{[^}]*\}\) \{\n?/, "")
188
- .sub(/\}\s*\z/, "")
189
- .gsub(/^ /, "")
243
+ .sub(/\Afunction render\(\{[^}]*\}\) \{\n?/, '')
244
+ .sub(/\}\s*\z/, '')
245
+ .gsub(/^ /, '')
190
246
  end
191
247
 
192
248
  def unwrap_function(js_function, fields, raw_fields, include_helpers: true)
193
249
  body = strip_function_wrapper(js_function)
194
- destructure = "let { #{fields.join(", ")} } = data;\n"
250
+ destructure = "let { #{fields.join(', ')} } = data;\n"
195
251
  escaped_body = add_html_escaping(body, raw_fields)
196
- helpers = include_helpers ? "#{ESCAPE_FN_JS}#{TAG_FN_JS}" : ""
252
+ helpers = include_helpers ? "#{ESCAPE_FN_JS}#{TAG_FN_JS}" : ''
197
253
  "#{helpers}#{destructure}#{escaped_body}"
198
254
  end
199
255
 
200
256
  def wrap_debug_return(body, label)
201
- wrapper = "return '<div data-reactive-debug=\"#{label}' + (data.dom_id ? ' #' + data.dom_id : '') + '\" class=\"reactive-debug-wrapper\">' + _buf + '</div>';"
257
+ wrapper = "return '<div data-reactive-debug=\"#{label}'" \
258
+ "+ (data.dom_id ? ' #' + data.dom_id : '')" \
259
+ "+ '\" class=\"reactive-debug-wrapper\">' + _buf + '</div>';"
202
260
  body.sub(/return _buf\s*\z/, wrapper)
203
261
  end
204
262
 
205
263
  def add_html_escaping(body, raw_fields)
206
264
  body.gsub(/\+= String\((.+?)\);/) do
207
- expr = $1
265
+ expr = ::Regexp.last_match(1)
208
266
  if raw_fields.include?(expr)
209
267
  "+= #{expr};"
210
268
  else
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action_view"
4
- require "action_view/record_identifier"
3
+ require 'action_view'
4
+ require 'action_view/record_identifier'
5
5
 
6
6
  module ReactiveComponent
7
7
  class DataEvaluator
@@ -10,14 +10,15 @@ module ReactiveComponent
10
10
  include ActionView::Helpers::NumberHelper
11
11
  include ActionView::Helpers::TagHelper
12
12
  include ActionView::Helpers::OutputSafetyHelper
13
+ include ActionView::Helpers::TranslationHelper
13
14
  include ActionView::RecordIdentifier
14
15
  include ActionView::Helpers::UrlHelper
15
16
 
16
17
  def self.inherited(subclass)
17
18
  super
18
- if defined?(Rails) && Rails.application
19
- subclass.include Rails.application.routes.url_helpers
20
- end
19
+ return unless defined?(Rails) && Rails.application
20
+
21
+ subclass.include Rails.application.routes.url_helpers
21
22
  end
22
23
 
23
24
  def self.finalize!
@@ -28,32 +29,42 @@ module ReactiveComponent
28
29
  instance_variable_set(:"@#{model_attr}", record) if model_attr
29
30
  kwargs.each { |k, v| instance_variable_set(:"@#{k}", v) }
30
31
 
31
- if component_class
32
- begin
33
- constructor_args = model_attr ? { model_attr => record }.merge(kwargs) : kwargs
34
- instance = component_class.new(**constructor_args)
35
- @component_delegate = instance
36
- instance.instance_variables.each do |ivar|
37
- next if (model_attr && ivar == :"@#{model_attr}") || instance_variable_defined?(ivar)
38
- instance_variable_set(ivar, instance.instance_variable_get(ivar))
39
- end
40
- rescue
41
- @component_delegate = component_class.allocate
32
+ return unless component_class
33
+
34
+ begin
35
+ constructor_args = model_attr ? { model_attr => record }.merge(kwargs) : kwargs
36
+ instance = component_class.new(**constructor_args)
37
+ @component_delegate = instance
38
+ instance.instance_variables.each do |ivar|
39
+ next if (model_attr && ivar == :"@#{model_attr}") || instance_variable_defined?(ivar)
40
+
41
+ instance_variable_set(ivar, instance.instance_variable_get(ivar))
42
42
  end
43
+ rescue StandardError
44
+ @component_delegate = component_class.allocate
43
45
  end
44
46
  end
45
47
 
46
48
  def evaluate(ruby_source)
47
49
  instance_eval(ruby_source)
48
50
  rescue NameError
49
- @component_delegate&.instance_eval(ruby_source) rescue nil
50
- rescue => e
51
+ begin
52
+ @component_delegate&.instance_eval(ruby_source)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ rescue StandardError => e
51
57
  Rails.logger.error "[ReactiveComponent::DataEvaluator] Error evaluating '#{ruby_source}': #{e.message}"
52
58
  nil
53
59
  end
54
60
 
55
61
  def render(renderable, &block)
56
62
  renderer = ReactiveComponent.renderer || ActionController::Base
63
+ if block
64
+ # ViewComponent needs block content set via with_content
65
+ block_result = yield
66
+ renderable.with_content(block_result) if renderable.respond_to?(:with_content)
67
+ end
57
68
  renderer.render(renderable, layout: false)
58
69
  end
59
70
 
@@ -66,9 +77,9 @@ module ReactiveComponent
66
77
  return [] unless collection
67
78
 
68
79
  block_var = computed[:block_var]
69
- eval_context = self
70
80
 
71
81
  lambdas = {}
82
+ typed = {}
72
83
  nested = {}
73
84
  (computed[:expressions] || {}).each do |var_name, info|
74
85
  if info[:nested_component]
@@ -81,13 +92,23 @@ module ReactiveComponent
81
92
  nested[var_name] = { klass: klass, kwargs: kwarg_lambdas }
82
93
  else
83
94
  lambdas[var_name] = eval_lambda(block_var, info[:source])
95
+ typed[var_name] = info[:source] if info[:typed]
84
96
  end
85
97
  end
86
98
 
87
99
  collection.map do |item|
88
100
  result = {}
89
101
  lambdas.each do |var_name, fn|
90
- result[var_name] = fn.call(item).to_s
102
+ value = fn.call(item)
103
+ # Condition fields keep their type — the client tests them for
104
+ # truthiness and "false" is truthy in JS. Output fields stay
105
+ # stringified so nil renders as "" rather than "null". Sanitized
106
+ # here, not only in build_data: the nested-component path skips it.
107
+ result[var_name] = if typed.key?(var_name)
108
+ ReactiveComponent.sanitize_for_broadcast(value, source: typed[var_name])
109
+ else
110
+ value.to_s
111
+ end
91
112
  end
92
113
  nested.each do |var_name, nc_info|
93
114
  kwargs_values = nc_info[:kwargs].transform_values { |fn| fn.call(item) }
@@ -97,10 +118,10 @@ module ReactiveComponent
97
118
  result[var_name] = klass.build_data(record, **kwargs_values)
98
119
  else
99
120
  result[var_name] = if klass.respond_to?(:build_data_for_nested)
100
- klass.build_data_for_nested(**kwargs_values)
101
- else
102
- ReactiveComponent::Compiler.build_data_for_nested(klass, **kwargs_values)
103
- end
121
+ klass.build_data_for_nested(**kwargs_values)
122
+ else
123
+ ReactiveComponent::Compiler.build_data_for_nested(klass, **kwargs_values)
124
+ end
104
125
  end
105
126
  end
106
127
  result
@@ -111,6 +132,10 @@ module ReactiveComponent
111
132
  Rails.application.routes.default_url_options
112
133
  end
113
134
 
135
+ def controller
136
+ nil
137
+ end
138
+
114
139
  def optimize_routes_generation?
115
140
  false
116
141
  end
@@ -118,14 +143,16 @@ module ReactiveComponent
118
143
  private
119
144
 
120
145
  def eval_lambda(block_var, source)
121
- instance_eval("lambda { |#{block_var}| #{source} }")
146
+ # lambda { |<block_var>| <source> }
147
+ instance_eval("lambda { |#{block_var}| #{source} }", __FILE__, __LINE__)
122
148
  rescue NameError
123
- @component_delegate.instance_eval("lambda { |#{block_var}| #{source} }")
149
+ # lambda { |<block_var>| <source> }
150
+ @component_delegate.instance_eval("lambda { |#{block_var}| #{source} }", __FILE__, __LINE__)
124
151
  end
125
152
 
126
- def method_missing(method, *args, **kwargs, &block)
153
+ def method_missing(method, ...)
127
154
  if component_own_method?(method)
128
- @component_delegate.send(method, *args, **kwargs, &block)
155
+ @component_delegate.send(method, ...)
129
156
  else
130
157
  super
131
158
  end
@@ -137,9 +164,10 @@ module ReactiveComponent
137
164
 
138
165
  def component_own_method?(method)
139
166
  return false unless @component_delegate
167
+
140
168
  klass = @component_delegate.class
141
- klass.instance_methods(false).include?(method) ||
142
- klass.private_instance_methods(false).include?(method)
169
+ klass.method_defined?(method, false) ||
170
+ klass.private_method_defined?(method, false)
143
171
  end
144
172
  end
145
173
  end
@@ -6,13 +6,19 @@ module ReactiveComponent
6
6
  class Engine < ::Rails::Engine
7
7
  isolate_namespace ReactiveComponent
8
8
 
9
- initializer "reactive_component.importmap", before: "importmap" do |app|
9
+ initializer 'reactive_component.data_evaluator' do
10
+ ReactiveComponent::DataEvaluator.finalize!
11
+ end
12
+
13
+ initializer 'reactive_component.importmap', before: 'importmap' do |app|
10
14
  if defined?(Importmap)
11
15
  app.config.importmap.paths <<
12
- Engine.root.join("config/importmap.rb")
16
+ Engine.root.join('config/importmap.rb')
13
17
 
14
- app.config.assets.paths <<
15
- Engine.root.join("app/javascript")
18
+ if app.config.respond_to?(:assets)
19
+ app.config.assets.paths <<
20
+ Engine.root.join('app/javascript')
21
+ end
16
22
  end
17
23
  end
18
24
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erubi'
4
+
5
+ module ReactiveComponent
6
+ # ERB → Ruby. Erubi with a `_buf` buffer, plus one rule the stock engine
7
+ # lacks: an expression that opens a block (`<%= render X do %>`) is emitted
8
+ # as `_buf.append= expr do` so the block attaches to the call instead of to
9
+ # a parenthesised `.to_s`. The transpiler recognises both `<<` and `append=`.
10
+ class Erubi < ::Erubi::Engine
11
+ BLOCK_EXPR = /((\s|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
12
+
13
+ def initialize(input, properties = {})
14
+ properties[:bufvar] ||= '_buf'
15
+ properties[:preamble] ||= "#{properties[:bufvar]} = ::String.new;"
16
+ properties[:postamble] ||= "#{properties[:bufvar]}.to_s"
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ def add_expression(_indicator, code)
23
+ if BLOCK_EXPR.match?(code)
24
+ src << " #{@bufvar}.append= " << code
25
+ else
26
+ src << " #{@bufvar} << (" << code << ').to_s;'
27
+ end
28
+ end
29
+ end
30
+ end