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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +10 -2
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +23 -15
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/hydration_test.rb +87 -0
  45. data/minitest/plugin_test.rb +51 -0
  46. data/minitest/schema_test.rb +106 -0
  47. data/minitest/ssr_test.rb +94 -0
  48. data/minitest/validations_test.rb +183 -0
  49. data/mrbgem.rake +1 -0
  50. data/mrblib/0_validations.rb +206 -0
  51. data/mrblib/1_validators.rb +180 -0
  52. data/mrblib/cable.rb +24 -9
  53. data/mrblib/component.rb +172 -33
  54. data/mrblib/debug.rb +3 -0
  55. data/mrblib/differ.rb +47 -37
  56. data/mrblib/file_upload.rb +9 -1
  57. data/mrblib/form_builder.rb +21 -5
  58. data/mrblib/funicular.rb +97 -8
  59. data/mrblib/html_serializer.rb +121 -0
  60. data/mrblib/http.rb +123 -29
  61. data/mrblib/model.rb +50 -0
  62. data/mrblib/patcher.rb +74 -8
  63. data/mrblib/router.rb +40 -3
  64. data/mrblib/store.rb +304 -0
  65. data/mrblib/store_collection.rb +171 -0
  66. data/mrblib/store_singleton.rb +79 -0
  67. data/sig/cable.rbs +1 -0
  68. data/sig/component.rbs +13 -5
  69. data/sig/funicular.rbs +14 -1
  70. data/sig/html_serializer.rbs +20 -0
  71. data/sig/http.rbs +21 -6
  72. data/sig/model.rbs +6 -1
  73. data/sig/patcher.rbs +4 -1
  74. data/sig/router.rbs +3 -2
  75. data/sig/store.rbs +89 -0
  76. data/sig/store_collection.rbs +43 -0
  77. data/sig/store_singleton.rbs +19 -0
  78. data/sig/validations.rbs +103 -0
  79. data/sig/vdom.rbs +6 -6
  80. metadata +47 -12
  81. data/docs/README.md +0 -419
  82. data/docs/advanced-features.md +0 -632
  83. data/docs/components-and-state.md +0 -539
  84. data/docs/data-fetching.md +0 -528
  85. data/docs/forms.md +0 -446
  86. data/docs/rails-integration.md +0 -426
  87. data/docs/realtime.md +0 -543
  88. data/docs/routing-and-navigation.md +0 -427
  89. 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::Object
15
+ attr_accessor dom_element: JS::Element
16
16
  attr_accessor mounted: bool
17
- attr_reader refs: Hash[Symbol, JS::Object]
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::Object container) -> void
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::Object dom_element, VDOM::VNode | VDOM::Text | nil vnode) -> void
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::Object dom_element, VDOM::VNode | VDOM::Text | nil vnode, ?Hash[Symbol, JS::Object] refs_map) -> Hash[Symbol, JS::Object]
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::Object, ?props: Hash[Symbol, untyped]) ?{ (Router router) -> void } -> (Component | Router)
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.get: (String url) { (Response) -> void } -> void
14
- def self.post: (String url, ?Hash[untyped, untyped]? body) { (Response) -> void } -> void
15
- def self.patch: (String url, ?Hash[untyped, untyped]? body) { (Response) -> void } -> void
16
- def self.delete: (String url) { (Response) -> void } -> void
17
- def self.put: (String url, ?Hash[untyped, untyped]? body) { (Response) -> void } -> void
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.request: (String method, String url, Hash[untyped, untyped]? body) { (Response) -> void } -> void
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