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.
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
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"
9
+
10
+ module ReactiveComponent
11
+ module Compiler
12
+ ESCAPE_FN_JS = <<~JS.freeze
13
+ function _escape(s) {
14
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
15
+ }
16
+ JS
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)) + '"';
26
+ }
27
+ }
28
+ return html + '>' + _escape(String(content)) + '</' + name + '>';
29
+ }
30
+ JS
31
+
32
+ module_function
33
+
34
+ def compile(component_class)
35
+ erb_source = read_erb(component_class)
36
+ erb_ruby = Ruby2JS::Erubi.new(erb_source).src
37
+
38
+ extraction = { expressions: {}, raw_fields: Set.new }
39
+
40
+ nestable_checker = lambda do |class_name, inside_block: false|
41
+ klass = class_name.safe_constantize
42
+ return nil unless klass
43
+ # Components with their own model attr are only nestable inside collection loops
44
+ # (where we can call build_data per item), not as standalone nested components
45
+ return nil if !inside_block && klass.respond_to?(:_live_model_attr) && klass._live_model_attr
46
+ begin; read_erb(klass); klass; rescue; nil; end
47
+ end
48
+
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
56
+
57
+ expressions = extraction[:expressions] || {}
58
+ raw_fields = extraction[:raw_fields] || Set.new
59
+ collection_computed = extraction[:collection_computed] || {}
60
+ nested_components = extraction[:nested_components] || {}
61
+
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
67
+
68
+ # Compile nested component templates and embed as JS functions
69
+ nested_functions_js = ""
70
+ embedded_classes = Set.new
71
+
72
+ nested_components.each do |key, info|
73
+ child_class = info[:class_name].constantize
74
+ embedded_classes << child_class.name
75
+ child_compiled = compile(child_class)
76
+ child_body = unwrap_function(
77
+ child_compiled[:raw_js_function],
78
+ child_compiled[:fields],
79
+ child_compiled[:raw_fields],
80
+ include_helpers: false
81
+ )
82
+ if ReactiveComponent.debug
83
+ debug_label = info[:class_name].underscore.humanize
84
+ child_body = wrap_debug_return(child_body, debug_label)
85
+ end
86
+ nested_functions_js += "function _render_#{key}(data) {\n"
87
+ nested_functions_js += child_body.gsub(/^/, " ") + "\n"
88
+ nested_functions_js += "}\n"
89
+ end
90
+
91
+ # Embed JS functions for nested components used inside collection loops
92
+ collection_computed.each_value do |cc_info|
93
+ (cc_info[:expressions] || {}).each_value do |expr_info|
94
+ next unless expr_info[:nested_component]
95
+ nc_class_name = expr_info[:nested_component][:class_name]
96
+ next if embedded_classes.include?(nc_class_name)
97
+ embedded_classes << nc_class_name
98
+
99
+ child_class = nc_class_name.constantize
100
+ child_compiled = compile(child_class)
101
+ fn_name = nc_class_name.underscore
102
+ child_body = unwrap_function(
103
+ child_compiled[:raw_js_function],
104
+ child_compiled[:fields],
105
+ child_compiled[:raw_fields],
106
+ include_helpers: false
107
+ )
108
+ if ReactiveComponent.debug
109
+ debug_label = nc_class_name.underscore.humanize
110
+ child_body = wrap_debug_return(child_body, debug_label)
111
+ end
112
+ nested_functions_js += "function _render_#{fn_name}(data) {\n"
113
+ nested_functions_js += child_body.gsub(/^/, " ") + "\n"
114
+ nested_functions_js += "}\n"
115
+ end
116
+ end
117
+
118
+ fields = (expressions.keys + simple_ivars + nested_components.keys).uniq.sort
119
+ parent_raw_body = strip_function_wrapper(js_function)
120
+ js_body = "#{ESCAPE_FN_JS}#{TAG_FN_JS}#{nested_functions_js}"
121
+ js_body += "let { #{fields.join(", ")} } = data;\n"
122
+ js_body += add_html_escaping(parent_raw_body, raw_fields)
123
+
124
+ {
125
+ js_body: js_body,
126
+ fields: fields,
127
+ expressions: expressions,
128
+ simple_ivars: simple_ivars,
129
+ collection_computed: collection_computed,
130
+ nested_components: nested_components,
131
+ raw_js_function: js_function,
132
+ raw_fields: raw_fields
133
+ }
134
+ end
135
+
136
+ def compile_js(component_class)
137
+ compile(component_class)[:js_body]
138
+ end
139
+
140
+ def compiled_data_for(klass)
141
+ @compiled_data_cache ||= {}
142
+ @compiled_data_cache[klass.name] ||= compile(klass)
143
+ end
144
+
145
+ def build_data_for_nested(klass, **kwargs)
146
+ compiled = compiled_data_for(klass)
147
+ evaluator = ReactiveComponent::DataEvaluator.new(nil, nil, component_class: klass, **kwargs)
148
+ data = {}
149
+ collection_computed = compiled[:collection_computed] || {}
150
+
151
+ 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
157
+ end
158
+
159
+ compiled[:simple_ivars].each do |ivar_name|
160
+ data[ivar_name] = kwargs[ivar_name.to_sym] if kwargs.key?(ivar_name.to_sym)
161
+ end
162
+
163
+ data
164
+ end
165
+
166
+ def extract_ivar_names(erb_ruby)
167
+ result = Prism.parse(erb_ruby)
168
+ ivars = Set.new
169
+ walk(result.value) do |node|
170
+ ivars << node.name.to_s.delete_prefix("@") if node.is_a?(Prism::InstanceVariableReadNode)
171
+ end
172
+ ivars
173
+ end
174
+
175
+ def read_erb(component_class)
176
+ erb_path = component_class.instance_method(:initialize)
177
+ .source_location&.first
178
+ &.sub(/\.rb$/, ".html.erb")
179
+
180
+ raise ArgumentError, "Cannot find ERB template for #{component_class}" unless erb_path && File.exist?(erb_path)
181
+
182
+ File.read(erb_path)
183
+ end
184
+
185
+ def strip_function_wrapper(js_function)
186
+ js_function
187
+ .sub(/\Afunction render\(\{[^}]*\}\) \{\n?/, "")
188
+ .sub(/\}\s*\z/, "")
189
+ .gsub(/^ /, "")
190
+ end
191
+
192
+ def unwrap_function(js_function, fields, raw_fields, include_helpers: true)
193
+ body = strip_function_wrapper(js_function)
194
+ destructure = "let { #{fields.join(", ")} } = data;\n"
195
+ escaped_body = add_html_escaping(body, raw_fields)
196
+ helpers = include_helpers ? "#{ESCAPE_FN_JS}#{TAG_FN_JS}" : ""
197
+ "#{helpers}#{destructure}#{escaped_body}"
198
+ end
199
+
200
+ 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>';"
202
+ body.sub(/return _buf\s*\z/, wrapper)
203
+ end
204
+
205
+ def add_html_escaping(body, raw_fields)
206
+ body.gsub(/\+= String\((.+?)\);/) do
207
+ expr = $1
208
+ if raw_fields.include?(expr)
209
+ "+= #{expr};"
210
+ else
211
+ "+= _escape(String(#{expr}));"
212
+ end
213
+ end
214
+ end
215
+
216
+ def walk(node, &block)
217
+ yield node
218
+ node.child_nodes.compact.each { |child| walk(child, &block) }
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require "action_view/record_identifier"
5
+
6
+ module ReactiveComponent
7
+ class DataEvaluator
8
+ include ActionView::Helpers::DateHelper
9
+ include ActionView::Helpers::TextHelper
10
+ include ActionView::Helpers::NumberHelper
11
+ include ActionView::Helpers::TagHelper
12
+ include ActionView::Helpers::OutputSafetyHelper
13
+ include ActionView::RecordIdentifier
14
+ include ActionView::Helpers::UrlHelper
15
+
16
+ def self.inherited(subclass)
17
+ super
18
+ if defined?(Rails) && Rails.application
19
+ subclass.include Rails.application.routes.url_helpers
20
+ end
21
+ end
22
+
23
+ def self.finalize!
24
+ include Rails.application.routes.url_helpers if defined?(Rails) && Rails.application
25
+ end
26
+
27
+ def initialize(model_attr, record, component_class: nil, **kwargs)
28
+ instance_variable_set(:"@#{model_attr}", record) if model_attr
29
+ kwargs.each { |k, v| instance_variable_set(:"@#{k}", v) }
30
+
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
42
+ end
43
+ end
44
+ end
45
+
46
+ def evaluate(ruby_source)
47
+ instance_eval(ruby_source)
48
+ rescue NameError
49
+ @component_delegate&.instance_eval(ruby_source) rescue nil
50
+ rescue => e
51
+ Rails.logger.error "[ReactiveComponent::DataEvaluator] Error evaluating '#{ruby_source}': #{e.message}"
52
+ nil
53
+ end
54
+
55
+ def render(renderable, &block)
56
+ renderer = ReactiveComponent.renderer || ActionController::Base
57
+ renderer.render(renderable, layout: false)
58
+ end
59
+
60
+ def evaluate_collection(ruby_source, computed)
61
+ collection = begin
62
+ instance_eval(ruby_source)
63
+ rescue NameError
64
+ @component_delegate&.instance_eval(ruby_source)
65
+ end
66
+ return [] unless collection
67
+
68
+ block_var = computed[:block_var]
69
+ eval_context = self
70
+
71
+ lambdas = {}
72
+ nested = {}
73
+ (computed[:expressions] || {}).each do |var_name, info|
74
+ if info[:nested_component]
75
+ nc = info[:nested_component]
76
+ klass = nc[:class_name].constantize
77
+ kwarg_lambdas = {}
78
+ nc[:kwargs].each do |kw_name, kw_source|
79
+ kwarg_lambdas[kw_name.to_sym] = eval_lambda(block_var, kw_source)
80
+ end
81
+ nested[var_name] = { klass: klass, kwargs: kwarg_lambdas }
82
+ else
83
+ lambdas[var_name] = eval_lambda(block_var, info[:source])
84
+ end
85
+ end
86
+
87
+ collection.map do |item|
88
+ result = {}
89
+ lambdas.each do |var_name, fn|
90
+ result[var_name] = fn.call(item).to_s
91
+ end
92
+ nested.each do |var_name, nc_info|
93
+ kwargs_values = nc_info[:kwargs].transform_values { |fn| fn.call(item) }
94
+ klass = nc_info[:klass]
95
+ if klass.respond_to?(:live_model_attr) && klass.live_model_attr
96
+ record = kwargs_values.delete(klass.live_model_attr)
97
+ result[var_name] = klass.build_data(record, **kwargs_values)
98
+ else
99
+ 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
104
+ end
105
+ end
106
+ result
107
+ end
108
+ end
109
+
110
+ def default_url_options
111
+ Rails.application.routes.default_url_options
112
+ end
113
+
114
+ def optimize_routes_generation?
115
+ false
116
+ end
117
+
118
+ private
119
+
120
+ def eval_lambda(block_var, source)
121
+ instance_eval("lambda { |#{block_var}| #{source} }")
122
+ rescue NameError
123
+ @component_delegate.instance_eval("lambda { |#{block_var}| #{source} }")
124
+ end
125
+
126
+ def method_missing(method, *args, **kwargs, &block)
127
+ if component_own_method?(method)
128
+ @component_delegate.send(method, *args, **kwargs, &block)
129
+ else
130
+ super
131
+ end
132
+ end
133
+
134
+ def respond_to_missing?(method, include_private = false)
135
+ component_own_method?(method) || super
136
+ end
137
+
138
+ def component_own_method?(method)
139
+ return false unless @component_delegate
140
+ klass = @component_delegate.class
141
+ klass.instance_methods(false).include?(method) ||
142
+ klass.private_instance_methods(false).include?(method)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file should be required from the main lib/reactive_component.rb module file.
4
+
5
+ module ReactiveComponent
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace ReactiveComponent
8
+
9
+ initializer "reactive_component.importmap", before: "importmap" do |app|
10
+ if defined?(Importmap)
11
+ app.config.importmap.paths <<
12
+ Engine.root.join("config/importmap.rb")
13
+
14
+ app.config.assets.paths <<
15
+ Engine.root.join("app/javascript")
16
+ end
17
+ end
18
+ end
19
+ end