funicular 0.2.0 → 0.3.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 +54 -0
- data/README.md +5 -5
- data/Rakefile +23 -15
- data/demo/test_chartjs.html +17 -17
- data/demo/test_component.html +15 -15
- data/demo/test_error_boundary.html +41 -41
- data/demo/test_router.html +57 -57
- data/demo/tic-tac-toe.html +29 -29
- data/docs/architecture.md +43 -30
- data/lib/funicular/compiler.rb +15 -16
- data/lib/funicular/helpers/picoruby_helper.rb +24 -1
- data/lib/funicular/ssr/runtime.rb +2 -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} +14 -1
- 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 +38 -23
- 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 +1 -1
- 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 +47 -23
- 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 +47 -46
- 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/fixtures/funicular_app/components/greeting_component.rb +6 -6
- data/minitest/form_for_test.rb +106 -0
- data/minitest/funicular_test.rb +28 -2
- data/minitest/hydration_test.rb +2 -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/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/component.rb +210 -229
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +19 -27
- data/mrblib/form_builder.rb +34 -24
- data/mrblib/funicular.rb +2 -1
- data/mrblib/html_serializer.rb +14 -13
- data/mrblib/http.rb +12 -1
- data/mrblib/patcher.rb +16 -9
- data/mrblib/router.rb +9 -5
- data/mrblib/runtime.rb +28 -0
- data/mrblib/styles.rb +13 -17
- data/mrblib/vdom.rb +92 -9
- data/mrblib/view_context.rb +135 -0
- data/sig/component.rbs +34 -85
- data/sig/error_boundary.rbs +4 -4
- 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 +3 -4
- data/sig/vdom.rbs +11 -2
- data/sig/view_context.rbs +47 -0
- metadata +19 -5
- data/lib/funicular/vendor/picorbc/VERSION +0 -1
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
data/mrblib/error_boundary.rb
CHANGED
|
@@ -3,13 +3,13 @@ module Funicular
|
|
|
3
3
|
# a fallback UI instead of crashing the entire application.
|
|
4
4
|
#
|
|
5
5
|
# Usage:
|
|
6
|
-
# component(ErrorBoundary) do
|
|
7
|
-
# component(RiskyComponent)
|
|
6
|
+
# h.component(ErrorBoundary) do |hh|
|
|
7
|
+
# hh.component(RiskyComponent)
|
|
8
8
|
# end
|
|
9
9
|
#
|
|
10
10
|
# With custom fallback:
|
|
11
|
-
# component(ErrorBoundary, fallback: ->(error) { div { "Error: #{error.message}" } }) do
|
|
12
|
-
# component(RiskyComponent)
|
|
11
|
+
# h.component(ErrorBoundary, fallback: ->(h, error) { h.div { "Error: #{error.message}" } }) do |hh|
|
|
12
|
+
# hh.component(RiskyComponent)
|
|
13
13
|
# end
|
|
14
14
|
#
|
|
15
15
|
# Props:
|
|
@@ -60,56 +60,48 @@ module Funicular
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
def render
|
|
63
|
+
def render(h)
|
|
64
64
|
if state[:has_error]
|
|
65
|
-
render_fallback
|
|
65
|
+
render_fallback(h)
|
|
66
66
|
else
|
|
67
|
-
render_children
|
|
67
|
+
render_children(h)
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
private
|
|
72
72
|
|
|
73
|
-
def render_fallback
|
|
73
|
+
def render_fallback(h)
|
|
74
74
|
if props[:fallback]
|
|
75
|
-
result = props[:fallback].call(state[:error])
|
|
75
|
+
result = props[:fallback].call(h, state[:error])
|
|
76
76
|
if result.is_a?(VDOM::VNode)
|
|
77
77
|
result
|
|
78
78
|
else
|
|
79
|
-
div { result.to_s }
|
|
79
|
+
h.div { result.to_s }
|
|
80
80
|
end
|
|
81
81
|
else
|
|
82
|
-
default_fallback
|
|
82
|
+
default_fallback(h)
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
def default_fallback
|
|
87
|
-
div(class: 'error-boundary-fallback', style: 'padding: 20px; background: #fee; border: 1px solid #f00; border-radius: 4px;') do
|
|
88
|
-
h3(style: 'color: #c00; margin: 0 0 10px 0;') { "Something went wrong" }
|
|
86
|
+
def default_fallback(h)
|
|
87
|
+
h.div(class: 'error-boundary-fallback', style: 'padding: 20px; background: #fee; border: 1px solid #f00; border-radius: 4px;') do |hh|
|
|
88
|
+
hh.h3(style: 'color: #c00; margin: 0 0 10px 0;') { "Something went wrong" }
|
|
89
89
|
if state[:error]
|
|
90
|
-
div(style: 'font-family: monospace; white-space: pre-wrap; font-size: 12px; color: #600;') do
|
|
90
|
+
hh.div(style: 'font-family: monospace; white-space: pre-wrap; font-size: 12px; color: #600;') do
|
|
91
91
|
"#{state[:error].class}: #{state[:error].message}"
|
|
92
92
|
end
|
|
93
93
|
end
|
|
94
94
|
if Funicular.env.development? && state[:error_info]
|
|
95
|
-
div(style: 'margin-top: 10px; font-size: 11px; color: #666;') do
|
|
95
|
+
hh.div(style: 'margin-top: 10px; font-size: 11px; color: #666;') do
|
|
96
96
|
"Component: #{state[:error_info][:component_class]}"
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
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 { "" }
|
|
102
|
+
def render_children(h)
|
|
103
|
+
h.div(class: 'error-boundary-content') do
|
|
104
|
+
children
|
|
113
105
|
end
|
|
114
106
|
end
|
|
115
107
|
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)
|
|
@@ -53,6 +55,7 @@ module Funicular
|
|
|
53
55
|
|
|
54
56
|
# Build field attributes
|
|
55
57
|
attrs = {
|
|
58
|
+
name: field_options[:name] || field_key,
|
|
56
59
|
type: field_type,
|
|
57
60
|
value: value,
|
|
58
61
|
oninput: on_input
|
|
@@ -62,10 +65,10 @@ module Funicular
|
|
|
62
65
|
attrs[:class] = css_class unless css_class.empty?
|
|
63
66
|
|
|
64
67
|
# Render field + error message
|
|
65
|
-
@
|
|
66
|
-
|
|
68
|
+
@view_context.div do |h|
|
|
69
|
+
h.input(attrs)
|
|
67
70
|
if has_error
|
|
68
|
-
|
|
71
|
+
h.div(class: @error_class) { error_message }
|
|
69
72
|
end
|
|
70
73
|
end
|
|
71
74
|
end
|
|
@@ -98,7 +101,8 @@ module Funicular
|
|
|
98
101
|
set_nested_value(@model_key, field_key, new_value)
|
|
99
102
|
end
|
|
100
103
|
|
|
101
|
-
|
|
104
|
+
errors = @component.state[:errors]
|
|
105
|
+
error_message = errors ? errors[field_key.to_sym] : nil
|
|
102
106
|
# errors may be a single message (legacy) or an array of messages
|
|
103
107
|
# (Funicular::Model::Errors#messages). Show the first.
|
|
104
108
|
error_message = error_message.first if error_message.is_a?(Array)
|
|
@@ -117,15 +121,17 @@ module Funicular
|
|
|
117
121
|
end
|
|
118
122
|
|
|
119
123
|
attrs = {
|
|
124
|
+
name: options[:name] || field_key,
|
|
125
|
+
value: value,
|
|
120
126
|
oninput: on_input
|
|
121
127
|
}.merge(options.reject { |k, _| k == :class })
|
|
122
128
|
|
|
123
129
|
attrs[:class] = css_class unless css_class.empty?
|
|
124
130
|
|
|
125
|
-
@
|
|
126
|
-
|
|
131
|
+
@view_context.div do |h|
|
|
132
|
+
h.textarea(attrs)
|
|
127
133
|
if has_error
|
|
128
|
-
|
|
134
|
+
h.div(class: @error_class) { error_message }
|
|
129
135
|
end
|
|
130
136
|
end
|
|
131
137
|
end
|
|
@@ -142,12 +148,13 @@ module Funicular
|
|
|
142
148
|
end
|
|
143
149
|
|
|
144
150
|
attrs = {
|
|
151
|
+
name: options[:name] || field_key,
|
|
145
152
|
type: "checkbox",
|
|
146
153
|
checked: value,
|
|
147
154
|
onchange: on_change
|
|
148
155
|
}.merge(options)
|
|
149
156
|
|
|
150
|
-
@
|
|
157
|
+
@view_context.input(attrs)
|
|
151
158
|
end
|
|
152
159
|
|
|
153
160
|
def select(field_name, choices, options = {})
|
|
@@ -161,7 +168,8 @@ module Funicular
|
|
|
161
168
|
set_nested_value(@model_key, field_key, new_value)
|
|
162
169
|
end
|
|
163
170
|
|
|
164
|
-
|
|
171
|
+
errors = @component.state[:errors]
|
|
172
|
+
error_message = errors ? errors[field_key.to_sym] : nil
|
|
165
173
|
# errors may be a single message (legacy) or an array of messages
|
|
166
174
|
# (Funicular::Model::Errors#messages). Show the first.
|
|
167
175
|
error_message = error_message.first if error_message.is_a?(Array)
|
|
@@ -180,23 +188,24 @@ module Funicular
|
|
|
180
188
|
end
|
|
181
189
|
|
|
182
190
|
attrs = {
|
|
191
|
+
name: options[:name] || field_key,
|
|
183
192
|
onchange: on_change
|
|
184
193
|
}.merge(options.reject { |k, _| k == :class })
|
|
185
194
|
|
|
186
195
|
attrs[:class] = css_class unless css_class.empty?
|
|
187
196
|
|
|
188
|
-
@
|
|
189
|
-
|
|
197
|
+
@view_context.div do |h|
|
|
198
|
+
h.select(attrs) do |hh|
|
|
190
199
|
choices.each do |choice|
|
|
191
200
|
option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
|
|
192
201
|
selected = value.to_s == option_value.to_s
|
|
193
|
-
|
|
202
|
+
hh.option(value: option_value, selected: selected) do
|
|
194
203
|
option_text
|
|
195
204
|
end
|
|
196
205
|
end
|
|
197
206
|
end
|
|
198
207
|
if has_error
|
|
199
|
-
|
|
208
|
+
h.div(class: @error_class) { error_message }
|
|
200
209
|
end
|
|
201
210
|
end
|
|
202
211
|
end
|
|
@@ -222,21 +231,22 @@ module Funicular
|
|
|
222
231
|
on_change = custom_handler || default_handler
|
|
223
232
|
|
|
224
233
|
attrs = {
|
|
234
|
+
name: options[:name] || field_key,
|
|
225
235
|
type: "file",
|
|
226
236
|
onchange: on_change
|
|
227
237
|
}.merge(options)
|
|
228
238
|
|
|
229
|
-
@
|
|
239
|
+
@view_context.input(attrs)
|
|
230
240
|
end
|
|
231
241
|
|
|
232
242
|
def submit(label = "Submit", options = {})
|
|
233
243
|
attrs = { type: "submit" }.merge(options)
|
|
234
|
-
@
|
|
244
|
+
@view_context.button(attrs) { label }
|
|
235
245
|
end
|
|
236
246
|
|
|
237
247
|
def label(field_name, text = nil, options = {})
|
|
238
248
|
text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
|
|
239
|
-
@
|
|
249
|
+
@view_context.label(options) { text }
|
|
240
250
|
end
|
|
241
251
|
|
|
242
252
|
private
|
|
@@ -246,10 +256,10 @@ module Funicular
|
|
|
246
256
|
keys = key_path.split('.')
|
|
247
257
|
value = state
|
|
248
258
|
keys.each do |key|
|
|
249
|
-
if value.
|
|
250
|
-
value = value.send(key.to_sym)
|
|
251
|
-
elsif value.is_a?(Hash)
|
|
259
|
+
if value.is_a?(Hash)
|
|
252
260
|
value = value[key.to_sym] || value[key]
|
|
261
|
+
elsif value.respond_to?(:[])
|
|
262
|
+
value = value[key.to_sym]
|
|
253
263
|
else
|
|
254
264
|
value = nil
|
|
255
265
|
end
|
|
@@ -264,12 +274,12 @@ module Funicular
|
|
|
264
274
|
if field_key.include?('.')
|
|
265
275
|
# Complex nested update
|
|
266
276
|
keys = field_key.split('.')
|
|
267
|
-
current_model = @component.state
|
|
277
|
+
current_model = @component.state[model_key.to_sym]
|
|
268
278
|
updated_model = deep_merge_value(current_model, keys, new_value)
|
|
269
279
|
@component.patch(model_key.to_sym => updated_model)
|
|
270
280
|
else
|
|
271
281
|
# Simple update
|
|
272
|
-
current_model = @component.state
|
|
282
|
+
current_model = @component.state[model_key.to_sym]
|
|
273
283
|
if current_model.nil?
|
|
274
284
|
@component.patch(model_key.to_sym => { field_key.to_sym => new_value })
|
|
275
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.3.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,17 +180,19 @@ 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)
|
|
192
|
+
element[key_str] = false
|
|
190
193
|
else
|
|
191
194
|
element.setAttribute(key_str, key_str)
|
|
195
|
+
element[key_str] = true
|
|
192
196
|
end
|
|
193
197
|
elsif value.nil?
|
|
194
198
|
element.removeAttribute(key_str)
|
|
@@ -223,17 +227,20 @@ module Funicular
|
|
|
223
227
|
element = @doc.createElement(vnode.tag)
|
|
224
228
|
vnode.props.each do |key, value|
|
|
225
229
|
key_str = key.to_s
|
|
226
|
-
|
|
227
|
-
if
|
|
230
|
+
normalized_key = key_str.downcase
|
|
231
|
+
next if VDOM.event_attribute?(normalized_key)
|
|
232
|
+
if VDOM.blocked_attribute?(normalized_key, value)
|
|
228
233
|
puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
|
|
229
234
|
next
|
|
230
235
|
end
|
|
231
236
|
if key_str == "value" && (vnode.tag == "input" || vnode.tag == "textarea")
|
|
232
237
|
element[:value] = value.to_s
|
|
233
|
-
elsif BOOLEAN_ATTRIBUTES.include?(
|
|
238
|
+
elsif BOOLEAN_ATTRIBUTES.include?(normalized_key)
|
|
234
239
|
if value.nil? || value.to_s == "false"
|
|
240
|
+
element[key_str] = false
|
|
235
241
|
else
|
|
236
242
|
element.setAttribute(key_str, key_str)
|
|
243
|
+
element[key_str] = true
|
|
237
244
|
end
|
|
238
245
|
else
|
|
239
246
|
element.setAttribute(key_str, value.to_s)
|
|
@@ -259,7 +266,7 @@ module Funicular
|
|
|
259
266
|
element
|
|
260
267
|
when :component
|
|
261
268
|
raise "Expected Component vnode" unless vnode.is_a?(Component)
|
|
262
|
-
Renderer.new(@doc).render(vnode, nil)
|
|
269
|
+
Renderer.new(@doc, vnode.runtime || @runtime).render(vnode, nil)
|
|
263
270
|
else
|
|
264
271
|
raise "Unknown vnode type: #{vnode.type}"
|
|
265
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
|
@@ -30,33 +30,29 @@ module Funicular
|
|
|
30
30
|
@definitions = definitions
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def
|
|
33
|
+
def [](name, variant = nil)
|
|
34
34
|
style = @definitions[name]
|
|
35
35
|
return StyleValue.new("") unless style
|
|
36
36
|
|
|
37
|
-
if
|
|
37
|
+
if variant.nil?
|
|
38
38
|
# No arguments: return base or value
|
|
39
39
|
StyleValue.new(style[:base] || style[:value] || "")
|
|
40
|
-
elsif
|
|
40
|
+
elsif variant == true || variant == false
|
|
41
41
|
# Boolean argument: base + active (if true)
|
|
42
42
|
# JS::Object#== now supports direct comparison with Ruby true/false
|
|
43
43
|
base = style[:base] || ""
|
|
44
|
-
active_class = (
|
|
44
|
+
active_class = (variant == true) ? (style[:active] || "") : ""
|
|
45
45
|
StyleValue.new("#{base} #{active_class}".strip)
|
|
46
|
-
elsif
|
|
46
|
+
elsif variant.is_a?(Symbol)
|
|
47
47
|
# Symbol argument: base + variants[symbol]
|
|
48
48
|
base = style[:base] || ""
|
|
49
|
-
variant_class = style[:variants] ? (style[:variants][
|
|
49
|
+
variant_class = style[:variants] ? (style[:variants][variant] || "") : ""
|
|
50
50
|
StyleValue.new("#{base} #{variant_class}".strip)
|
|
51
51
|
else
|
|
52
52
|
# Other types: just return base
|
|
53
53
|
StyleValue.new(style[:base] || style[:value] || "")
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
|
-
|
|
57
|
-
def respond_to_missing?(name, include_private = false)
|
|
58
|
-
@definitions.key?(name) || super
|
|
59
|
-
end
|
|
60
56
|
end
|
|
61
57
|
|
|
62
58
|
class StyleBuilder
|
|
@@ -64,13 +60,13 @@ module Funicular
|
|
|
64
60
|
@definitions = {}
|
|
65
61
|
end
|
|
66
62
|
|
|
67
|
-
def
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@definitions[name] =
|
|
63
|
+
def define(name, value = nil, **options)
|
|
64
|
+
if value.is_a?(String)
|
|
65
|
+
@definitions[name] = { value: value }
|
|
66
|
+
elsif value.is_a?(Hash)
|
|
67
|
+
@definitions[name] = value
|
|
68
|
+
elsif !options.empty?
|
|
69
|
+
@definitions[name] = options
|
|
74
70
|
else
|
|
75
71
|
raise ArgumentError, "Invalid style definition for #{name}"
|
|
76
72
|
end
|