funicular 0.2.1 → 0.4.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 +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +2 -2
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +8 -8
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +8 -5
- data/demo/test_router.html +11 -11
- data/demo/tic-tac-toe.html +4 -4
- data/docs/architecture.md +80 -30
- data/lib/funicular/compiler.rb +13 -13
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +3 -0
- data/lib/funicular/ssr.rb +2 -1
- data/lib/funicular/testing/node_runner.rb +1 -1
- data/lib/funicular/vendor/mrbc/VERSION +1 -0
- data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +626 -486
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +675 -449
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +860 -567
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
- data/lib/funicular/version.rb +1 -1
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +25 -25
- data/lib/tasks/funicular.rake +1 -1
- data/minitest/commands_routes_test.rb +97 -0
- data/minitest/compiler_test.rb +195 -0
- data/minitest/configuration_test.rb +64 -0
- data/minitest/dsl_test.rb +237 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +2 -2
- data/minitest/funicular_test.rb +28 -2
- data/minitest/middleware_test.rb +154 -0
- data/minitest/picoruby_helper_test.rb +139 -0
- data/minitest/route_parser_test.rb +139 -0
- data/minitest/schema_test.rb +23 -0
- data/minitest/sig_tags_test.rb +30 -0
- data/minitest/ssr_test.rb +57 -0
- data/minitest/support/rails_stub.rb +59 -0
- data/minitest/test_helper.rb +20 -0
- data/minitest/testing_test.rb +267 -0
- data/minitest/view_context_test.rb +101 -0
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/component.rb +255 -244
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +11 -13
- data/mrblib/form_builder.rb +28 -24
- data/mrblib/funicular.rb +2 -1
- data/mrblib/html_serializer.rb +14 -13
- data/mrblib/http.rb +12 -1
- data/mrblib/patcher.rb +12 -9
- data/mrblib/router.rb +9 -5
- data/mrblib/runtime.rb +28 -0
- data/mrblib/styles.rb +107 -21
- data/mrblib/vdom.rb +90 -9
- data/mrblib/view_context.rb +106 -0
- data/sig/component.rbs +47 -84
- data/sig/form_builder.rbs +2 -1
- data/sig/html_serializer.rbs +2 -1
- data/sig/http.rbs +1 -0
- data/sig/patcher.rbs +1 -1
- data/sig/router.rbs +2 -13
- data/sig/runtime.rbs +13 -0
- data/sig/styles.rbs +19 -7
- data/sig/tags.rbs +54 -0
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +60 -0
- metadata +22 -5
- data/lib/funicular/vendor/picorbc/VERSION +0 -1
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
data/mrblib/debug.rb
CHANGED
|
@@ -152,16 +152,17 @@ module Funicular
|
|
|
152
152
|
|
|
153
153
|
result = {} #: Hash[String, String]
|
|
154
154
|
component.instance_variables.each do |var|
|
|
155
|
-
|
|
156
|
-
next if
|
|
157
|
-
if
|
|
158
|
-
|
|
155
|
+
name = var.to_s
|
|
156
|
+
next if name == '@state'
|
|
157
|
+
next if name.start_with?('@__debug')
|
|
158
|
+
if name == '@vdom' || name == '@child_components'
|
|
159
|
+
result[name] = "<omitted>"
|
|
159
160
|
next
|
|
160
161
|
end
|
|
161
162
|
begin
|
|
162
|
-
result[
|
|
163
|
+
result[name] = component.instance_variable_get(var).inspect
|
|
163
164
|
rescue
|
|
164
|
-
result[
|
|
165
|
+
result[name] = "<error inspecting value>"
|
|
165
166
|
end
|
|
166
167
|
end
|
|
167
168
|
JSON.generate(result)
|
data/mrblib/differ.rb
CHANGED
|
@@ -213,11 +213,13 @@ module Funicular
|
|
|
213
213
|
# If only Proc props changed, update props but skip VDOM rebuild
|
|
214
214
|
unless data_props_changed
|
|
215
215
|
instance.props = new_node.props
|
|
216
|
+
instance.children = new_node.children
|
|
216
217
|
return []
|
|
217
218
|
end
|
|
218
219
|
|
|
219
220
|
old_internal_vdom = instance.vdom
|
|
220
221
|
instance.props = new_node.props
|
|
222
|
+
instance.children = new_node.children
|
|
221
223
|
new_internal_vdom = instance.build_vdom
|
|
222
224
|
internal_patches = diff(old_internal_vdom, new_internal_vdom)
|
|
223
225
|
|
|
@@ -227,7 +229,7 @@ module Funicular
|
|
|
227
229
|
end
|
|
228
230
|
|
|
229
231
|
# Original logic: If props changed, replace the entire component
|
|
230
|
-
if old_node.props != new_node.props
|
|
232
|
+
if old_node.props != new_node.props || old_node.children != new_node.children
|
|
231
233
|
return [[:replace, new_node, old_node]]
|
|
232
234
|
end
|
|
233
235
|
|
data/mrblib/error_boundary.rb
CHANGED
|
@@ -8,12 +8,18 @@ module Funicular
|
|
|
8
8
|
# end
|
|
9
9
|
#
|
|
10
10
|
# With custom fallback:
|
|
11
|
-
# component(ErrorBoundary, fallback: ->(error) { div { "Error: #{error.message}" } }) do
|
|
11
|
+
# component(ErrorBoundary, fallback: ->(h, error) { h.div { "Error: #{error.message}" } }) do
|
|
12
12
|
# component(RiskyComponent)
|
|
13
13
|
# end
|
|
14
14
|
#
|
|
15
15
|
# Props:
|
|
16
|
-
# - fallback: Proc or Method that receives
|
|
16
|
+
# - fallback: Proc or Method that receives (view_context, error) and
|
|
17
|
+
# returns VDOM. NOTE: unlike everywhere else, the fallback cannot use
|
|
18
|
+
# bareword tags. The proc is created in the parent component's scope
|
|
19
|
+
# but runs during the boundary's render, where only the boundary's
|
|
20
|
+
# cursor is active; a bareword tag would dispatch against the parent
|
|
21
|
+
# and raise RenderContextError. Build elements through the view
|
|
22
|
+
# context argument (`h.div { ... }`) instead.
|
|
17
23
|
# - on_error: Optional callback when error is caught (for logging, reporting)
|
|
18
24
|
#
|
|
19
25
|
class ErrorBoundary < Component
|
|
@@ -72,7 +78,7 @@ module Funicular
|
|
|
72
78
|
|
|
73
79
|
def render_fallback
|
|
74
80
|
if props[:fallback]
|
|
75
|
-
result = props[:fallback].call(state[:error])
|
|
81
|
+
result = props[:fallback].call(__view__, state[:error])
|
|
76
82
|
if result.is_a?(VDOM::VNode)
|
|
77
83
|
result
|
|
78
84
|
else
|
|
@@ -100,16 +106,8 @@ module Funicular
|
|
|
100
106
|
end
|
|
101
107
|
|
|
102
108
|
def render_children
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if props[:children_block]
|
|
106
|
-
div(class: 'error-boundary-content') do
|
|
107
|
-
props[:children_block].call
|
|
108
|
-
end
|
|
109
|
-
elsif props[:children]
|
|
110
|
-
props[:children]
|
|
111
|
-
else
|
|
112
|
-
div { "" }
|
|
109
|
+
div(class: 'error-boundary-content') do
|
|
110
|
+
children
|
|
113
111
|
end
|
|
114
112
|
end
|
|
115
113
|
end
|
data/mrblib/form_builder.rb
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
class FormBuilder
|
|
3
|
-
attr_reader :component, :model_key, :options
|
|
3
|
+
attr_reader :component, :view_context, :model_key, :options
|
|
4
4
|
|
|
5
|
-
def initialize(component, model_key, options = {})
|
|
5
|
+
def initialize(component, view_context, model_key, options = {})
|
|
6
6
|
@component = component
|
|
7
|
+
@view_context = view_context
|
|
7
8
|
@model_key = model_key
|
|
8
9
|
@options = options
|
|
9
10
|
# Per-form options win, then the global Funicular.configure_forms config,
|
|
@@ -32,7 +33,8 @@ module Funicular
|
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# Check for errors
|
|
35
|
-
|
|
36
|
+
errors = @component.state[:errors]
|
|
37
|
+
error_message = errors ? errors[field_key.to_sym] : nil
|
|
36
38
|
# errors may be a single message (legacy) or an array of messages
|
|
37
39
|
# (Funicular::Model::Errors#messages). Show the first.
|
|
38
40
|
error_message = error_message.first if error_message.is_a?(Array)
|
|
@@ -63,10 +65,10 @@ module Funicular
|
|
|
63
65
|
attrs[:class] = css_class unless css_class.empty?
|
|
64
66
|
|
|
65
67
|
# Render field + error message
|
|
66
|
-
@
|
|
67
|
-
@
|
|
68
|
+
@view_context.div do
|
|
69
|
+
@view_context.input(attrs)
|
|
68
70
|
if has_error
|
|
69
|
-
@
|
|
71
|
+
@view_context.div(class: @error_class) { error_message }
|
|
70
72
|
end
|
|
71
73
|
end
|
|
72
74
|
end
|
|
@@ -99,7 +101,8 @@ module Funicular
|
|
|
99
101
|
set_nested_value(@model_key, field_key, new_value)
|
|
100
102
|
end
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
errors = @component.state[:errors]
|
|
105
|
+
error_message = errors ? errors[field_key.to_sym] : nil
|
|
103
106
|
# errors may be a single message (legacy) or an array of messages
|
|
104
107
|
# (Funicular::Model::Errors#messages). Show the first.
|
|
105
108
|
error_message = error_message.first if error_message.is_a?(Array)
|
|
@@ -125,10 +128,10 @@ module Funicular
|
|
|
125
128
|
|
|
126
129
|
attrs[:class] = css_class unless css_class.empty?
|
|
127
130
|
|
|
128
|
-
@
|
|
129
|
-
@
|
|
131
|
+
@view_context.div do
|
|
132
|
+
@view_context.textarea(attrs)
|
|
130
133
|
if has_error
|
|
131
|
-
@
|
|
134
|
+
@view_context.div(class: @error_class) { error_message }
|
|
132
135
|
end
|
|
133
136
|
end
|
|
134
137
|
end
|
|
@@ -151,7 +154,7 @@ module Funicular
|
|
|
151
154
|
onchange: on_change
|
|
152
155
|
}.merge(options)
|
|
153
156
|
|
|
154
|
-
@
|
|
157
|
+
@view_context.input(attrs)
|
|
155
158
|
end
|
|
156
159
|
|
|
157
160
|
def select(field_name, choices, options = {})
|
|
@@ -165,7 +168,8 @@ module Funicular
|
|
|
165
168
|
set_nested_value(@model_key, field_key, new_value)
|
|
166
169
|
end
|
|
167
170
|
|
|
168
|
-
|
|
171
|
+
errors = @component.state[:errors]
|
|
172
|
+
error_message = errors ? errors[field_key.to_sym] : nil
|
|
169
173
|
# errors may be a single message (legacy) or an array of messages
|
|
170
174
|
# (Funicular::Model::Errors#messages). Show the first.
|
|
171
175
|
error_message = error_message.first if error_message.is_a?(Array)
|
|
@@ -190,18 +194,18 @@ module Funicular
|
|
|
190
194
|
|
|
191
195
|
attrs[:class] = css_class unless css_class.empty?
|
|
192
196
|
|
|
193
|
-
@
|
|
194
|
-
@
|
|
197
|
+
@view_context.div do
|
|
198
|
+
@view_context.select(attrs) do
|
|
195
199
|
choices.each do |choice|
|
|
196
200
|
option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
|
|
197
201
|
selected = value.to_s == option_value.to_s
|
|
198
|
-
@
|
|
202
|
+
@view_context.option(value: option_value, selected: selected) do
|
|
199
203
|
option_text
|
|
200
204
|
end
|
|
201
205
|
end
|
|
202
206
|
end
|
|
203
207
|
if has_error
|
|
204
|
-
@
|
|
208
|
+
@view_context.div(class: @error_class) { error_message }
|
|
205
209
|
end
|
|
206
210
|
end
|
|
207
211
|
end
|
|
@@ -232,17 +236,17 @@ module Funicular
|
|
|
232
236
|
onchange: on_change
|
|
233
237
|
}.merge(options)
|
|
234
238
|
|
|
235
|
-
@
|
|
239
|
+
@view_context.input(attrs)
|
|
236
240
|
end
|
|
237
241
|
|
|
238
242
|
def submit(label = "Submit", options = {})
|
|
239
243
|
attrs = { type: "submit" }.merge(options)
|
|
240
|
-
@
|
|
244
|
+
@view_context.button(attrs) { label }
|
|
241
245
|
end
|
|
242
246
|
|
|
243
247
|
def label(field_name, text = nil, options = {})
|
|
244
248
|
text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
|
|
245
|
-
@
|
|
249
|
+
@view_context.label(options) { text }
|
|
246
250
|
end
|
|
247
251
|
|
|
248
252
|
private
|
|
@@ -252,10 +256,10 @@ module Funicular
|
|
|
252
256
|
keys = key_path.split('.')
|
|
253
257
|
value = state
|
|
254
258
|
keys.each do |key|
|
|
255
|
-
if value.
|
|
256
|
-
value = value.send(key.to_sym)
|
|
257
|
-
elsif value.is_a?(Hash)
|
|
259
|
+
if value.is_a?(Hash)
|
|
258
260
|
value = value[key.to_sym] || value[key]
|
|
261
|
+
elsif value.respond_to?(:[])
|
|
262
|
+
value = value[key.to_sym]
|
|
259
263
|
else
|
|
260
264
|
value = nil
|
|
261
265
|
end
|
|
@@ -270,12 +274,12 @@ module Funicular
|
|
|
270
274
|
if field_key.include?('.')
|
|
271
275
|
# Complex nested update
|
|
272
276
|
keys = field_key.split('.')
|
|
273
|
-
current_model = @component.state
|
|
277
|
+
current_model = @component.state[model_key.to_sym]
|
|
274
278
|
updated_model = deep_merge_value(current_model, keys, new_value)
|
|
275
279
|
@component.patch(model_key.to_sym => updated_model)
|
|
276
280
|
else
|
|
277
281
|
# Simple update
|
|
278
|
-
current_model = @component.state
|
|
282
|
+
current_model = @component.state[model_key.to_sym]
|
|
279
283
|
if current_model.nil?
|
|
280
284
|
@component.patch(model_key.to_sym => { field_key.to_sym => new_value })
|
|
281
285
|
elsif current_model.is_a?(Hash)
|
data/mrblib/funicular.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Funicular
|
|
|
18
18
|
# Guard against redefinition: when the mrblib runtime is loaded into a
|
|
19
19
|
# CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
|
|
20
20
|
# VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
|
|
21
|
-
VERSION = '0.
|
|
21
|
+
VERSION = '0.4.0' unless Funicular.const_defined?(:VERSION)
|
|
22
22
|
|
|
23
23
|
def self.version
|
|
24
24
|
VERSION
|
|
@@ -185,6 +185,7 @@ module Funicular
|
|
|
185
185
|
# Otherwise, mount single component (backward compatible)
|
|
186
186
|
if component_class
|
|
187
187
|
instance = component_class.new(props)
|
|
188
|
+
instance.runtime = Funicular::Runtime.new(nil)
|
|
188
189
|
server_root = hydrate ? first_element_child(container_element) : nil
|
|
189
190
|
if server_root
|
|
190
191
|
instance.seed_state(window_state)
|
data/mrblib/html_serializer.rb
CHANGED
|
@@ -16,10 +16,14 @@ module Funicular
|
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
# Props that must never be emitted as HTML attributes.
|
|
19
|
-
SKIP_PROPS = %i[ref key
|
|
19
|
+
SKIP_PROPS = %i[ref key]
|
|
20
20
|
|
|
21
|
-
def self.serialize(vnode)
|
|
22
|
-
new.render(vnode)
|
|
21
|
+
def self.serialize(vnode, runtime = nil)
|
|
22
|
+
new(runtime).render(vnode)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(runtime = nil)
|
|
26
|
+
@runtime = runtime
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def render(vnode)
|
|
@@ -46,7 +50,7 @@ module Funicular
|
|
|
46
50
|
tag = element.tag
|
|
47
51
|
attrs = serialize_props(element.props)
|
|
48
52
|
|
|
49
|
-
if VOID_ELEMENTS.include?(tag)
|
|
53
|
+
if VOID_ELEMENTS.include?(tag.downcase)
|
|
50
54
|
"<#{tag}#{attrs}>"
|
|
51
55
|
else
|
|
52
56
|
"<#{tag}#{attrs}>#{render_children(element.children)}</#{tag}>"
|
|
@@ -75,6 +79,8 @@ module Funicular
|
|
|
75
79
|
|
|
76
80
|
def render_component(component_vnode)
|
|
77
81
|
instance = component_vnode.component_class.new(component_vnode.props)
|
|
82
|
+
instance.runtime = component_vnode.runtime || @runtime || Funicular::Runtime.new
|
|
83
|
+
instance.children = component_vnode.children
|
|
78
84
|
component_vnode.instance = instance
|
|
79
85
|
render(instance.build_vdom)
|
|
80
86
|
end
|
|
@@ -83,17 +89,12 @@ module Funicular
|
|
|
83
89
|
parts = [] #: Array[String]
|
|
84
90
|
props.each do |key, value|
|
|
85
91
|
key_str = key.to_s
|
|
86
|
-
|
|
92
|
+
normalized_key = key_str.downcase
|
|
93
|
+
next if SKIP_PROPS.include?(key) || SKIP_PROPS.include?(normalized_key.to_sym)
|
|
87
94
|
# Event handlers are bound on the client, never serialized.
|
|
88
|
-
next if
|
|
89
|
-
|
|
90
|
-
if URL_ATTRIBUTES.include?(key_str) &&
|
|
91
|
-
value.to_s.strip.downcase.start_with?("javascript:")
|
|
92
|
-
# Prevent XSS via javascript: URIs (mirrors Renderer#render_element).
|
|
93
|
-
next
|
|
94
|
-
end
|
|
95
|
+
next if VDOM.blocked_attribute?(normalized_key, value)
|
|
95
96
|
|
|
96
|
-
if BOOLEAN_ATTRIBUTES.include?(
|
|
97
|
+
if BOOLEAN_ATTRIBUTES.include?(normalized_key)
|
|
97
98
|
# Boolean attributes are absent when false/nil, otherwise present.
|
|
98
99
|
next if value.nil? || value.to_s == "false"
|
|
99
100
|
parts << " #{key_str}=\"#{key_str}\""
|
data/mrblib/http.rb
CHANGED
|
@@ -134,6 +134,17 @@ module Funicular
|
|
|
134
134
|
block.call(Response.new(status, data)) if block
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
def parse_response_body(text)
|
|
138
|
+
return nil if text.nil?
|
|
139
|
+
|
|
140
|
+
body = text.to_s
|
|
141
|
+
return nil if body.empty?
|
|
142
|
+
|
|
143
|
+
JSON.parse(body)
|
|
144
|
+
rescue
|
|
145
|
+
body
|
|
146
|
+
end
|
|
147
|
+
|
|
137
148
|
def request(method, url, body, cache: nil, &block)
|
|
138
149
|
if method == "GET" && cache.is_a?(Integer) && cache > 0
|
|
139
150
|
entry = cache_lookup(url)
|
|
@@ -163,7 +174,7 @@ module Funicular
|
|
|
163
174
|
JS.global.fetch(url, options) do |response|
|
|
164
175
|
status = response.status.to_i
|
|
165
176
|
json_text = response.to_binary
|
|
166
|
-
data =
|
|
177
|
+
data = parse_response_body(json_text)
|
|
167
178
|
# @type var status: Integer
|
|
168
179
|
http_response = Response.new(status, data)
|
|
169
180
|
|
data/mrblib/patcher.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
module VDOM
|
|
3
3
|
class Patcher
|
|
4
|
-
def initialize(doc = nil)
|
|
4
|
+
def initialize(doc = nil, runtime = nil)
|
|
5
5
|
@doc = doc || JS.document
|
|
6
|
+
@runtime = runtime
|
|
6
7
|
end
|
|
7
8
|
|
|
8
9
|
def apply(element, patches)
|
|
@@ -28,7 +29,7 @@ module Funicular
|
|
|
28
29
|
old_dom_element = instance.dom_element
|
|
29
30
|
|
|
30
31
|
# Apply internal patches and get the potentially new root element
|
|
31
|
-
new_dom_element = Patcher.new(@doc).apply(old_dom_element, internal_patches)
|
|
32
|
+
new_dom_element = Patcher.new(@doc, instance.runtime).apply(old_dom_element, internal_patches)
|
|
32
33
|
|
|
33
34
|
# Update the instance's reference to its root DOM element if it changed
|
|
34
35
|
if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
|
|
@@ -159,9 +160,10 @@ module Funicular
|
|
|
159
160
|
|
|
160
161
|
props_patch.each do |key, value|
|
|
161
162
|
key_str = key.to_s
|
|
163
|
+
normalized_key = key_str.downcase
|
|
162
164
|
|
|
163
165
|
# Skip event handlers (handled by bind_events)
|
|
164
|
-
next if
|
|
166
|
+
next if VDOM.event_attribute?(normalized_key)
|
|
165
167
|
|
|
166
168
|
# Skip updating value for focused input/textarea elements
|
|
167
169
|
if key_str == "value"
|
|
@@ -178,13 +180,13 @@ module Funicular
|
|
|
178
180
|
end
|
|
179
181
|
|
|
180
182
|
# Block javascript: URIs in URL attributes
|
|
181
|
-
if
|
|
183
|
+
if VDOM.blocked_attribute?(normalized_key, value)
|
|
182
184
|
puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
|
|
183
185
|
next
|
|
184
186
|
end
|
|
185
187
|
|
|
186
188
|
# Handle boolean attributes
|
|
187
|
-
if BOOLEAN_ATTRIBUTES.include?(
|
|
189
|
+
if BOOLEAN_ATTRIBUTES.include?(normalized_key)
|
|
188
190
|
if value.nil? || value.to_s == "false"
|
|
189
191
|
element.removeAttribute(key_str)
|
|
190
192
|
element[key_str] = false
|
|
@@ -225,14 +227,15 @@ module Funicular
|
|
|
225
227
|
element = @doc.createElement(vnode.tag)
|
|
226
228
|
vnode.props.each do |key, value|
|
|
227
229
|
key_str = key.to_s
|
|
228
|
-
|
|
229
|
-
if
|
|
230
|
+
normalized_key = key_str.downcase
|
|
231
|
+
next if VDOM.event_attribute?(normalized_key)
|
|
232
|
+
if VDOM.blocked_attribute?(normalized_key, value)
|
|
230
233
|
puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
|
|
231
234
|
next
|
|
232
235
|
end
|
|
233
236
|
if key_str == "value" && (vnode.tag == "input" || vnode.tag == "textarea")
|
|
234
237
|
element[:value] = value.to_s
|
|
235
|
-
elsif BOOLEAN_ATTRIBUTES.include?(
|
|
238
|
+
elsif BOOLEAN_ATTRIBUTES.include?(normalized_key)
|
|
236
239
|
if value.nil? || value.to_s == "false"
|
|
237
240
|
element[key_str] = false
|
|
238
241
|
else
|
|
@@ -263,7 +266,7 @@ module Funicular
|
|
|
263
266
|
element
|
|
264
267
|
when :component
|
|
265
268
|
raise "Expected Component vnode" unless vnode.is_a?(Component)
|
|
266
|
-
Renderer.new(@doc).render(vnode, nil)
|
|
269
|
+
Renderer.new(@doc, vnode.runtime || @runtime).render(vnode, nil)
|
|
267
270
|
else
|
|
268
271
|
raise "Unknown vnode type: #{vnode.type}"
|
|
269
272
|
end
|
data/mrblib/router.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
class Router
|
|
3
|
-
attr_reader :routes, :current_component, :current_path
|
|
3
|
+
attr_reader :routes, :current_component, :current_path, :url_helpers, :route_helpers
|
|
4
4
|
|
|
5
5
|
def initialize(container)
|
|
6
6
|
@container = container
|
|
@@ -10,7 +10,9 @@ module Funicular
|
|
|
10
10
|
@current_path = nil
|
|
11
11
|
@popstate_callback_id = nil
|
|
12
12
|
@url_helpers = Module.new
|
|
13
|
-
|
|
13
|
+
@route_helpers = Object.new
|
|
14
|
+
@route_helpers.extend(@url_helpers)
|
|
15
|
+
@runtime = Funicular::Runtime.new(self)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
# Rails-style DSL methods
|
|
@@ -133,6 +135,7 @@ module Funicular
|
|
|
133
135
|
# Mount new component
|
|
134
136
|
@current_path = path
|
|
135
137
|
@current_component = component_class.new(params)
|
|
138
|
+
@current_component.runtime = @runtime
|
|
136
139
|
# @type ivar @current_component: Funicular::Component
|
|
137
140
|
|
|
138
141
|
server_root = hydrate_now ? Funicular.first_element_child(@container) : nil
|
|
@@ -147,6 +150,7 @@ module Funicular
|
|
|
147
150
|
puts "[Funicular] Hydration failed, falling back to full render: #{e.message}"
|
|
148
151
|
@container[:innerHTML] = ''
|
|
149
152
|
@current_component = component_class.new(params)
|
|
153
|
+
@current_component.runtime = @runtime
|
|
150
154
|
end
|
|
151
155
|
end
|
|
152
156
|
|
|
@@ -194,19 +198,19 @@ module Funicular
|
|
|
194
198
|
if param_names.empty?
|
|
195
199
|
# No parameters - return static path
|
|
196
200
|
@url_helpers.module_eval do
|
|
197
|
-
define_method(helper_method_name) do
|
|
201
|
+
define_method(helper_method_name) do
|
|
198
202
|
path_pattern
|
|
199
203
|
end
|
|
200
204
|
end
|
|
201
205
|
else
|
|
202
206
|
# With parameters
|
|
203
207
|
@url_helpers.module_eval do
|
|
204
|
-
define_method(helper_method_name) do |*args|
|
|
208
|
+
define_method(helper_method_name) do |*args|
|
|
205
209
|
# Handle model objects with id method
|
|
206
210
|
if args.length == 1 && args[0].respond_to?(:id) && param_names.length == 1
|
|
207
211
|
args = [args[0].id]
|
|
208
212
|
elsif args.length != param_names.length
|
|
209
|
-
raise ArgumentError, "#{helper_method_name} expects #{param_names.length} argument(s), got #{args.length}"
|
|
213
|
+
Kernel.raise ArgumentError, "#{helper_method_name} expects #{param_names.length} argument(s), got #{args.length}"
|
|
210
214
|
end
|
|
211
215
|
|
|
212
216
|
result = path_pattern.dup
|
data/mrblib/runtime.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class Runtime
|
|
3
|
+
attr_accessor :router
|
|
4
|
+
|
|
5
|
+
def initialize(router = nil)
|
|
6
|
+
@router = router
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def routes
|
|
10
|
+
router = @router
|
|
11
|
+
if router
|
|
12
|
+
router.route_helpers
|
|
13
|
+
else
|
|
14
|
+
EmptyRoutes
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module EmptyRoutes
|
|
20
|
+
def self.method_missing(name, *args)
|
|
21
|
+
raise NoMethodError, "undefined route helper #{name}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.respond_to_missing?(name, include_private = false)
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/mrblib/styles.rb
CHANGED
|
@@ -25,28 +25,45 @@ module Funicular
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
class
|
|
28
|
+
# Read-side accessor. One subclass is generated per component class with a
|
|
29
|
+
# real method per declared style name (see accessor_for), so lookups never
|
|
30
|
+
# go through method_missing at render time and a typo raises loudly.
|
|
31
|
+
# BasicObject keeps Object/Kernel names (display, hash, ...) usable as
|
|
32
|
+
# style names on both mruby and CRuby.
|
|
33
|
+
class StyleAccessor < BasicObject
|
|
34
|
+
def self.accessor_for(definitions)
|
|
35
|
+
klass = ::Class.new(self) #: singleton(StyleAccessor)
|
|
36
|
+
definitions.each_key do |name|
|
|
37
|
+
klass.__send__(:define_method, name) do |variant = nil|
|
|
38
|
+
self[name, variant] # steep:ignore
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
klass
|
|
42
|
+
end
|
|
43
|
+
|
|
29
44
|
def initialize(definitions)
|
|
30
45
|
@definitions = definitions
|
|
31
46
|
end
|
|
32
47
|
|
|
33
|
-
def
|
|
48
|
+
def [](name, variant = nil)
|
|
34
49
|
style = @definitions[name]
|
|
35
|
-
|
|
50
|
+
unless style
|
|
51
|
+
::Kernel.raise ::ArgumentError,
|
|
52
|
+
"unknown style :#{name} (declared: #{@definitions.keys.map { |k| ":#{k}" }.join(', ')})"
|
|
53
|
+
end
|
|
36
54
|
|
|
37
|
-
if
|
|
55
|
+
if variant.nil?
|
|
38
56
|
# No arguments: return base or value
|
|
39
57
|
StyleValue.new(style[:base] || style[:value] || "")
|
|
40
|
-
elsif
|
|
58
|
+
elsif variant == true || variant == false
|
|
41
59
|
# Boolean argument: base + active (if true)
|
|
42
|
-
# JS::Object#== now supports direct comparison with Ruby true/false
|
|
43
60
|
base = style[:base] || ""
|
|
44
|
-
active_class = (
|
|
61
|
+
active_class = (variant == true) ? (style[:active] || "") : ""
|
|
45
62
|
StyleValue.new("#{base} #{active_class}".strip)
|
|
46
|
-
elsif
|
|
63
|
+
elsif ::Symbol === variant
|
|
47
64
|
# Symbol argument: base + variants[symbol]
|
|
48
65
|
base = style[:base] || ""
|
|
49
|
-
variant_class = style[:variants] ? (style[:variants][
|
|
66
|
+
variant_class = style[:variants] ? (style[:variants][variant] || "") : ""
|
|
50
67
|
StyleValue.new("#{base} #{variant_class}".strip)
|
|
51
68
|
else
|
|
52
69
|
# Other types: just return base
|
|
@@ -54,30 +71,99 @@ module Funicular
|
|
|
54
71
|
end
|
|
55
72
|
end
|
|
56
73
|
|
|
57
|
-
def
|
|
58
|
-
|
|
74
|
+
def method_missing(name, *_args)
|
|
75
|
+
::Kernel.raise ::NoMethodError,
|
|
76
|
+
"unknown style '#{name}' (declared: #{@definitions.keys.map { |k| ":#{k}" }.join(', ')})"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def respond_to_missing?(name, _include_private = false)
|
|
80
|
+
@definitions.key?(name)
|
|
59
81
|
end
|
|
60
82
|
end
|
|
61
83
|
|
|
62
|
-
class
|
|
84
|
+
# Definition-side builder. The class-level `styles do ... end` block is
|
|
85
|
+
# instance_exec'd on an instance, so barewords define styles:
|
|
86
|
+
#
|
|
87
|
+
# styles do
|
|
88
|
+
# shell "app-shell"
|
|
89
|
+
# button base: "btn", variants: { primary: "btn--primary" }
|
|
90
|
+
# end
|
|
91
|
+
#
|
|
92
|
+
# BasicObject keeps the bareword namespace clean on both VMs; the block
|
|
93
|
+
# also receives the builder as an argument for the explicit form
|
|
94
|
+
# `styles { |css| css.define(:name, ...) }`, needed when a value is
|
|
95
|
+
# computed (a bareword helper call inside the block would be captured as
|
|
96
|
+
# a style definition, and its nil return raises via validation below).
|
|
97
|
+
class StyleBuilder < BasicObject
|
|
98
|
+
# Style names that would clobber the builder/accessor internals.
|
|
99
|
+
RESERVED_NAMES = %i[
|
|
100
|
+
initialize method_missing respond_to_missing? define to_definitions
|
|
101
|
+
validate_options
|
|
102
|
+
== != ! [] equal? __send__ __id__ instance_eval instance_exec
|
|
103
|
+
]
|
|
104
|
+
|
|
63
105
|
def initialize
|
|
64
|
-
@definitions = {}
|
|
106
|
+
@definitions = {} #: Hash[Symbol, untyped]
|
|
65
107
|
end
|
|
66
108
|
|
|
67
|
-
def
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
109
|
+
def define(name, value = nil, **options)
|
|
110
|
+
if RESERVED_NAMES.include?(name)
|
|
111
|
+
::Kernel.raise ::ArgumentError, "style name :#{name} is reserved"
|
|
112
|
+
end
|
|
113
|
+
if @definitions.key?(name)
|
|
114
|
+
::Kernel.raise ::ArgumentError, "style :#{name} is already defined"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if ::String === value
|
|
118
|
+
@definitions[name] = { value: value }
|
|
119
|
+
elsif ::Hash === value
|
|
120
|
+
@definitions[name] = validate_options(name, value)
|
|
121
|
+
elsif (::NilClass === value) && !options.empty?
|
|
122
|
+
@definitions[name] = validate_options(name, options)
|
|
74
123
|
else
|
|
75
|
-
raise ArgumentError,
|
|
124
|
+
::Kernel.raise ::ArgumentError,
|
|
125
|
+
"invalid style definition for :#{name}; expected a String, a " \
|
|
126
|
+
"Hash, or keyword options. Note: helper methods cannot be called " \
|
|
127
|
+
"bareword inside a styles block; use " \
|
|
128
|
+
"`styles { |css| css.define(...) }` for computed values."
|
|
129
|
+
end
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_options(name, options)
|
|
134
|
+
options.each do |key, val|
|
|
135
|
+
case key
|
|
136
|
+
when :value, :base, :active
|
|
137
|
+
unless ::String === val
|
|
138
|
+
::Kernel.raise ::ArgumentError,
|
|
139
|
+
"style :#{name} option #{key}: must be a String. Note: helper " \
|
|
140
|
+
"methods cannot be called bareword inside a styles block; use " \
|
|
141
|
+
"`styles { |css| css.define(...) }` for computed values."
|
|
142
|
+
end
|
|
143
|
+
when :variants
|
|
144
|
+
unless (::Hash === val) && val.all? { |_k, v| ::String === v }
|
|
145
|
+
::Kernel.raise ::ArgumentError,
|
|
146
|
+
"style :#{name} option variants: must be a Hash of Symbol => String"
|
|
147
|
+
end
|
|
148
|
+
else
|
|
149
|
+
::Kernel.raise ::ArgumentError,
|
|
150
|
+
"style :#{name} has unknown option #{key.inspect} " \
|
|
151
|
+
"(allowed: value, base, active, variants)"
|
|
152
|
+
end
|
|
76
153
|
end
|
|
154
|
+
options
|
|
77
155
|
end
|
|
78
156
|
|
|
79
157
|
def to_definitions
|
|
80
158
|
@definitions
|
|
81
159
|
end
|
|
160
|
+
|
|
161
|
+
def method_missing(name, value = nil, **options)
|
|
162
|
+
define(name, value, **options)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def respond_to_missing?(_name, _include_private = false)
|
|
166
|
+
true
|
|
167
|
+
end
|
|
82
168
|
end
|
|
83
169
|
end
|