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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class Store
|
|
3
|
+
# Collection store: an ordered Array per scope, with bounded size and a
|
|
4
|
+
# key proc that supports remove(id) / same_tail? semantics.
|
|
5
|
+
#
|
|
6
|
+
# class MessageCache < Funicular::Store::Collection
|
|
7
|
+
# database "funicular_message_cache"
|
|
8
|
+
# scope :channel_id
|
|
9
|
+
# limit 100
|
|
10
|
+
# key ->(m) { m["id"] }
|
|
11
|
+
# cleared_on :logout
|
|
12
|
+
#
|
|
13
|
+
# subscribes_to "ChatChannel",
|
|
14
|
+
# params: ->(s) { { channel: "ChatChannel", channel_id: s.channel_id } } do |data, _scope|
|
|
15
|
+
# case data["type"]
|
|
16
|
+
# when "initial_messages" then replace(data["messages"] || [])
|
|
17
|
+
# when "new_message" then append(data["message"])
|
|
18
|
+
# when "delete_message" then remove(data["message_id"])
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
class Collection < Store
|
|
23
|
+
DEFAULT_KEY_PROC = ->(item) {
|
|
24
|
+
item.is_a?(Hash) ? item["id"] : nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_reader :__limit, :__order, :__key_proc
|
|
29
|
+
|
|
30
|
+
def limit(n)
|
|
31
|
+
@__limit = n
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# :append (default) keeps the most recent items at the tail and caps
|
|
35
|
+
# by truncating the head; :prepend caps by truncating the tail.
|
|
36
|
+
def order(direction)
|
|
37
|
+
@__order = direction.to_sym
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def key(proc)
|
|
41
|
+
@__key_proc = proc
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scope_class
|
|
45
|
+
Funicular::Store::Collection::Scope
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Scope < Funicular::Store::Scope
|
|
50
|
+
def all
|
|
51
|
+
rec = read
|
|
52
|
+
return [] unless rec.is_a?(Hash)
|
|
53
|
+
if expired_record?(rec)
|
|
54
|
+
erase
|
|
55
|
+
return []
|
|
56
|
+
end
|
|
57
|
+
items = rec["items"]
|
|
58
|
+
items.is_a?(Array) ? items : []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def replace(arr)
|
|
62
|
+
new_arr = cap(arr.is_a?(Array) ? arr : [])
|
|
63
|
+
# Skip IndexedDB write if the cached snapshot already matches by
|
|
64
|
+
# tail. Always fire callback so subscribers know replace completed
|
|
65
|
+
# (e.g. to clear loading state).
|
|
66
|
+
unless same_tail?(new_arr)
|
|
67
|
+
write(new_arr)
|
|
68
|
+
end
|
|
69
|
+
fire_change(new_arr)
|
|
70
|
+
new_arr
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def append(item)
|
|
74
|
+
new_arr = cap(append_to(all, item))
|
|
75
|
+
write(new_arr)
|
|
76
|
+
fire_change(new_arr)
|
|
77
|
+
new_arr
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def remove(id)
|
|
81
|
+
cur = all
|
|
82
|
+
kp = key_proc
|
|
83
|
+
new_arr = cur.reject { |m| kp.call(m) == id }
|
|
84
|
+
return cur if new_arr.size == cur.size
|
|
85
|
+
write(new_arr)
|
|
86
|
+
fire_change(new_arr)
|
|
87
|
+
new_arr
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def last
|
|
91
|
+
arr = all
|
|
92
|
+
arr.empty? ? nil : arr[arr.size - 1]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def last_id
|
|
96
|
+
l = last
|
|
97
|
+
return nil unless l
|
|
98
|
+
key_proc.call(l)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def size
|
|
102
|
+
all.size
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def clear
|
|
106
|
+
erase
|
|
107
|
+
fire_change([])
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def expired?
|
|
112
|
+
rec = read
|
|
113
|
+
expired_record?(rec)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# True iff `other` matches the current cached snapshot by size and
|
|
117
|
+
# last-item key. Cheap staleness probe used by callers that already
|
|
118
|
+
# have a fresh server response and want to skip a redundant
|
|
119
|
+
# state-replace re-render.
|
|
120
|
+
def same_tail?(other)
|
|
121
|
+
return false unless other.is_a?(Array)
|
|
122
|
+
cur = all
|
|
123
|
+
return false if cur.size != other.size
|
|
124
|
+
return true if cur.empty? && other.empty?
|
|
125
|
+
kp = key_proc
|
|
126
|
+
kp.call(cur[cur.size - 1]) == kp.call(other[other.size - 1])
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def key_proc
|
|
132
|
+
@store_class.__key_proc || Funicular::Store::Collection::DEFAULT_KEY_PROC
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cap(arr)
|
|
136
|
+
lim = @store_class.__limit
|
|
137
|
+
return arr unless lim.is_a?(Integer) && lim < arr.size
|
|
138
|
+
if @store_class.__order == :prepend
|
|
139
|
+
arr[0, lim] || arr
|
|
140
|
+
else
|
|
141
|
+
arr[arr.size - lim, lim] || arr
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def append_to(arr, item)
|
|
146
|
+
if @store_class.__order == :prepend
|
|
147
|
+
[item] + arr
|
|
148
|
+
else
|
|
149
|
+
arr + [item]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def read
|
|
154
|
+
kvs[storage_key]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def write(items)
|
|
158
|
+
kvs[storage_key] = {
|
|
159
|
+
"items" => items,
|
|
160
|
+
"wrote_at" => now_seconds,
|
|
161
|
+
"expires_in" => @store_class.__expires_in
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def erase
|
|
166
|
+
kvs.delete(storage_key)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class Store
|
|
3
|
+
# Singleton store: one value per scope. Suitable for things like a
|
|
4
|
+
# per-channel draft text or per-user preferences blob.
|
|
5
|
+
#
|
|
6
|
+
# class DraftStore < Funicular::Store::Singleton
|
|
7
|
+
# database "funicular_drafts"
|
|
8
|
+
# scope :channel_id
|
|
9
|
+
# cleared_on :logout
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# draft = DraftStore.where(channel_id: 1)
|
|
13
|
+
# draft.value = "hello"
|
|
14
|
+
# draft.value # => "hello"
|
|
15
|
+
# draft.delete
|
|
16
|
+
class Singleton < Store
|
|
17
|
+
def self.scope_class
|
|
18
|
+
Funicular::Store::Singleton::Scope
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Scope < Funicular::Store::Scope
|
|
22
|
+
def value
|
|
23
|
+
rec = read
|
|
24
|
+
return nil unless rec.is_a?(Hash)
|
|
25
|
+
if expired_record?(rec)
|
|
26
|
+
erase
|
|
27
|
+
return nil
|
|
28
|
+
end
|
|
29
|
+
rec["v"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Setting "" on a String-typed value deletes the entry, matching
|
|
33
|
+
# the semantics of the original DraftStore.
|
|
34
|
+
def value=(v)
|
|
35
|
+
if v.is_a?(String) && v.empty?
|
|
36
|
+
delete
|
|
37
|
+
return v
|
|
38
|
+
end
|
|
39
|
+
write(v)
|
|
40
|
+
fire_change(v)
|
|
41
|
+
v
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete
|
|
45
|
+
erase
|
|
46
|
+
fire_change(nil)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def present?
|
|
51
|
+
!value.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def expired?
|
|
55
|
+
rec = read
|
|
56
|
+
expired_record?(rec)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def read
|
|
62
|
+
kvs[storage_key]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write(v)
|
|
66
|
+
kvs[storage_key] = {
|
|
67
|
+
"v" => v,
|
|
68
|
+
"wrote_at" => now_seconds,
|
|
69
|
+
"expires_in" => @store_class.__expires_in
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def erase
|
|
74
|
+
kvs.delete(storage_key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/mrblib/styles.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class StyleValue
|
|
3
|
+
attr_reader :value
|
|
4
|
+
|
|
5
|
+
def initialize(value)
|
|
6
|
+
@value = value.to_s
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def |(other)
|
|
10
|
+
case other
|
|
11
|
+
when StyleValue
|
|
12
|
+
StyleValue.new("#{@value} #{other.value}".strip)
|
|
13
|
+
when String
|
|
14
|
+
StyleValue.new("#{@value} #{other}".strip)
|
|
15
|
+
when nil
|
|
16
|
+
self
|
|
17
|
+
else
|
|
18
|
+
other = other #: untyped
|
|
19
|
+
StyleValue.new("#{@value} #{other.to_s}".strip)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
@value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class StyleAccessor
|
|
29
|
+
def initialize(definitions)
|
|
30
|
+
@definitions = definitions
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def method_missing(name, *args)
|
|
34
|
+
style = @definitions[name]
|
|
35
|
+
return StyleValue.new("") unless style
|
|
36
|
+
|
|
37
|
+
if args.empty?
|
|
38
|
+
# No arguments: return base or value
|
|
39
|
+
StyleValue.new(style[:base] || style[:value] || "")
|
|
40
|
+
elsif args[0] == true || args[0] == false
|
|
41
|
+
# Boolean argument: base + active (if true)
|
|
42
|
+
# JS::Object#== now supports direct comparison with Ruby true/false
|
|
43
|
+
base = style[:base] || ""
|
|
44
|
+
active_class = (args[0] == true) ? (style[:active] || "") : ""
|
|
45
|
+
StyleValue.new("#{base} #{active_class}".strip)
|
|
46
|
+
elsif args[0].is_a?(Symbol)
|
|
47
|
+
# Symbol argument: base + variants[symbol]
|
|
48
|
+
base = style[:base] || ""
|
|
49
|
+
variant_class = style[:variants] ? (style[:variants][args[0]] || "") : ""
|
|
50
|
+
StyleValue.new("#{base} #{variant_class}".strip)
|
|
51
|
+
else
|
|
52
|
+
# Other types: just return base
|
|
53
|
+
StyleValue.new(style[:base] || style[:value] || "")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def respond_to_missing?(name, include_private = false)
|
|
58
|
+
@definitions.key?(name) || super
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class StyleBuilder
|
|
63
|
+
def initialize
|
|
64
|
+
@definitions = {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def method_missing(name, *args)
|
|
68
|
+
if args.size == 1 && args[0].is_a?(String)
|
|
69
|
+
# Simple style: name "class-string"
|
|
70
|
+
@definitions[name] = { value: args[0] }
|
|
71
|
+
elsif args.size == 1 && args[0].is_a?(Hash)
|
|
72
|
+
# Complex style with base/active/variants
|
|
73
|
+
@definitions[name] = args[0]
|
|
74
|
+
else
|
|
75
|
+
raise ArgumentError, "Invalid style definition for #{name}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_definitions
|
|
80
|
+
@definitions
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/mrblib/vdom.rb
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module VDOM
|
|
3
|
+
BOOLEAN_ATTRIBUTES = %w[
|
|
4
|
+
disabled checked selected readonly required autofocus multiple
|
|
5
|
+
]
|
|
6
|
+
|
|
7
|
+
URL_ATTRIBUTES = %w[href src action formaction data poster xlink:href]
|
|
8
|
+
|
|
9
|
+
class VNode
|
|
10
|
+
attr_reader :type, :key
|
|
11
|
+
|
|
12
|
+
def initialize(type)
|
|
13
|
+
@type = type
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Element < VNode
|
|
18
|
+
attr_reader :tag, :props, :children
|
|
19
|
+
|
|
20
|
+
def initialize(tag, props = {}, children = [])
|
|
21
|
+
super(:element)
|
|
22
|
+
@tag = tag.to_s
|
|
23
|
+
@key = props.delete(:key)
|
|
24
|
+
@props = props || {}
|
|
25
|
+
@children = normalize_children(children || [])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def normalize_children(children)
|
|
31
|
+
result = [] #: Array[child_t]
|
|
32
|
+
children.each do |child|
|
|
33
|
+
case child
|
|
34
|
+
when VNode
|
|
35
|
+
result << child
|
|
36
|
+
when String
|
|
37
|
+
result << child
|
|
38
|
+
when Array
|
|
39
|
+
# Flatten arrays (typically from .each or .map return values)
|
|
40
|
+
# Recursively normalize nested arrays
|
|
41
|
+
# @type var child: Array[Funicular::VDOM::child_t]
|
|
42
|
+
result.concat(normalize_children(child))
|
|
43
|
+
when nil
|
|
44
|
+
# Skip nil values
|
|
45
|
+
else
|
|
46
|
+
# Convert other types to strings
|
|
47
|
+
result << child.to_s
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ==(other)
|
|
54
|
+
return false unless other.is_a?(Element)
|
|
55
|
+
@tag == other.tag && @props == other.props && @children == other.children
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Text < VNode
|
|
60
|
+
attr_reader :content
|
|
61
|
+
|
|
62
|
+
def initialize(content)
|
|
63
|
+
super(:text)
|
|
64
|
+
@content = content.to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ==(other)
|
|
68
|
+
return false unless other.is_a?(Text)
|
|
69
|
+
@content == other.content
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Component < VNode
|
|
74
|
+
attr_reader :component_class, :props
|
|
75
|
+
attr_accessor :instance
|
|
76
|
+
|
|
77
|
+
def initialize(component_class, props = {})
|
|
78
|
+
super(:component)
|
|
79
|
+
@component_class = component_class
|
|
80
|
+
@key = props.delete(:key)
|
|
81
|
+
@props = props
|
|
82
|
+
@instance = nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ==(other)
|
|
86
|
+
return false unless other.is_a?(Component)
|
|
87
|
+
@component_class == other.component_class && @props == other.props
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Renderer
|
|
92
|
+
def initialize(doc = nil)
|
|
93
|
+
@doc = doc || JS.document
|
|
94
|
+
@error_boundary_stack = []
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render(vnode, parent = nil)
|
|
98
|
+
case vnode&.type
|
|
99
|
+
when :element
|
|
100
|
+
# @type var vnode: Funicular::VDOM::Element
|
|
101
|
+
render_element(vnode, parent)
|
|
102
|
+
when :text
|
|
103
|
+
# @type var vnode: Funicular::VDOM::Text
|
|
104
|
+
render_text(vnode, parent)
|
|
105
|
+
when :component
|
|
106
|
+
# @type var vnode: Funicular::VDOM::Component
|
|
107
|
+
render_component(vnode, parent)
|
|
108
|
+
else
|
|
109
|
+
raise "Unknown vnode type: #{vnode&.type}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Find the nearest error boundary instance on the stack
|
|
116
|
+
def current_error_boundary
|
|
117
|
+
@error_boundary_stack.last
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def render_element(element, parent)
|
|
121
|
+
dom_node = @doc.createElement(element.tag)
|
|
122
|
+
|
|
123
|
+
element.props.each do |key, value|
|
|
124
|
+
key_str = key.to_s
|
|
125
|
+
if key_str.start_with?('on')
|
|
126
|
+
# Event handlers are handled by Funicular::Component and should not be set as attributes.
|
|
127
|
+
# warn "Funicular: Attempted to set event handler '#{key_str}' as an attribute. This will be ignored."
|
|
128
|
+
elsif URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
|
|
129
|
+
# Prevent XSS attacks by blocking javascript: URIs in URL attributes
|
|
130
|
+
puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
|
|
131
|
+
elsif BOOLEAN_ATTRIBUTES.include?(key_str)
|
|
132
|
+
# Handle boolean attributes
|
|
133
|
+
if value.nil? || value.to_s == "false"
|
|
134
|
+
# Do not set attribute (leave it absent)
|
|
135
|
+
else
|
|
136
|
+
dom_node.setAttribute(key_str, key_str)
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
# Attribute
|
|
140
|
+
dom_node.setAttribute(key_str, value.to_s)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
element.children.each do |child|
|
|
145
|
+
if child.is_a?(VNode)
|
|
146
|
+
child_dom = render(child)
|
|
147
|
+
dom_node.appendChild(child_dom)
|
|
148
|
+
elsif child.is_a?(String)
|
|
149
|
+
text_node = @doc.createTextNode(child)
|
|
150
|
+
dom_node.appendChild(text_node)
|
|
151
|
+
elsif child.is_a?(Array)
|
|
152
|
+
child.each do |c|
|
|
153
|
+
if c.is_a?(VNode)
|
|
154
|
+
child_dom = render(c)
|
|
155
|
+
dom_node.appendChild(child_dom)
|
|
156
|
+
elsif c.is_a?(String)
|
|
157
|
+
text_node = @doc.createTextNode(c)
|
|
158
|
+
dom_node.appendChild(text_node)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
parent.appendChild(dom_node) if parent
|
|
165
|
+
|
|
166
|
+
dom_node
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def render_text(text, parent)
|
|
170
|
+
dom_node = @doc.createTextNode(text.content)
|
|
171
|
+
parent.appendChild(dom_node) if parent
|
|
172
|
+
dom_node
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def render_component(component_vnode, parent)
|
|
176
|
+
instance = component_vnode.component_class.new(component_vnode.props)
|
|
177
|
+
component_vnode.instance = instance
|
|
178
|
+
|
|
179
|
+
is_error_boundary = instance.is_a?(Funicular::ErrorBoundary)
|
|
180
|
+
|
|
181
|
+
# Push error boundary to stack if this component is one
|
|
182
|
+
@error_boundary_stack.push(instance) if is_error_boundary
|
|
183
|
+
|
|
184
|
+
begin
|
|
185
|
+
component_vdom = instance.build_vdom
|
|
186
|
+
dom_node = render(component_vdom, parent)
|
|
187
|
+
|
|
188
|
+
# Check if this ErrorBoundary caught an error during child rendering
|
|
189
|
+
# If so, its @vdom was already set to fallback in the rescue block
|
|
190
|
+
error_was_caught = is_error_boundary && instance.error_caught_during_render
|
|
191
|
+
|
|
192
|
+
if error_was_caught
|
|
193
|
+
# ErrorBoundary caught an error - use the fallback vdom/dom that were set in rescue
|
|
194
|
+
# Note: The div.error-boundary-content created during initial render
|
|
195
|
+
# will be orphaned, but that's acceptable as it's not attached to the DOM
|
|
196
|
+
fallback_vdom = instance.vdom
|
|
197
|
+
fallback_dom = instance.dom_element
|
|
198
|
+
|
|
199
|
+
# Bind events on the fallback DOM
|
|
200
|
+
instance.bind_events(fallback_dom, fallback_vdom)
|
|
201
|
+
instance.collect_refs(fallback_dom, fallback_vdom)
|
|
202
|
+
|
|
203
|
+
# Return the fallback DOM
|
|
204
|
+
fallback_dom
|
|
205
|
+
else
|
|
206
|
+
# Normal case - store VDOM and DOM element
|
|
207
|
+
instance.vdom = component_vdom
|
|
208
|
+
instance.dom_element = dom_node
|
|
209
|
+
instance.bind_events(dom_node, component_vdom)
|
|
210
|
+
instance.collect_refs(dom_node, component_vdom)
|
|
211
|
+
dom_node
|
|
212
|
+
end
|
|
213
|
+
rescue => e
|
|
214
|
+
# Pop error boundary from stack before handling
|
|
215
|
+
@error_boundary_stack.pop if is_error_boundary
|
|
216
|
+
|
|
217
|
+
# Try to find an error boundary to handle this error
|
|
218
|
+
boundary = current_error_boundary
|
|
219
|
+
if boundary && !is_error_boundary
|
|
220
|
+
error_info = {
|
|
221
|
+
component_class: component_vnode.component_class.to_s,
|
|
222
|
+
props: component_vnode.props
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Let the error boundary handle the error
|
|
226
|
+
boundary.catch_error(e, error_info)
|
|
227
|
+
|
|
228
|
+
# Re-render the error boundary with fallback UI
|
|
229
|
+
boundary_vdom = boundary.build_vdom
|
|
230
|
+
fallback_dom = render(boundary_vdom, nil)
|
|
231
|
+
|
|
232
|
+
# Update boundary's internal state
|
|
233
|
+
boundary.vdom = boundary_vdom
|
|
234
|
+
boundary.dom_element = fallback_dom
|
|
235
|
+
boundary.mounted = true
|
|
236
|
+
boundary.bind_events(fallback_dom, boundary_vdom)
|
|
237
|
+
|
|
238
|
+
fallback_dom
|
|
239
|
+
else
|
|
240
|
+
# No error boundary to catch this error, let it propagate
|
|
241
|
+
raise e
|
|
242
|
+
end
|
|
243
|
+
ensure
|
|
244
|
+
# Pop error boundary from stack after successful render
|
|
245
|
+
@error_boundary_stack.pop if is_error_boundary && @error_boundary_stack.last == instance
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.create_element(tag, props = {}, *children)
|
|
251
|
+
Element.new(tag, props, children.flatten)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def self.create_text(content)
|
|
255
|
+
Text.new(content)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.render(vnode, container)
|
|
259
|
+
renderer = Renderer.new
|
|
260
|
+
container.innerHTML = ''
|
|
261
|
+
renderer.render(vnode, container)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def self.diff(old_vnode, new_vnode)
|
|
265
|
+
Differ.diff(old_vnode, new_vnode)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def self.patch(element, patches)
|
|
269
|
+
patcher = Patcher.new
|
|
270
|
+
patcher.apply(element, patches)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
data/sig/cable.rbs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module Cable
|
|
3
|
+
STORAGE_KEY: String
|
|
4
|
+
|
|
5
|
+
def self.create_consumer: (String url) -> Consumer
|
|
6
|
+
|
|
7
|
+
class Consumer
|
|
8
|
+
attr_reader url: String
|
|
9
|
+
attr_reader subscriptions: Subscriptions
|
|
10
|
+
@reconnect_attempts: Integer
|
|
11
|
+
|
|
12
|
+
def initialize: (String url) -> void
|
|
13
|
+
def connect: () -> void
|
|
14
|
+
def send_command: (Hash[Symbol, untyped] command) -> void
|
|
15
|
+
def disconnect: () -> void
|
|
16
|
+
def cleanup: () -> void
|
|
17
|
+
def cleanup_event_listeners: () -> void
|
|
18
|
+
|
|
19
|
+
private def handle_message: (String data) -> void
|
|
20
|
+
private def flush_pending_commands: () -> void
|
|
21
|
+
private def schedule_reconnect: () -> void
|
|
22
|
+
private def calculate_backoff_delay: () -> Integer
|
|
23
|
+
private def setup_visibility_handler: () -> void
|
|
24
|
+
private def schedule_suspend: () -> void
|
|
25
|
+
private def cancel_suspend: () -> void
|
|
26
|
+
private def suspend_connection: () -> void
|
|
27
|
+
private def ensure_connected: () -> void
|
|
28
|
+
private def setup_beforeunload_handler: () -> void
|
|
29
|
+
private def save_pending_to_storage: () -> void
|
|
30
|
+
private def load_pending_from_storage: () -> Array[Hash[Symbol, untyped]]
|
|
31
|
+
private def clear_pending_storage: () -> void
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Subscriptions
|
|
35
|
+
@subscriptions: Hash[String, Subscription]
|
|
36
|
+
|
|
37
|
+
def initialize: (Consumer consumer) -> void
|
|
38
|
+
def create: (Hash[Symbol, untyped] params) ?{ (untyped message) -> void } -> Subscription
|
|
39
|
+
def find: (String identifier) -> Subscription?
|
|
40
|
+
def remove: (Subscription subscription) -> void
|
|
41
|
+
def notify_subscription_confirmed: (String identifier) -> void
|
|
42
|
+
def notify_subscription_rejected: (String identifier) -> void
|
|
43
|
+
def notify_message: (String identifier, untyped message) -> void
|
|
44
|
+
def resubscribe_all: () -> void
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Subscription
|
|
48
|
+
attr_reader consumer: Consumer
|
|
49
|
+
attr_reader identifier: String
|
|
50
|
+
attr_reader params: Hash[Symbol, untyped]
|
|
51
|
+
|
|
52
|
+
def initialize: (Consumer consumer, String identifier, Hash[Symbol, untyped] params) ?{ (untyped message) -> void } -> void
|
|
53
|
+
def subscribe: () -> void
|
|
54
|
+
def unsubscribe: () -> void
|
|
55
|
+
def perform: (String action, ?Hash[Symbol, untyped] data) -> void
|
|
56
|
+
def on_connected: () { () -> void } -> void
|
|
57
|
+
def on_disconnected: () { () -> void } -> void
|
|
58
|
+
def on_rejected: () { () -> void } -> void
|
|
59
|
+
def notify_connected: () -> void
|
|
60
|
+
def notify_rejected: () -> void
|
|
61
|
+
def notify_received: (untyped message) -> void
|
|
62
|
+
|
|
63
|
+
private def generate_idempotency_key: () -> String
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|