funicular 0.0.1 → 0.2.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 +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -8
data/mrblib/model.rb
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class Model
|
|
3
|
+
include Validations
|
|
4
|
+
|
|
5
|
+
attr_reader :id
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_accessor :schema, :endpoints
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.load_schema(schema_data)
|
|
12
|
+
@schema = schema_data["attributes"]
|
|
13
|
+
@endpoints = schema_data["endpoints"]
|
|
14
|
+
|
|
15
|
+
# Generate attr_accessor dynamically based on schema
|
|
16
|
+
@schema.each do |name, config|
|
|
17
|
+
attr_reader name.to_sym
|
|
18
|
+
|
|
19
|
+
unless config["readonly"]
|
|
20
|
+
define_method("#{name}=") do |value|
|
|
21
|
+
# @type self: Model
|
|
22
|
+
instance_variable_set("@#{name}", value)
|
|
23
|
+
@changed_attributes ||= {} # steep:ignore UnannotatedEmptyCollection
|
|
24
|
+
@changed_attributes[name] = value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validations are inlined per attribute by Funicular::Schema.build.
|
|
29
|
+
register_schema_validations(name => config["validations"]) if config["validations"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Backward-compatible: a top-level { attr => rules } block also works.
|
|
33
|
+
register_schema_validations(schema_data["validations"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# validations: { "attr" => { "presence" => true, "length" => { "maximum" => 30 } } }
|
|
37
|
+
def self.register_schema_validations(validations)
|
|
38
|
+
return unless validations.is_a?(Hash)
|
|
39
|
+
validations.each do |attribute, rules|
|
|
40
|
+
next unless rules.is_a?(Hash)
|
|
41
|
+
rules.each do |kind, opts|
|
|
42
|
+
options = normalize_validation_options(kind, opts)
|
|
43
|
+
add_schema_validator(attribute, kind, options)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Turn JSON-shaped validator options into the Ruby options the client
|
|
49
|
+
# validators expect (notably rebuilding a Regexp for `format`). Integer
|
|
50
|
+
# Regexp flags are used so this works the same under CRuby and the client
|
|
51
|
+
# JS RegExp wrapper.
|
|
52
|
+
def self.normalize_validation_options(kind, opts)
|
|
53
|
+
return opts unless opts.is_a?(Hash)
|
|
54
|
+
if kind.to_s == "format" && opts["with"]
|
|
55
|
+
flags = opts["flags"].to_s
|
|
56
|
+
bits = 0
|
|
57
|
+
bits |= Regexp::IGNORECASE if flags.include?("i")
|
|
58
|
+
bits |= Regexp::MULTILINE if flags.include?("m")
|
|
59
|
+
{ with: Regexp.new(opts["with"], bits) }
|
|
60
|
+
else
|
|
61
|
+
opts
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(attributes = {})
|
|
66
|
+
@changed_attributes = {}
|
|
67
|
+
# Set attributes based on schema
|
|
68
|
+
self.class.schema.each do |name, config|
|
|
69
|
+
value = attributes[name] || attributes[name.to_sym]
|
|
70
|
+
instance_variable_set("@#{name}", value)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.all(params = {}, &block)
|
|
75
|
+
endpoint = @endpoints["all"]
|
|
76
|
+
return unless endpoint
|
|
77
|
+
|
|
78
|
+
HTTP.get(endpoint["path"]) do |response|
|
|
79
|
+
if response.error?
|
|
80
|
+
block.call(nil, response.error_message) if block
|
|
81
|
+
else
|
|
82
|
+
instances = response.data.map { |attrs| new(attrs) }
|
|
83
|
+
block.call(instances, nil) if block
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.find(id = nil, endpoint_name: "find", model_class: nil, &block)
|
|
89
|
+
endpoint = @endpoints[endpoint_name]
|
|
90
|
+
return unless endpoint
|
|
91
|
+
|
|
92
|
+
path = endpoint["path"]
|
|
93
|
+
path = path.gsub(":id", id.to_s) if id
|
|
94
|
+
|
|
95
|
+
HTTP.get(path) do |response|
|
|
96
|
+
if response.error?
|
|
97
|
+
block.call(nil, response.error_message) if block
|
|
98
|
+
else
|
|
99
|
+
klass = model_class || self
|
|
100
|
+
instance = klass.new(response.data)
|
|
101
|
+
block.call(instance, nil) if block
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.create(attrs, model_class: nil, &block)
|
|
107
|
+
endpoint = @endpoints["create"]
|
|
108
|
+
return unless endpoint
|
|
109
|
+
|
|
110
|
+
# Validate on the client before the request (mirrors ActiveRecord#save).
|
|
111
|
+
candidate = new(attrs)
|
|
112
|
+
unless candidate.valid?
|
|
113
|
+
block.call(nil, candidate.errors) if block
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
HTTP.post(endpoint["path"], attrs) do |response|
|
|
118
|
+
if response.error?
|
|
119
|
+
block.call(nil, response.error_message) if block
|
|
120
|
+
else
|
|
121
|
+
klass = model_class || self
|
|
122
|
+
instance = klass.new(response.data)
|
|
123
|
+
block.call(instance, nil) if block
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.destroy(id = nil, &block)
|
|
129
|
+
endpoint = @endpoints["destroy"]
|
|
130
|
+
return unless endpoint
|
|
131
|
+
|
|
132
|
+
path = id ? endpoint["path"].gsub(":id", id.to_s) : endpoint["path"]
|
|
133
|
+
|
|
134
|
+
HTTP.delete(path) do |response|
|
|
135
|
+
if response.error?
|
|
136
|
+
block.call(false, response.error_message) if block
|
|
137
|
+
else
|
|
138
|
+
block.call(true, response.data) if block
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def update(attrs = nil, &block)
|
|
144
|
+
if attrs
|
|
145
|
+
attrs.each { |k, v| send("#{k}=", v) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Validate on the client before the request (mirrors ActiveRecord#save).
|
|
149
|
+
unless valid?
|
|
150
|
+
block.call(false, errors) if block
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return if @changed_attributes.empty?
|
|
155
|
+
|
|
156
|
+
json_attrs = @changed_attributes.reject do |name, value|
|
|
157
|
+
schema = self.class.schema[name]
|
|
158
|
+
schema && schema["type"] == "binary"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return if json_attrs.empty?
|
|
162
|
+
|
|
163
|
+
endpoint = self.class.endpoints["update"]
|
|
164
|
+
path = endpoint["path"].gsub(":id", @id.to_s)
|
|
165
|
+
|
|
166
|
+
HTTP.patch(path, json_attrs) do |response|
|
|
167
|
+
if response.error?
|
|
168
|
+
block.call(false, response.error_message) if block
|
|
169
|
+
else
|
|
170
|
+
# Update attributes with response data
|
|
171
|
+
response.data.each do |key, value|
|
|
172
|
+
instance_variable_set("@#{key}", value)
|
|
173
|
+
end
|
|
174
|
+
@changed_attributes = {}
|
|
175
|
+
block.call(true, response.data) if block
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def destroy(&block)
|
|
181
|
+
self.class.destroy(@id, &block)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reload(&block)
|
|
185
|
+
self.class.find(@id) do |instance, error|
|
|
186
|
+
if instance
|
|
187
|
+
instance.instance_variables.each do |var|
|
|
188
|
+
instance_variable_set(var, instance.instance_variable_get(var))
|
|
189
|
+
end
|
|
190
|
+
@changed_attributes = {}
|
|
191
|
+
end
|
|
192
|
+
block.call(instance, error) if block
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
data/mrblib/patcher.rb
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module VDOM
|
|
3
|
+
class Patcher
|
|
4
|
+
def initialize(doc = nil)
|
|
5
|
+
@doc = doc || JS.document
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def apply(element, patches)
|
|
9
|
+
return element if patches.empty?
|
|
10
|
+
result = element
|
|
11
|
+
patches.each do |patch|
|
|
12
|
+
case patch[0]
|
|
13
|
+
when :replace
|
|
14
|
+
new_vnode = patch[1]
|
|
15
|
+
old_vnode = patch[2]
|
|
16
|
+
unmount_component(old_vnode)
|
|
17
|
+
new_element = create_element(new_vnode)
|
|
18
|
+
if parent = element.parentElement
|
|
19
|
+
parent.replaceChild(new_element, element)
|
|
20
|
+
result = new_element
|
|
21
|
+
end
|
|
22
|
+
when :props
|
|
23
|
+
# Props patches only make sense for Element nodes (text nodes have
|
|
24
|
+
# no attributes); narrow before delegating.
|
|
25
|
+
update_props(element, patch[1]) if element.is_a?(JS::Element)
|
|
26
|
+
when :update_and_rebind
|
|
27
|
+
instance, internal_patches, new_vdom = patch[1], patch[2], patch[3]
|
|
28
|
+
old_dom_element = instance.dom_element
|
|
29
|
+
|
|
30
|
+
# Apply internal patches and get the potentially new root element
|
|
31
|
+
new_dom_element = Patcher.new(@doc).apply(old_dom_element, internal_patches)
|
|
32
|
+
|
|
33
|
+
# Update the instance's reference to its root DOM element if it changed
|
|
34
|
+
if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
|
|
35
|
+
instance.dom_element = new_dom_element
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Update the instance's VDOM to the new one AFTER applying patches
|
|
39
|
+
instance.vdom = new_vdom
|
|
40
|
+
|
|
41
|
+
# Re-collect refs using the new DOM element
|
|
42
|
+
if new_dom_element.is_a?(JS::Element)
|
|
43
|
+
instance.collect_refs(new_dom_element, new_vdom)
|
|
44
|
+
|
|
45
|
+
# Re-bind events using the new DOM element
|
|
46
|
+
instance.cleanup_events
|
|
47
|
+
instance.bind_events(new_dom_element, new_vdom)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Call component_updated on the child instance
|
|
51
|
+
instance.component_updated if instance.respond_to?(:component_updated)
|
|
52
|
+
when :update_props
|
|
53
|
+
# Update component props - element is the component's DOM element
|
|
54
|
+
# The component instance needs to be retrieved and updated
|
|
55
|
+
# This is handled at a higher level (in Component#re_render)
|
|
56
|
+
# For now, we just return the element as-is
|
|
57
|
+
when :remove
|
|
58
|
+
old_vnode = patch[1]
|
|
59
|
+
unmount_component(old_vnode)
|
|
60
|
+
element.parentElement&.removeChild(element)
|
|
61
|
+
when :keyed_children
|
|
62
|
+
# Composite patch produced by Differ.diff_children_with_keys.
|
|
63
|
+
# Applied in three phases against `element`:
|
|
64
|
+
# 1. snapshot DOM children, then remove unmatched keyed old
|
|
65
|
+
# children (descending old_index so the snapshot indices
|
|
66
|
+
# remain valid as removes happen).
|
|
67
|
+
# 2. apply content updates to kept children in place. The
|
|
68
|
+
# lookup is by snapshot[old_index], so updates are stable
|
|
69
|
+
# regardless of subsequent insertions.
|
|
70
|
+
# 3. insert new children at their new_index using
|
|
71
|
+
# insertBefore on the live DOM. Processed in ascending
|
|
72
|
+
# new_index order so each insertion fixes its own
|
|
73
|
+
# position before later inserts run.
|
|
74
|
+
ops = patch[1]
|
|
75
|
+
removes = patch[2]
|
|
76
|
+
|
|
77
|
+
child_nodes = element[:childNodes]
|
|
78
|
+
snapshot = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
|
|
79
|
+
|
|
80
|
+
# Phase 1: removes (descending old_index)
|
|
81
|
+
sorted_removes = removes.sort { |a, b| b[0] <=> a[0] }
|
|
82
|
+
sorted_removes.each do |entry|
|
|
83
|
+
old_index = entry[0]
|
|
84
|
+
old_vnode = entry[1]
|
|
85
|
+
target = snapshot[old_index]
|
|
86
|
+
next if target.nil?
|
|
87
|
+
unmount_component(old_vnode)
|
|
88
|
+
parent_el = target.parentElement
|
|
89
|
+
parent_el.removeChild(target) if parent_el
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Phase 2: updates against the snapshot (no movement)
|
|
93
|
+
ops.each do |op|
|
|
94
|
+
next unless op[0] == :keep
|
|
95
|
+
old_index = op[1]
|
|
96
|
+
child_patches = op[3]
|
|
97
|
+
next if child_patches.empty?
|
|
98
|
+
target = snapshot[old_index]
|
|
99
|
+
next if target.nil?
|
|
100
|
+
apply(target, child_patches)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Phase 3: inserts in ascending new_index order
|
|
104
|
+
ops.each do |op|
|
|
105
|
+
next unless op[0] == :insert
|
|
106
|
+
new_index = op[1]
|
|
107
|
+
new_vnode = op[2]
|
|
108
|
+
new_node = create_element(new_vnode)
|
|
109
|
+
next if new_node.nil?
|
|
110
|
+
live_nodes = element[:childNodes]
|
|
111
|
+
live_arr = live_nodes.is_a?(JS::Object) ? live_nodes.to_a : [] #: Array[untyped]
|
|
112
|
+
ref = live_arr[new_index]
|
|
113
|
+
if ref.nil?
|
|
114
|
+
element.appendChild(new_node) if element.is_a?(JS::Element)
|
|
115
|
+
else
|
|
116
|
+
element.insertBefore(new_node, ref) if element.is_a?(JS::Element)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
when Integer
|
|
120
|
+
child_index = patch[0]
|
|
121
|
+
child_patches = patch[1]
|
|
122
|
+
# Use childNodes instead of children to include text nodes
|
|
123
|
+
child_nodes = element[:childNodes]
|
|
124
|
+
children = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[JS::Object]
|
|
125
|
+
child_element = children[child_index]
|
|
126
|
+
if child_element.nil?
|
|
127
|
+
# No existing child at this index - we need to create new elements
|
|
128
|
+
child_patches.each do |child_patch|
|
|
129
|
+
case child_patch[0]
|
|
130
|
+
when :replace
|
|
131
|
+
new_child_element = create_element(child_patch[1])
|
|
132
|
+
element.appendChild(new_child_element) if element.is_a?(JS::Element)
|
|
133
|
+
when :remove
|
|
134
|
+
# Nothing to remove if child doesn't exist
|
|
135
|
+
when Integer
|
|
136
|
+
# This shouldn't happen at the top level of child_patches
|
|
137
|
+
# But if it does, recursively process it
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
elsif child_element.is_a?(JS::Object)
|
|
141
|
+
# Recurse into the child node. apply handles both Element and
|
|
142
|
+
# text Node cases (the latter only meaningfully via :replace).
|
|
143
|
+
apply(child_element, child_patches)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
result
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def unmount_component(vnode)
|
|
153
|
+
return unless vnode.is_a?(VDOM::Component) && vnode.instance
|
|
154
|
+
vnode.instance.unmount
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def update_props(element, props_patch)
|
|
158
|
+
return unless element.is_a?(JS::Element)
|
|
159
|
+
|
|
160
|
+
props_patch.each do |key, value|
|
|
161
|
+
key_str = key.to_s
|
|
162
|
+
|
|
163
|
+
# Skip event handlers (handled by bind_events)
|
|
164
|
+
next if key_str.start_with?('on')
|
|
165
|
+
|
|
166
|
+
# Skip updating value for focused input/textarea elements
|
|
167
|
+
if key_str == "value"
|
|
168
|
+
tag_name = element[:tagName].to_s.downcase
|
|
169
|
+
if (tag_name == "input" || tag_name == "textarea")
|
|
170
|
+
active_element = @doc[:activeElement]
|
|
171
|
+
if active_element && element == active_element
|
|
172
|
+
next
|
|
173
|
+
end
|
|
174
|
+
# Use property instead of attribute for value
|
|
175
|
+
element[:value] = value.to_s
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Block javascript: URIs in URL attributes
|
|
181
|
+
if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
|
|
182
|
+
puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Handle boolean attributes
|
|
187
|
+
if BOOLEAN_ATTRIBUTES.include?(key_str)
|
|
188
|
+
if value.nil? || value.to_s == "false"
|
|
189
|
+
element.removeAttribute(key_str)
|
|
190
|
+
else
|
|
191
|
+
element.setAttribute(key_str, key_str)
|
|
192
|
+
end
|
|
193
|
+
elsif value.nil?
|
|
194
|
+
element.removeAttribute(key_str)
|
|
195
|
+
else
|
|
196
|
+
element.setAttribute(key_str, value.to_s)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def create_element(vnode)
|
|
202
|
+
return @doc.createTextNode(vnode) if vnode.is_a?(String)
|
|
203
|
+
if vnode.is_a?(Array)
|
|
204
|
+
# Arrays should have been flattened in VDOM::Element.normalize_children
|
|
205
|
+
# Create a wrapper div as fallback
|
|
206
|
+
wrapper = @doc.createElement("div")
|
|
207
|
+
vnode.each do |child|
|
|
208
|
+
if child.is_a?(VNode)
|
|
209
|
+
wrapper.appendChild(create_element(child))
|
|
210
|
+
elsif child.is_a?(String)
|
|
211
|
+
wrapper.appendChild(@doc.createTextNode(child))
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
return wrapper
|
|
215
|
+
end
|
|
216
|
+
raise "Invalid vnode: #{vnode.class}" unless vnode.is_a?(VNode)
|
|
217
|
+
case vnode.type
|
|
218
|
+
when :text
|
|
219
|
+
raise "Expected Text vnode" unless vnode.is_a?(Text)
|
|
220
|
+
@doc.createTextNode(vnode.content)
|
|
221
|
+
when :element
|
|
222
|
+
raise "Expected Element vnode" unless vnode.is_a?(Element)
|
|
223
|
+
element = @doc.createElement(vnode.tag)
|
|
224
|
+
vnode.props.each do |key, value|
|
|
225
|
+
key_str = key.to_s
|
|
226
|
+
next if key_str.start_with?('on')
|
|
227
|
+
if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
|
|
228
|
+
puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
|
|
229
|
+
next
|
|
230
|
+
end
|
|
231
|
+
if key_str == "value" && (vnode.tag == "input" || vnode.tag == "textarea")
|
|
232
|
+
element[:value] = value.to_s
|
|
233
|
+
elsif BOOLEAN_ATTRIBUTES.include?(key_str)
|
|
234
|
+
if value.nil? || value.to_s == "false"
|
|
235
|
+
else
|
|
236
|
+
element.setAttribute(key_str, key_str)
|
|
237
|
+
end
|
|
238
|
+
else
|
|
239
|
+
element.setAttribute(key_str, value.to_s)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
vnode.children.each do |child|
|
|
243
|
+
if child.is_a?(VNode)
|
|
244
|
+
element.appendChild(create_element(child))
|
|
245
|
+
elsif child.is_a?(String)
|
|
246
|
+
text_node = @doc.createTextNode(child)
|
|
247
|
+
element.appendChild(text_node)
|
|
248
|
+
elsif child.is_a?(Array)
|
|
249
|
+
child.each do |c|
|
|
250
|
+
if c.is_a?(VNode)
|
|
251
|
+
element.appendChild(create_element(c))
|
|
252
|
+
elsif c.is_a?(String)
|
|
253
|
+
text_node = @doc.createTextNode(c)
|
|
254
|
+
element.appendChild(text_node)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
element
|
|
260
|
+
when :component
|
|
261
|
+
raise "Expected Component vnode" unless vnode.is_a?(Component)
|
|
262
|
+
Renderer.new(@doc).render(vnode, nil)
|
|
263
|
+
else
|
|
264
|
+
raise "Unknown vnode type: #{vnode.type}"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|