funicular 0.2.1 → 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 +2 -2
- 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 +13 -13
- 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 +16 -10
- 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 +85 -84
- 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 +2 -2
- 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 +158 -237
- data/mrblib/debug.rb +7 -6
- data/mrblib/differ.rb +3 -1
- data/mrblib/error_boundary.rb +19 -27
- 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 +13 -17
- data/mrblib/vdom.rb +90 -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 +18 -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)
|
|
@@ -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 |h|
|
|
69
|
+
h.input(attrs)
|
|
68
70
|
if has_error
|
|
69
|
-
|
|
71
|
+
h.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 |h|
|
|
132
|
+
h.textarea(attrs)
|
|
130
133
|
if has_error
|
|
131
|
-
|
|
134
|
+
h.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 |h|
|
|
198
|
+
h.select(attrs) do |hh|
|
|
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
|
+
hh.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
|
+
h.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.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,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
|
@@ -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
|