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,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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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
|