funicular 0.0.1 → 0.1.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. metadata +119 -8
@@ -0,0 +1,528 @@
1
+ # Data Fetching
2
+
3
+ Funicular provides multiple layers for data fetching: low-level HTTP client, high-level Model abstraction (Object-REST Mapper), and Suspense for loading states.
4
+
5
+ ## Table of Contents
6
+
7
+ - [HTTP Client](#http-client)
8
+ - [Object-REST Mapper (Model)](#object-rest-mapper-model)
9
+ - [Suspense / Loading States](#suspense--loading-states)
10
+ - [Best Practices](#best-practices)
11
+
12
+ ## HTTP Client
13
+
14
+ The `Funicular::HTTP` module provides a low-level interface for making HTTP requests.
15
+
16
+ ### GET Requests
17
+
18
+ ```ruby
19
+ Funicular::HTTP.get('/api/users') do |response|
20
+ if response.ok
21
+ patch(users: response.data)
22
+ else
23
+ patch(error: response.error_message)
24
+ end
25
+ end
26
+ ```
27
+
28
+ ### POST Requests
29
+
30
+ ```ruby
31
+ Funicular::HTTP.post('/api/users', { name: "Alice", email: "alice@example.com" }) do |response|
32
+ if response.ok
33
+ patch(user: response.data)
34
+ else
35
+ patch(errors: response.data["errors"])
36
+ end
37
+ end
38
+ ```
39
+
40
+ ### Other HTTP Methods
41
+
42
+ ```ruby
43
+ # PATCH
44
+ Funicular::HTTP.patch('/api/users/123', { name: "Alice Updated" }) do |response|
45
+ # ...
46
+ end
47
+
48
+ # PUT
49
+ Funicular::HTTP.put('/api/users/123', user_data) do |response|
50
+ # ...
51
+ end
52
+
53
+ # DELETE
54
+ Funicular::HTTP.delete('/api/users/123') do |response|
55
+ # ...
56
+ end
57
+ ```
58
+
59
+ ### Response Object
60
+
61
+ ```ruby
62
+ Funicular::HTTP.get('/api/data') do |response|
63
+ response.status # HTTP status code (200, 404, etc.)
64
+ response.ok # true if status is 2xx
65
+ response.data # Parsed JSON response
66
+ response.error? # true if response contains error
67
+ response.error_message # Error message from response
68
+ end
69
+ ```
70
+
71
+ ### CSRF Token
72
+
73
+ For non-GET requests to Rails backends, CSRF tokens are automatically included:
74
+
75
+ ```ruby
76
+ # Funicular automatically:
77
+ # 1. Reads <meta name="csrf-token"> from page
78
+ # 2. Adds X-CSRF-Token header to POST/PATCH/PUT/DELETE requests
79
+ Funicular::HTTP.post('/api/posts', post_data) do |response|
80
+ # CSRF token automatically included
81
+ end
82
+ ```
83
+
84
+ ## Object-REST Mapper (Model)
85
+
86
+ The Model layer provides an ActiveRecord-style interface for working with REST APIs.
87
+
88
+ ### Defining Models
89
+
90
+ ```ruby
91
+ class User < Funicular::Model
92
+ end
93
+
94
+ class Post < Funicular::Model
95
+ end
96
+
97
+ class Channel < Funicular::Model
98
+ end
99
+ ```
100
+
101
+ ### Schema Loading
102
+
103
+ Models load their attributes from a schema endpoint:
104
+
105
+ ```ruby
106
+ # In your initializer
107
+ Funicular.load_schemas({
108
+ User => "user",
109
+ Post => "post",
110
+ Channel => "channel"
111
+ }) do
112
+ # Callback executed after all schemas load
113
+ Funicular.start(container: 'app') do |router|
114
+ # Define routes...
115
+ end
116
+ end
117
+ ```
118
+
119
+ The schema endpoint should return attribute definitions:
120
+
121
+ ```ruby
122
+ # GET /api/schema/user
123
+ {
124
+ "attributes": ["id", "username", "email", "created_at"]
125
+ }
126
+ ```
127
+
128
+ ### Finding Records
129
+
130
+ #### Find All
131
+
132
+ ```ruby
133
+ User.all do |users, error|
134
+ if error
135
+ patch(error: error)
136
+ else
137
+ patch(users: users)
138
+ end
139
+ end
140
+ ```
141
+
142
+ #### Find by ID
143
+
144
+ ```ruby
145
+ User.find(123) do |user, error|
146
+ if error
147
+ patch(error: "User not found")
148
+ else
149
+ patch(user: user)
150
+ end
151
+ end
152
+ ```
153
+
154
+ #### Find with Parameters
155
+
156
+ ```ruby
157
+ Post.all({ category: "tech", limit: 10 }) do |posts, error|
158
+ patch(posts: posts)
159
+ end
160
+ ```
161
+
162
+ ### Creating Records
163
+
164
+ ```ruby
165
+ User.create({
166
+ username: "alice",
167
+ email: "alice@example.com",
168
+ password: "secret"
169
+ }) do |user, errors|
170
+ if errors
171
+ # errors = { email: "Email is already taken" }
172
+ patch(errors: errors)
173
+ else
174
+ patch(user: user, success: "User created!")
175
+ end
176
+ end
177
+ ```
178
+
179
+ ### Updating Records
180
+
181
+ ```ruby
182
+ user = state.user
183
+ user.update({ email: "newemail@example.com" }) do |updated_user, errors|
184
+ if errors
185
+ patch(errors: errors)
186
+ else
187
+ patch(user: updated_user, success: "Profile updated!")
188
+ end
189
+ end
190
+ ```
191
+
192
+ ### Deleting Records
193
+
194
+ ```ruby
195
+ user = state.user
196
+ user.destroy do |response, error|
197
+ if error
198
+ patch(error: "Failed to delete user")
199
+ else
200
+ patch(user: nil, success: "User deleted")
201
+ end
202
+ end
203
+ ```
204
+
205
+ ### Custom Endpoints
206
+
207
+ For custom API endpoints, use the `endpoint` class method:
208
+
209
+ ```ruby
210
+ class User < Funicular::Model
211
+ def self.endpoint
212
+ {
213
+ "path" => "/api/v2/users",
214
+ "find" => "/api/v2/users/:id",
215
+ "create" => "/api/v2/users",
216
+ "update" => "/api/v2/users/:id",
217
+ "destroy" => "/api/v2/users/:id"
218
+ }
219
+ end
220
+ end
221
+ ```
222
+
223
+ ### Accessing Attributes
224
+
225
+ ```ruby
226
+ User.find(123) do |user, error|
227
+ user.id # => 123
228
+ user.username # => "alice"
229
+ user.email # => "alice@example.com"
230
+ user.created_at # => "2024-01-15T10:30:00Z"
231
+ end
232
+ ```
233
+
234
+ ## Suspense / Loading States
235
+
236
+ Funicular provides a Suspense pattern for handling asynchronous data fetching with declarative loading states.
237
+
238
+ ### Basic Usage
239
+
240
+ Declare async data loaders at the class level using `use_suspense`:
241
+
242
+ ```ruby
243
+ class UserProfile < Funicular::Component
244
+ use_suspense :user,
245
+ ->(resolve, reject) {
246
+ User.find(props[:id]) do |user, error|
247
+ error ? reject.call(error) : resolve.call(user)
248
+ end
249
+ }
250
+
251
+ def render
252
+ div do
253
+ h1 { "Profile" }
254
+
255
+ suspense(
256
+ fallback: -> { div(class: "spinner") { "Loading..." } }
257
+ ) do
258
+ div { "Name: #{user.name}" }
259
+ div { "Email: #{user.email}" }
260
+ end
261
+ end
262
+ end
263
+ end
264
+ ```
265
+
266
+ The suspense data (`user`) is automatically accessible as a method within the component.
267
+
268
+ ### With Error Handling
269
+
270
+ ```ruby
271
+ suspense(
272
+ fallback: -> { div(class: "spinner") { "Loading user..." } },
273
+ error: ->(e) do
274
+ div(class: "error") do
275
+ span { "Failed to load: #{e}" }
276
+ button(onclick: -> { reload_suspense(:user) }) { "Retry" }
277
+ end
278
+ end
279
+ ) do
280
+ div { "Welcome, #{user.name}" }
281
+ end
282
+ ```
283
+
284
+ ### Syncing with Form State
285
+
286
+ When using suspense data with forms, use `on_resolve` to sync loaded data:
287
+
288
+ ```ruby
289
+ class SettingsComponent < Funicular::Component
290
+ use_suspense :current_user,
291
+ ->(resolve, reject) {
292
+ Session.current_user do |user, error|
293
+ error ? reject.call(error) : resolve.call(user)
294
+ end
295
+ },
296
+ on_resolve: ->(user) {
297
+ # Sync form state when data loads
298
+ patch(
299
+ form: {
300
+ username: user.username,
301
+ display_name: user.display_name,
302
+ bio: user.bio
303
+ }
304
+ )
305
+ }
306
+
307
+ def initialize_state
308
+ { form: { username: "", display_name: "", bio: "" } }
309
+ end
310
+
311
+ def render
312
+ suspense(fallback: -> { div { "Loading settings..." } }) do
313
+ form_for(:form, on_submit: :handle_save) do |f|
314
+ f.text_field(:username, disabled: true)
315
+ f.text_field(:display_name)
316
+ f.textarea(:bio)
317
+ f.submit("Save")
318
+ end
319
+ end
320
+ end
321
+ end
322
+ ```
323
+
324
+ ### Preventing Flickering
325
+
326
+ For fast-loading data, use `min_delay` to ensure the loading state is visible for a minimum duration:
327
+
328
+ ```ruby
329
+ use_suspense :data,
330
+ ->(resolve, reject) {
331
+ API.fetch_data do |data, error|
332
+ error ? reject.call(error) : resolve.call(data)
333
+ end
334
+ },
335
+ min_delay: 300 # Show loading spinner for at least 300ms
336
+ ```
337
+
338
+ This prevents UI flickering when data loads very quickly.
339
+
340
+ ### Multiple Suspense Sources
341
+
342
+ Declare multiple suspense data sources. The `suspense` helper waits for all of them:
343
+
344
+ ```ruby
345
+ class Dashboard < Funicular::Component
346
+ use_suspense :user, ->(resolve, reject) {
347
+ User.current do |user, error|
348
+ error ? reject.call(error) : resolve.call(user)
349
+ end
350
+ }
351
+
352
+ use_suspense :stats, ->(resolve, reject) {
353
+ Stats.fetch do |stats, error|
354
+ error ? reject.call(error) : resolve.call(stats)
355
+ end
356
+ }
357
+
358
+ use_suspense :notifications, ->(resolve, reject) {
359
+ Notification.recent do |notifications, error|
360
+ error ? reject.call(error) : resolve.call(notifications)
361
+ end
362
+ }
363
+
364
+ def render
365
+ suspense(fallback: -> { div { "Loading dashboard..." } }) do
366
+ # All three are loaded before this block executes
367
+ div do
368
+ h1 { "Welcome, #{user.name}" }
369
+ p { "Unread notifications: #{notifications.length}" }
370
+ p { "Total posts: #{stats.post_count}" }
371
+ end
372
+ end
373
+ end
374
+ end
375
+ ```
376
+
377
+ ### Reloading Data
378
+
379
+ Use `reload_suspense` to refresh data:
380
+
381
+ ```ruby
382
+ def handle_refresh
383
+ reload_suspense(:user) # Reload specific data source
384
+ end
385
+
386
+ def render
387
+ suspense(
388
+ fallback: -> { div { "Loading..." } },
389
+ error: ->(e) {
390
+ div do
391
+ span { "Error: #{e}" }
392
+ button(onclick: -> { reload_suspense(:user) }) { "Retry" }
393
+ end
394
+ }
395
+ ) do
396
+ div do
397
+ div { user.name }
398
+ button(onclick: -> { handle_refresh }) { "Refresh" }
399
+ end
400
+ end
401
+ end
402
+ ```
403
+
404
+ ### Helper Methods
405
+
406
+ - `suspense_loading?` - Check if any suspense data is still loading
407
+ - `suspense_loading?(:name)` - Check if specific suspense data is loading
408
+ - `suspense_error?(:name)` - Check if suspense data failed to load
409
+ - `suspense_error(:name)` - Get the error for a specific suspense data
410
+ - `reload_suspense(:name)` - Reload specific suspense data
411
+
412
+ ### When to Use Suspense
413
+
414
+ ```ruby
415
+ # ✅ Use Suspense for: Initial data loading
416
+ use_suspense :user, ->(resolve, reject) { User.current(&resolve) }
417
+
418
+ # ❌ Don't use Suspense for: User actions (use normal state instead)
419
+ def handle_like(post_id)
420
+ Post.like(post_id) do |result, error|
421
+ # Just update state, don't use suspense
422
+ patch(liked: true)
423
+ end
424
+ end
425
+ ```
426
+
427
+ ## Best Practices
428
+
429
+ ### 1. Use Models for CRUD Operations
430
+
431
+ ```ruby
432
+ # ✅ Good: Use Model layer
433
+ User.all do |users|
434
+ patch(users: users)
435
+ end
436
+
437
+ # ❌ Avoid: Manual HTTP for standard CRUD
438
+ Funicular::HTTP.get('/api/users') do |response|
439
+ patch(users: response.data)
440
+ end
441
+ ```
442
+
443
+ ### 2. Use Suspense for Initial Loading
444
+
445
+ ```ruby
446
+ # ✅ Good: Suspense for initial data
447
+ use_suspense :posts, ->(resolve, reject) {
448
+ Post.all(&resolve)
449
+ }
450
+
451
+ # ❌ Avoid: Manual loading state management
452
+ def component_mounted
453
+ patch(is_loading: true)
454
+ Post.all do |posts|
455
+ patch(posts: posts, is_loading: false)
456
+ end
457
+ end
458
+ ```
459
+
460
+ ### 3. Handle Errors Gracefully
461
+
462
+ ```ruby
463
+ # ✅ Good: Handle both success and error cases
464
+ User.create(form_data) do |user, errors|
465
+ if errors
466
+ patch(errors: errors, success_message: nil)
467
+ else
468
+ patch(user: user, errors: {}, success_message: "User created!")
469
+ end
470
+ end
471
+
472
+ # ❌ Avoid: Ignoring errors
473
+ User.create(form_data) do |user, errors|
474
+ patch(user: user) # What if errors?
475
+ end
476
+ ```
477
+
478
+ ### 4. Show Loading States
479
+
480
+ ```ruby
481
+ # ✅ Good: Clear loading feedback
482
+ def handle_submit(form_data)
483
+ patch(is_saving: true, errors: {})
484
+
485
+ Post.create(form_data) do |post, errors|
486
+ patch(is_saving: false, errors: errors || {})
487
+ end
488
+ end
489
+
490
+ def render
491
+ form_for(:post, on_submit: :handle_submit) do |f|
492
+ f.submit(
493
+ state.is_saving ? "Saving..." : "Save",
494
+ disabled: state.is_saving
495
+ )
496
+ end
497
+ end
498
+ ```
499
+
500
+ ### 5. Normalize Data Structures
501
+
502
+ ```ruby
503
+ # ✅ Good: Normalize nested data
504
+ User.all do |users|
505
+ users_by_id = users.each_with_object({}) { |u, h| h[u.id] = u }
506
+ patch(users_by_id: users_by_id)
507
+ end
508
+
509
+ # ❌ Avoid: Deep nesting
510
+ patch(data: { users: { list: users, metadata: { ... } } })
511
+ ```
512
+
513
+ ### 6. Cleanup Async Operations
514
+
515
+ ```ruby
516
+ def component_mounted
517
+ @request_in_flight = true
518
+
519
+ User.all do |users|
520
+ # Only update if component is still mounted
521
+ patch(users: users) if @request_in_flight
522
+ end
523
+ end
524
+
525
+ def component_unmounted
526
+ @request_in_flight = false
527
+ end
528
+ ```