funicular 0.1.0 → 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 +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -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/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- 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 -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 +3 -0
- 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 +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -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/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
data/mrblib/store.rb
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
# Declarative client-side store backed by IndexedDB::KVS.
|
|
3
|
+
#
|
|
4
|
+
# Subclass either Funicular::Store::Singleton (one value per scope) or
|
|
5
|
+
# Funicular::Store::Collection (ordered list per scope) and use the
|
|
6
|
+
# class-level DSL to wire everything up. See store_singleton.rb /
|
|
7
|
+
# store_collection.rb for the user-facing API.
|
|
8
|
+
class Store
|
|
9
|
+
# event_name => Array of store classes that subscribed via cleared_on
|
|
10
|
+
EVENT_REGISTRY = {} #: Hash[Symbol, Array[singleton(Funicular::Store)]]
|
|
11
|
+
|
|
12
|
+
# [database, kvs_store] => IndexedDB::KVS instance, shared across store classes
|
|
13
|
+
KVS_POOL = {} #: Hash[Array[String], IndexedDB::KVS]
|
|
14
|
+
|
|
15
|
+
# Snapshot of a `subscribes_to` declaration captured on the store class.
|
|
16
|
+
SubscribesTo = Data.define(:channel_name, :params_proc, :handler_block)
|
|
17
|
+
|
|
18
|
+
# Thin wrapper around a Funicular::Cable::Subscription. Exists so the
|
|
19
|
+
# store layer can hold lifecycle ownership without leaking the cable
|
|
20
|
+
# type into user code.
|
|
21
|
+
class Subscription
|
|
22
|
+
attr_reader :cable_sub
|
|
23
|
+
|
|
24
|
+
def initialize(cable_sub)
|
|
25
|
+
@cable_sub = cable_sub
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unsubscribe
|
|
29
|
+
@cable_sub.unsubscribe
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Per-scope accessor returned by Funicular::Store.where(...). Singleton
|
|
35
|
+
# and Collection define their own subclasses with the data-shape API.
|
|
36
|
+
class Scope
|
|
37
|
+
attr_reader :store_class, :scope_kwargs
|
|
38
|
+
|
|
39
|
+
def initialize(store_class, scope_kwargs)
|
|
40
|
+
@store_class = store_class
|
|
41
|
+
@scope_kwargs = scope_kwargs
|
|
42
|
+
@on_change = {}
|
|
43
|
+
@next_cb_id = 0
|
|
44
|
+
@subscription = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Expose scope kwargs as method calls (e.g. scope.channel_id) so that
|
|
48
|
+
# `params: ->(s) { { channel_id: s.channel_id } }` reads naturally in
|
|
49
|
+
# the subscribes_to DSL.
|
|
50
|
+
def method_missing(name, *args)
|
|
51
|
+
if @scope_kwargs.key?(name)
|
|
52
|
+
@scope_kwargs[name]
|
|
53
|
+
else
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def respond_to_missing?(name, include_private = false)
|
|
59
|
+
@scope_kwargs.key?(name) || super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on_change(&blk)
|
|
63
|
+
@next_cb_id += 1
|
|
64
|
+
id = @next_cb_id
|
|
65
|
+
@on_change[id] = blk
|
|
66
|
+
id
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def off_change(id)
|
|
70
|
+
@on_change.delete(id)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def subscribed?
|
|
75
|
+
!@subscription.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def subscription
|
|
79
|
+
@subscription
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Lazily create a Cable subscription for this scope using the store
|
|
83
|
+
# class's `subscribes_to` declaration. The handler block runs with
|
|
84
|
+
# `self == this Scope`, so bareword calls like `replace`, `append`,
|
|
85
|
+
# and `remove` resolve to the scope's mutators.
|
|
86
|
+
def subscribe!
|
|
87
|
+
existing = @subscription
|
|
88
|
+
return existing if existing
|
|
89
|
+
cable_binding = @store_class.__cable_binding
|
|
90
|
+
raise "no subscribes_to declared on #{@store_class}" unless cable_binding
|
|
91
|
+
consumer = @store_class.__consumer
|
|
92
|
+
params = cable_binding.params_proc.call(self)
|
|
93
|
+
handler = cable_binding.handler_block
|
|
94
|
+
scope = self
|
|
95
|
+
cable_sub = consumer.subscriptions.create(params) do |data|
|
|
96
|
+
scope.instance_exec(data, scope, &handler) # steep:ignore
|
|
97
|
+
end
|
|
98
|
+
sub = Funicular::Store::Subscription.new(cable_sub)
|
|
99
|
+
@subscription = sub
|
|
100
|
+
sub
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def unsubscribe!
|
|
104
|
+
sub = @subscription
|
|
105
|
+
return nil unless sub
|
|
106
|
+
sub.unsubscribe
|
|
107
|
+
@subscription = nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def storage_key
|
|
113
|
+
parts = [] #: Array[String]
|
|
114
|
+
@scope_kwargs.each do |k, v|
|
|
115
|
+
parts << "#{k}=#{v}"
|
|
116
|
+
end
|
|
117
|
+
parts.join(":")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def kvs
|
|
121
|
+
@store_class.__kvs
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def now_seconds
|
|
125
|
+
Time.now.to_i
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def expired_record?(rec)
|
|
129
|
+
return false unless rec.is_a?(Hash)
|
|
130
|
+
ttl = rec["expires_in"]
|
|
131
|
+
return false unless ttl.is_a?(Integer) && 0 < ttl
|
|
132
|
+
wrote = rec["wrote_at"]
|
|
133
|
+
return false unless wrote.is_a?(Integer)
|
|
134
|
+
ttl < (now_seconds - wrote)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fire_change(snapshot)
|
|
138
|
+
@on_change.each_value do |cb|
|
|
139
|
+
begin
|
|
140
|
+
cb.call(snapshot)
|
|
141
|
+
rescue => e
|
|
142
|
+
puts "[Funicular::Store] on_change error in #{@store_class}: #{e.class}: #{e.message}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# Class-level DSL & runtime
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
class << self
|
|
153
|
+
attr_reader :__database, :__kvs_store_name, :__scope_keys,
|
|
154
|
+
:__expires_in, :__source, :__belongs_to,
|
|
155
|
+
:__cable_url, :__cable_binding,
|
|
156
|
+
:__cleared_handlers
|
|
157
|
+
|
|
158
|
+
# IndexedDB database name. Required (no implicit default; users
|
|
159
|
+
# should pin database names so refactors stay data-compatible).
|
|
160
|
+
def database(name)
|
|
161
|
+
@__database = name.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Object-store name within the database. Default: "kv".
|
|
165
|
+
def kvs_store(name)
|
|
166
|
+
@__kvs_store_name = name.to_s
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Declare scope keys. Single Symbol or splat of Symbols.
|
|
170
|
+
#
|
|
171
|
+
# scope :channel_id
|
|
172
|
+
# scope :channel_id, :user_id
|
|
173
|
+
def scope(*keys)
|
|
174
|
+
@__scope_keys = keys.map { |k| k.to_sym }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def expires_in(seconds)
|
|
178
|
+
@__expires_in = seconds
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Declarative-only annotation pointing at a Funicular::Model class.
|
|
182
|
+
# Has no behaviour; intended for documentation / tooling.
|
|
183
|
+
def source(model_class)
|
|
184
|
+
@__source = model_class
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Declarative-only association marker (e.g. `belongs_to :channel`).
|
|
188
|
+
# No behaviour.
|
|
189
|
+
def belongs_to(name)
|
|
190
|
+
@__belongs_to = name.to_sym
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def cable_url(url)
|
|
194
|
+
@__cable_url = url.to_s
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Capture a Cable subscription binding. The block is invoked via
|
|
198
|
+
# instance_exec on the Scope when the cable subscription receives a
|
|
199
|
+
# message, so bareword `replace` / `append` / `remove` resolve to the
|
|
200
|
+
# scope's own mutators.
|
|
201
|
+
def subscribes_to(channel_name, params:, &block)
|
|
202
|
+
raise "subscribes_to requires a block" unless block
|
|
203
|
+
@__cable_binding = SubscribesTo.new(channel_name.to_s, params, block)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Register this store class for one or more global event names. When
|
|
207
|
+
# Funicular::Store.dispatch(:event) is called, every registered class
|
|
208
|
+
# has its data wiped (default) or runs the user-supplied block.
|
|
209
|
+
def cleared_on(*event_names, &block)
|
|
210
|
+
pool = (@__cleared_handlers ||= {}) #: Hash[Symbol, Proc?]
|
|
211
|
+
registry = Funicular::Store::EVENT_REGISTRY
|
|
212
|
+
event_names.each do |ev|
|
|
213
|
+
sym = ev.to_sym
|
|
214
|
+
pool[sym] = block
|
|
215
|
+
arr = (registry[sym] ||= []) #: Array[singleton(Funicular::Store)]
|
|
216
|
+
arr << self unless arr.include?(self)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Return (and memoize) the Scope for the given scope kwargs. Same
|
|
221
|
+
# kwargs always return the same Scope instance, which is required so
|
|
222
|
+
# `on_change` callbacks attach to a single identity.
|
|
223
|
+
def where(**scope_kwargs)
|
|
224
|
+
validate_scope_kwargs!(scope_kwargs)
|
|
225
|
+
pool = (@__scope_pool ||= {}) #: Hash[Hash[Symbol, untyped], Funicular::Store::Scope]
|
|
226
|
+
existing = pool[scope_kwargs]
|
|
227
|
+
return existing if existing
|
|
228
|
+
scope = scope_class.new(self, scope_kwargs)
|
|
229
|
+
pool[scope_kwargs] = scope
|
|
230
|
+
scope
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Subclasses (Singleton / Collection) override to return the
|
|
234
|
+
# appropriate Scope class.
|
|
235
|
+
def scope_class
|
|
236
|
+
raise NotImplementedError, "#{self} must subclass Funicular::Store::Singleton or Funicular::Store::Collection"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def __kvs
|
|
240
|
+
db = @__database || raise("#{self}: missing `database \"...\"` declaration")
|
|
241
|
+
store_name = @__kvs_store_name || "kv"
|
|
242
|
+
key = [db, store_name]
|
|
243
|
+
existing = Funicular::Store::KVS_POOL[key]
|
|
244
|
+
return existing if existing
|
|
245
|
+
kvs = IndexedDB::KVS.open(db, store: store_name)
|
|
246
|
+
Funicular::Store::KVS_POOL[key] = kvs
|
|
247
|
+
kvs
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def __consumer
|
|
251
|
+
@__consumer ||= Funicular::Cable.create_consumer(@__cable_url || "/cable")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def __handle_dispatch(event, payload)
|
|
255
|
+
pool = @__cleared_handlers
|
|
256
|
+
block = pool ? pool[event] : nil
|
|
257
|
+
if block
|
|
258
|
+
instance_exec(payload) { |p| block.call(p) }
|
|
259
|
+
else
|
|
260
|
+
__clear_all!
|
|
261
|
+
end
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def __clear_all!
|
|
266
|
+
return nil unless @__database
|
|
267
|
+
__kvs.clear
|
|
268
|
+
@__scope_pool = {} if @__scope_pool
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
def validate_scope_kwargs!(scope_kwargs)
|
|
275
|
+
declared = @__scope_keys || []
|
|
276
|
+
given = scope_kwargs.keys
|
|
277
|
+
unknown = given - declared
|
|
278
|
+
unless unknown.empty?
|
|
279
|
+
raise ArgumentError, "#{self}: unknown scope keys #{unknown.inspect}; declared #{declared.inspect}"
|
|
280
|
+
end
|
|
281
|
+
missing = declared - given
|
|
282
|
+
unless missing.empty?
|
|
283
|
+
raise ArgumentError, "#{self}: missing scope keys #{missing.inspect}"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Dispatch an event to every store class that registered via
|
|
289
|
+
# `cleared_on`. Default per-class action is `__clear_all!` (wipe the
|
|
290
|
+
# KVS + drop memoized scopes); override per class with a block.
|
|
291
|
+
def self.dispatch(event, payload = nil)
|
|
292
|
+
sym = event.to_sym
|
|
293
|
+
classes = Funicular::Store::EVENT_REGISTRY[sym] || []
|
|
294
|
+
classes.each do |klass|
|
|
295
|
+
begin
|
|
296
|
+
klass.__handle_dispatch(sym, payload)
|
|
297
|
+
rescue => e
|
|
298
|
+
puts "[Funicular::Store] dispatch(#{sym.inspect}) error in #{klass}: #{e.class}: #{e.message}"
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
nil
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -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/sig/cable.rbs
CHANGED
|
@@ -41,6 +41,7 @@ module Funicular
|
|
|
41
41
|
def notify_subscription_confirmed: (String identifier) -> void
|
|
42
42
|
def notify_subscription_rejected: (String identifier) -> void
|
|
43
43
|
def notify_message: (String identifier, untyped message) -> void
|
|
44
|
+
def resubscribe_all: () -> void
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
class Subscription
|
data/sig/component.rbs
CHANGED
|
@@ -12,9 +12,9 @@ module Funicular
|
|
|
12
12
|
|
|
13
13
|
attr_accessor props: Hash[Symbol, untyped]
|
|
14
14
|
attr_accessor vdom: VDOM::VNode | VDOM::Text | nil
|
|
15
|
-
attr_accessor dom_element: JS::
|
|
15
|
+
attr_accessor dom_element: JS::Element
|
|
16
16
|
attr_accessor mounted: bool
|
|
17
|
-
attr_reader refs: Hash[Symbol, JS::
|
|
17
|
+
attr_reader refs: Hash[Symbol, JS::Element]
|
|
18
18
|
@event_listeners: Array[Integer]
|
|
19
19
|
@child_components: Array[Component]
|
|
20
20
|
@suspense_data: Hash[Symbol, untyped]
|
|
@@ -53,10 +53,12 @@ module Funicular
|
|
|
53
53
|
def suspense: (fallback: ^() -> void, ?error: ^(untyped) -> void) { () -> void } -> void
|
|
54
54
|
|
|
55
55
|
def patch: (Hash[Symbol, untyped] new_state) -> void
|
|
56
|
-
def mount: (JS::
|
|
56
|
+
def mount: (JS::Element container) -> void
|
|
57
|
+
def hydrate: (JS::Element dom_element) -> void
|
|
58
|
+
def seed_state: (Hash[untyped, untyped]? state_hash) -> self
|
|
57
59
|
def unmount: () -> void
|
|
58
60
|
def render: () -> (VDOM::VNode | String | Integer | Float | Array[untyped] | nil)
|
|
59
|
-
def bind_events: (JS::
|
|
61
|
+
def bind_events: (JS::Element dom_element, VDOM::VNode | VDOM::Text | nil vnode) -> void
|
|
60
62
|
def build_vdom: () -> (VDOM::VNode | VDOM::Text | nil)
|
|
61
63
|
|
|
62
64
|
# Lifecycle hooks (public, can be overridden in subclasses)
|
|
@@ -91,13 +93,19 @@ module Funicular
|
|
|
91
93
|
private def re_render: () -> void
|
|
92
94
|
private def normalize_vnode: (untyped value) -> (VDOM::Element | VDOM::Text | VDOM::Component | nil)
|
|
93
95
|
private def add_data_component_attribute: (VDOM::VNode vnode) -> void
|
|
94
|
-
private def collect_refs: (JS::
|
|
96
|
+
private def collect_refs: (JS::Element dom_element, VDOM::VNode | VDOM::Text | nil vnode, ?Hash[Symbol, JS::Element] refs_map) -> Hash[Symbol, JS::Element]
|
|
95
97
|
private def cleanup_events: () -> void
|
|
96
98
|
private def cleanup_suspense_timers: () -> void
|
|
97
99
|
private def add_child: (untyped child) -> void
|
|
98
100
|
private def collect_child_components: (VDOM::VNode | VDOM::Text | nil vnode) -> void
|
|
99
101
|
private def collect_child_components_recursive: (VDOM::VNode | VDOM::Text | nil vnode, Array[Component] components) -> void
|
|
100
102
|
|
|
103
|
+
# Hydration helpers
|
|
104
|
+
private def hydration_match?: (untyped vnode, untyped dom_element) -> bool
|
|
105
|
+
private def warn_hydration_mismatch: (untyped vnode, untyped dom_element) -> void
|
|
106
|
+
private def full_render_fallback: (VDOM::VNode | VDOM::Text | nil new_vdom, untyped server_dom) -> JS::Element
|
|
107
|
+
private def hydrate_child_components: (untyped vnode, untyped dom_element) -> void
|
|
108
|
+
|
|
101
109
|
# HTML element methods (DSL)
|
|
102
110
|
def div: (?Hash[Symbol, untyped] props) ?{ -> untyped } -> VDOM::Element
|
|
103
111
|
def span: (?Hash[Symbol, untyped] props) ?{ -> untyped } -> VDOM::Element
|
data/sig/funicular.rbs
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
VERSION: String
|
|
3
3
|
|
|
4
|
+
self.@server: bool
|
|
5
|
+
self.@form_builder_config: Hash[Symbol, String]?
|
|
6
|
+
|
|
4
7
|
def self.version: () -> String
|
|
5
8
|
def self.env: () -> EnvironmentInquirer
|
|
6
9
|
def self.env=: (EnvironmentInquirer | String environment) -> EnvironmentInquirer?
|
|
7
10
|
def self.router: () -> Router?
|
|
11
|
+
|
|
12
|
+
# SSR / hydration support
|
|
13
|
+
def self.server?: () -> bool
|
|
14
|
+
def self.server=: (untyped value) -> bool
|
|
15
|
+
def self.window_state: () -> Hash[String, untyped]
|
|
16
|
+
def self.has_ssr_state?: () -> bool
|
|
17
|
+
def self.first_element_child: (JS::Element container_element) -> JS::Element?
|
|
18
|
+
|
|
8
19
|
def self.load_schemas: (Hash[singleton(Model), String] models) ?{ () -> void } -> void
|
|
9
|
-
def self.start: (?singleton(Component)? component_class, ?container: String | JS::
|
|
20
|
+
def self.start: (?singleton(Component)? component_class, ?container: String | JS::Element, ?props: Hash[Symbol, untyped], ?hydrate: bool) ?{ (Router router) -> void } -> (Component | Router | nil)
|
|
10
21
|
def self.configure_forms: () ?{ (Hash[Symbol, String]) -> void } -> void
|
|
22
|
+
def self.form_builder_config: () -> Hash[Symbol, String]?
|
|
23
|
+
def self.form_builder_config=: (Hash[Symbol, String] config) -> Hash[Symbol, String]
|
|
11
24
|
def self.configure_debug: () ?{ (self) -> void } -> void
|
|
12
25
|
def self.export_debug_config: () -> void
|
|
13
26
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module VDOM
|
|
3
|
+
class HTMLSerializer
|
|
4
|
+
VOID_ELEMENTS: Array[String]
|
|
5
|
+
SKIP_PROPS: Array[Symbol]
|
|
6
|
+
|
|
7
|
+
def self.serialize: (VNode? vnode) -> String
|
|
8
|
+
def render: (VNode? vnode) -> String
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
def render_element: (Element element) -> String
|
|
12
|
+
def render_children: (Array[child_t] children) -> String
|
|
13
|
+
def render_text: (Text text) -> String
|
|
14
|
+
def render_component: (Component component_vnode) -> String
|
|
15
|
+
def serialize_props: (Hash[Symbol, untyped] props) -> String
|
|
16
|
+
def escape_html: (untyped str) -> String
|
|
17
|
+
def escape_attr: (untyped str) -> String
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/sig/http.rbs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
module HTTP
|
|
3
|
+
CACHE_DB_NAME: String
|
|
4
|
+
CACHE_STORE: String
|
|
5
|
+
|
|
6
|
+
self.@cache: IndexedDB::KVS?
|
|
7
|
+
|
|
3
8
|
class Response
|
|
4
9
|
attr_reader data: untyped
|
|
5
10
|
attr_reader status: Integer
|
|
@@ -10,13 +15,23 @@ module Funicular
|
|
|
10
15
|
def error_message: () -> String?
|
|
11
16
|
end
|
|
12
17
|
|
|
13
|
-
def self.
|
|
14
|
-
def self.
|
|
15
|
-
def self.
|
|
16
|
-
def self.
|
|
17
|
-
def self.
|
|
18
|
+
def self.cache_init!: () -> IndexedDB::KVS
|
|
19
|
+
def self.cache_purge: (String url) -> nil
|
|
20
|
+
def self.cache_clear: () -> nil
|
|
21
|
+
def self.cache_lookup: (String url) -> untyped
|
|
22
|
+
def self.cache_write: (String url, Hash[String, untyped] entry) -> nil
|
|
23
|
+
|
|
24
|
+
def self.get: (String url, ?cache: Integer?) { (Response) -> void } -> void
|
|
25
|
+
def self.post: (String url, ?Hash[untyped, untyped]? body, ?cache: Integer?) { (Response) -> void } -> void
|
|
26
|
+
def self.patch: (String url, ?Hash[untyped, untyped]? body, ?cache: Integer?) { (Response) -> void } -> void
|
|
27
|
+
def self.delete: (String url, ?cache: Integer?) { (Response) -> void } -> void
|
|
28
|
+
def self.put: (String url, ?Hash[untyped, untyped]? body, ?cache: Integer?) { (Response) -> void } -> void
|
|
18
29
|
def self.csrf_token: () -> String?
|
|
19
30
|
|
|
20
|
-
private def self.
|
|
31
|
+
private def self.warn_unsupported_cache: (String verb) -> void
|
|
32
|
+
private def self.now_seconds: () -> Integer
|
|
33
|
+
private def self.cache_hit?: (untyped entry, Integer ttl) -> bool
|
|
34
|
+
private def self.serve_from_cache: (Hash[String, untyped] entry) { (Response) -> void } -> void
|
|
35
|
+
private def self.request: (String method, String url, Hash[untyped, untyped]? body, ?cache: Integer?) { (Response) -> void } -> void
|
|
21
36
|
end
|
|
22
37
|
end
|