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
@@ -1,632 +0,0 @@
1
- # Advanced Features
2
-
3
- This guide covers advanced Funicular features including Error Boundaries, CSS Transitions, and JavaScript integration.
4
-
5
- ## Table of Contents
6
-
7
- - [Error Boundary](#error-boundary)
8
- - [CSS Transition Helpers](#css-transition-helpers)
9
- - [JS Integration via Delegation Model](#js-integration-via-delegation-model)
10
-
11
- ## Error Boundary
12
-
13
- Funicular provides an `ErrorBoundary` component that catches errors from child components and displays a fallback UI instead of crashing the entire application. This is similar to React's Error Boundaries pattern.
14
-
15
- ### Basic Usage
16
-
17
- Wrap components that might throw errors with `ErrorBoundary`:
18
-
19
- ```ruby
20
- class MyApp < Funicular::Component
21
- def render
22
- div do
23
- h1 { "My Application" }
24
-
25
- # If RiskyComponent throws, only this section shows error UI
26
- component(Funicular::ErrorBoundary) do
27
- component(RiskyComponent)
28
- end
29
-
30
- # This component continues to work normally
31
- component(SafeComponent)
32
- end
33
- end
34
- end
35
- ```
36
-
37
- ### Custom Fallback UI
38
-
39
- Provide a custom fallback using the `fallback` prop:
40
-
41
- ```ruby
42
- component(Funicular::ErrorBoundary,
43
- fallback: ->(error) {
44
- div(style: 'background: #fee; padding: 20px; border: 2px solid #f88;') do
45
- h3 { "Oops! Something went wrong" }
46
- p { "Error: #{error.message}" }
47
- button(onclick: -> { Funicular.router.navigate('/') }) { "Go Home" }
48
- end
49
- }
50
- ) do
51
- component(RiskyComponent)
52
- end
53
- ```
54
-
55
- The fallback receives the error object as an argument.
56
-
57
- ### Error Callback
58
-
59
- Use `on_error` to log or report errors to an external service:
60
-
61
- ```ruby
62
- component(Funicular::ErrorBoundary,
63
- on_error: ->(error, info) {
64
- puts "Error in #{info[:component_class]}: #{error.message}"
65
-
66
- # Send to error tracking service
67
- ErrorTracker.report({
68
- message: error.message,
69
- component: info[:component_class],
70
- props: info[:props],
71
- stack_trace: error.backtrace.join("\n")
72
- })
73
- }
74
- ) do
75
- component(RiskyComponent)
76
- end
77
- ```
78
-
79
- The `on_error` callback receives:
80
- - `error`: The error object
81
- - `info`: Hash with `{ component_class:, props: }`
82
-
83
- ### Isolating Failures
84
-
85
- Use multiple ErrorBoundaries to isolate failures. One component's error won't affect others:
86
-
87
- ```ruby
88
- class Dashboard < Funicular::Component
89
- def render
90
- div(style: 'display: flex; gap: 20px;') do
91
- # Each section is isolated
92
- component(Funicular::ErrorBoundary,
93
- fallback: ->(e) { div(class: "error") { "Widget A failed" } }
94
- ) do
95
- component(WidgetA)
96
- end
97
-
98
- component(Funicular::ErrorBoundary,
99
- fallback: ->(e) { div(class: "error") { "Widget B failed" } }
100
- ) do
101
- component(WidgetB) # If this fails, WidgetA and WidgetC still work
102
- end
103
-
104
- component(Funicular::ErrorBoundary,
105
- fallback: ->(e) { div(class: "error") { "Widget C failed" } }
106
- ) do
107
- component(WidgetC)
108
- end
109
- end
110
- end
111
- end
112
- ```
113
-
114
- ### Default Fallback UI
115
-
116
- When no custom fallback is provided, ErrorBoundary displays a styled error message:
117
-
118
- ```
119
- ┌─────────────────────────────────────┐
120
- │ ⚠ Error │
121
- │ │
122
- │ RuntimeError: Something went wrong │
123
- │ │
124
- │ Component: RiskyComponent │
125
- └─────────────────────────────────────┘
126
- ```
127
-
128
- ### Best Practices
129
-
130
- **1. Place Boundaries Strategically**
131
-
132
- ```ruby
133
- # ✅ Good: Boundary around independent features
134
- component(ErrorBoundary) { component(UserProfile) }
135
- component(ErrorBoundary) { component(CommentList) }
136
-
137
- # ❌ Avoid: Boundary around entire app (too broad)
138
- component(ErrorBoundary) do
139
- component(EntireApplication)
140
- end
141
- ```
142
-
143
- **2. Provide Recovery Actions**
144
-
145
- ```ruby
146
- component(ErrorBoundary,
147
- fallback: ->(error) {
148
- div do
149
- p { "Failed to load data" }
150
- button(onclick: -> { reload_component }) { "Try Again" }
151
- button(onclick: -> { Funicular.router.navigate('/') }) { "Go Home" }
152
- end
153
- }
154
- )
155
- ```
156
-
157
- **3. Log Errors**
158
-
159
- ```ruby
160
- component(ErrorBoundary,
161
- on_error: ->(error, info) {
162
- # Always log errors for debugging
163
- puts "[ERROR] #{info[:component_class]}: #{error.message}"
164
- puts error.backtrace.join("\n")
165
- }
166
- )
167
- ```
168
-
169
- ## CSS Transition Helpers
170
-
171
- Funicular provides built-in CSS transition helpers to add smooth animations when elements enter or leave the DOM. These helpers leverage native CSS transitions for GPU-accelerated performance.
172
-
173
- ### Adding Elements with Animation
174
-
175
- Use `add_via` to smoothly fade in new elements:
176
-
177
- ```ruby
178
- class MessageComponent < Funicular::Component
179
- def component_mounted
180
- add_via(
181
- "message-#{props[:message]['id']}", # Element ID
182
- "opacity-0 scale-95", # from: invisible, slightly smaller
183
- "opacity-100 scale-100", # to: visible, normal size
184
- duration: 300 # Animation duration in ms
185
- )
186
- end
187
-
188
- def render
189
- div(class: "opacity-0 scale-95 transition-all", id: "message-#{props[:message]['id']}") do
190
- p { props[:message]["content"] }
191
- end
192
- end
193
- end
194
- ```
195
-
196
- ### Removing Elements with Animation
197
-
198
- Use `remove_via` to smoothly fade out elements before removing them from state:
199
-
200
- ```ruby
201
- class ChatComponent < Funicular::Component
202
- def handle_message_delete(message_id)
203
- remove_via(
204
- "message-#{message_id}",
205
- "opacity-100 max-h-screen", # from: visible, full height
206
- "opacity-0 max-h-0", # to: invisible, collapsed
207
- duration: 500
208
- ) do
209
- # Callback: update state after animation completes
210
- updated_messages = state.messages.reject { |m| m["id"] == message_id }
211
- patch(messages: updated_messages)
212
- end
213
- end
214
-
215
- def render
216
- state.messages.map do |message|
217
- div(
218
- id: "message-#{message['id']}",
219
- class: "opacity-100 max-h-screen transition-all"
220
- ) do
221
- span { message["content"] }
222
- button(onclick: -> { handle_message_delete(message["id"]) }) { "Delete" }
223
- end
224
- end
225
- end
226
- end
227
- ```
228
-
229
- ### How It Works
230
-
231
- 1. **String-based Class Specification**: CSS classes are specified as space-separated strings (e.g., `"opacity-0 scale-95"`)
232
- 2. **From-To Transitions**: The first string is the starting state, the second is the ending state
233
- 3. **Callback Support**: Optional blocks execute after animations complete
234
- 4. **Native CSS**: Uses browser CSS transitions for smooth, GPU-accelerated animations
235
-
236
- ### CSS Setup
237
-
238
- Define your transitions using Funicular's Styles DSL or regular CSS:
239
-
240
- ```ruby
241
- class MessageComponent < Funicular::Component
242
- styles do
243
- message "transition-[opacity,max-height,transform] duration-500 ease-out max-h-screen"
244
- end
245
-
246
- def render
247
- div(class: "#{s.message} opacity-0 scale-95", id: "message-#{props[:message]['id']}") do
248
- # Message content
249
- end
250
- end
251
- end
252
- ```
253
-
254
- Or with plain CSS:
255
-
256
- ```css
257
- .message {
258
- transition: opacity 500ms ease-out,
259
- max-height 500ms ease-out,
260
- transform 500ms ease-out;
261
- max-height: 100vh;
262
- }
263
- ```
264
-
265
- ### Common Animation Patterns
266
-
267
- **Fade in from below**:
268
- ```ruby
269
- add_via(element_id, "opacity-0 translate-y-4", "opacity-100 translate-y-0", duration: 300)
270
- ```
271
-
272
- **Slide out to the right**:
273
- ```ruby
274
- remove_via(element_id, "translate-x-0 opacity-100", "translate-x-full opacity-0", duration: 400)
275
- ```
276
-
277
- **Scale and fade**:
278
- ```ruby
279
- add_via(element_id, "opacity-0 scale-50", "opacity-100 scale-100", duration: 200)
280
- ```
281
-
282
- **Collapse height**:
283
- ```ruby
284
- remove_via(element_id, "max-h-screen opacity-100", "max-h-0 opacity-0", duration: 500)
285
- ```
286
-
287
- ### Timing Functions
288
-
289
- Specify different timing functions via CSS:
290
-
291
- ```ruby
292
- styles do
293
- # Ease out (default)
294
- fade_out "transition-opacity duration-300 ease-out"
295
-
296
- # Spring effect
297
- bounce_in "transition-transform duration-500 ease-[cubic-bezier(0.68,-0.55,0.265,1.55)]"
298
-
299
- # Linear
300
- slide "transition-transform duration-400 linear"
301
- end
302
- ```
303
-
304
- ### Complete Example
305
-
306
- ```ruby
307
- class NotificationList < Funicular::Component
308
- styles do
309
- notification "transition-all duration-500 ease-out max-h-24 overflow-hidden"
310
- notification_enter "opacity-0 translate-x-full"
311
- notification_visible "opacity-100 translate-x-0"
312
- notification_exit "opacity-0 max-h-0 translate-x-full"
313
- end
314
-
315
- def initialize_state
316
- { notifications: [] }
317
- end
318
-
319
- def add_notification(message)
320
- notification = {
321
- id: Time.now.to_i,
322
- message: message,
323
- timestamp: Time.now
324
- }
325
-
326
- patch(notifications: state.notifications + [notification])
327
-
328
- # Animate in after a brief delay (allows element to render)
329
- JS.global.setTimeout(50) do
330
- add_via(
331
- "notification-#{notification[:id]}",
332
- s.notification_enter,
333
- s.notification_visible,
334
- duration: 500
335
- )
336
- end
337
-
338
- # Auto-remove after 5 seconds
339
- JS.global.setTimeout(5000) do
340
- remove_notification(notification[:id])
341
- end
342
- end
343
-
344
- def remove_notification(id)
345
- remove_via(
346
- "notification-#{id}",
347
- s.notification_visible,
348
- s.notification_exit,
349
- duration: 500
350
- ) do
351
- patch(notifications: state.notifications.reject { |n| n[:id] == id })
352
- end
353
- end
354
-
355
- def render
356
- div(class: "notification-container fixed top-4 right-4 space-y-2") do
357
- state.notifications.map do |notification|
358
- div(
359
- id: "notification-#{notification[:id]}",
360
- key: notification[:id],
361
- class: "#{s.notification} #{s.notification_enter} bg-blue-600 text-white p-4 rounded shadow-lg"
362
- ) do
363
- p { notification[:message] }
364
- button(
365
- onclick: -> { remove_notification(notification[:id]) },
366
- class: "text-white underline text-sm"
367
- ) { "Dismiss" }
368
- end
369
- end
370
- end
371
- end
372
- end
373
- ```
374
-
375
- ## JS Integration via Delegation Model
376
-
377
- For complex UI interactions or data visualizations requiring existing JavaScript libraries (e.g., Chart.js, D3.js), Funicular adopts a "delegation model."
378
-
379
- ### The Delegation Model
380
-
381
- This strategy:
382
- 1. **Define container elements** in Ruby components using `ref` attributes
383
- 2. **Delegate control** to external JavaScript during lifecycle hooks (like `component_mounted`)
384
- 3. **Maintain separation** between Ruby (component structure, data flow) and JavaScript (DOM manipulation, library-specific logic)
385
-
386
- ### Basic Example: Chart.js Integration
387
-
388
- ```ruby
389
- class ChartComponent < Funicular::Component
390
- def component_mounted
391
- # Get the canvas element via ref
392
- canvas = refs[:chart_canvas]
393
-
394
- # Prepare chart data
395
- chart_data = {
396
- labels: state.labels,
397
- datasets: [{
398
- label: 'Sales',
399
- data: state.data,
400
- backgroundColor: 'rgba(54, 162, 235, 0.2)',
401
- borderColor: 'rgba(54, 162, 235, 1)',
402
- borderWidth: 1
403
- }]
404
- }
405
-
406
- # Delegate to Chart.js
407
- @chart = JS.global.Chart.new(canvas, {
408
- type: 'bar',
409
- data: chart_data,
410
- options: {
411
- responsive: true,
412
- maintainAspectRatio: false
413
- }
414
- })
415
- end
416
-
417
- def component_unmounted
418
- # Clean up: destroy the chart
419
- @chart&.destroy()
420
- end
421
-
422
- def render
423
- div(class: "chart-container", style: "height: 400px") do
424
- canvas(ref: :chart_canvas)
425
- end
426
- end
427
- end
428
- ```
429
-
430
- ### File Upload Integration
431
-
432
- Use `Funicular::FileUpload` for file uploads:
433
-
434
- ```ruby
435
- class ProfilePictureUpload < Funicular::Component
436
- def initialize_state
437
- { uploading: false, image_url: nil, error: nil }
438
- end
439
-
440
- def component_mounted
441
- # Mount the file upload helper (only once globally)
442
- Funicular::FileUpload.mount
443
- end
444
-
445
- def handle_file_select(event)
446
- file = event.target[:files][0]
447
- return unless file
448
-
449
- patch(uploading: true, error: nil)
450
-
451
- # Use the FileUpload helper
452
- Funicular::FileUpload.upload(file) do |result|
453
- if result[:error]
454
- patch(uploading: false, error: result[:error])
455
- else
456
- patch(
457
- uploading: false,
458
- image_url: result[:url],
459
- error: nil
460
- )
461
- end
462
- end
463
- end
464
-
465
- def render
466
- div do
467
- input(
468
- type: "file",
469
- accept: "image/*",
470
- onchange: :handle_file_select
471
- )
472
-
473
- if state.uploading
474
- p { "Uploading..." }
475
- elsif state.image_url
476
- img(src: state.image_url, class: "w-32 h-32")
477
- elsif state.error
478
- p(class: "text-red-600") { "Error: #{state.error}" }
479
- end
480
- end
481
- end
482
- end
483
- ```
484
-
485
- ### D3.js Integration
486
-
487
- ```ruby
488
- class D3GraphComponent < Funicular::Component
489
- def component_mounted
490
- render_graph
491
- end
492
-
493
- def component_updated(prev_state)
494
- # Re-render graph when data changes
495
- if prev_state[:data] != state.data
496
- render_graph
497
- end
498
- end
499
-
500
- def render_graph
501
- container = refs[:graph_container]
502
-
503
- # Clear existing SVG
504
- container.innerHTML = ""
505
-
506
- # Get D3 from global scope
507
- d3 = JS.global.d3
508
-
509
- # Create SVG
510
- svg = d3.select(container)
511
- .append("svg")
512
- .attr("width", 600)
513
- .attr("height", 400)
514
-
515
- # Bind data and create elements
516
- circles = svg.selectAll("circle")
517
- .data(state.data)
518
- .enter()
519
- .append("circle")
520
- .attr("cx", ->(d, i) { i * 50 + 25 })
521
- .attr("cy", 200)
522
- .attr("r", ->(d) { d * 10 })
523
- .attr("fill", "steelblue")
524
-
525
- # Add transitions
526
- circles.transition()
527
- .duration(1000)
528
- .attr("r", ->(d) { d * 15 })
529
- end
530
-
531
- def render
532
- div(class: "graph-container") do
533
- h3 { "D3 Visualization" }
534
- div(ref: :graph_container)
535
- end
536
- end
537
- end
538
- ```
539
-
540
- ### Custom JavaScript Initialization
541
-
542
- For custom JavaScript code:
543
-
544
- ```ruby
545
- class MapComponent < Funicular::Component
546
- def component_mounted
547
- init_map
548
- end
549
-
550
- def init_map
551
- container = refs[:map_container]
552
-
553
- # Call custom JavaScript function
554
- JS.global.initializeMap(container, {
555
- center: { lat: props[:lat], lng: props[:lng] },
556
- zoom: props[:zoom] || 10,
557
- markers: props[:markers] || []
558
- })
559
- end
560
-
561
- def component_unmounted
562
- # Clean up map instance
563
- JS.global.destroyMap(refs[:map_container])
564
- end
565
-
566
- def render
567
- div(ref: :map_container, style: "width: 100%; height: 500px;")
568
- end
569
- end
570
- ```
571
-
572
- ### Best Practices
573
-
574
- **1. Use Refs for Delegation**
575
-
576
- ```ruby
577
- # ✅ Good: Use refs to identify delegation targets
578
- canvas(ref: :chart_canvas)
579
- div(ref: :map_container)
580
-
581
- # ❌ Avoid: Relying on class/id selectors
582
- canvas(id: "my-chart") # Fragile, might conflict
583
- ```
584
-
585
- **2. Clean Up in component_unmounted**
586
-
587
- ```ruby
588
- def component_mounted
589
- @chart = JS.global.Chart.new(...)
590
- @interval = JS.global.setInterval(1000) { update_data }
591
- end
592
-
593
- def component_unmounted
594
- @chart&.destroy() # Destroy chart
595
- JS.global.clearInterval(@interval) # Clear timers
596
- end
597
- ```
598
-
599
- **3. Keep JS Logic Minimal**
600
-
601
- ```ruby
602
- # ✅ Good: Ruby handles data, JS handles rendering
603
- def render_chart
604
- @chart.data.datasets[0].data = state.chart_data # Update from Ruby
605
- @chart.update()
606
- end
607
-
608
- # ❌ Avoid: Complex logic in JS
609
- # Keep business logic in Ruby, use JS only for rendering
610
- ```
611
-
612
- **4. Handle Updates Properly**
613
-
614
- ```ruby
615
- def component_updated(prev_state)
616
- # Only update JS library when relevant data changes
617
- if prev_state[:chart_data] != state.chart_data
618
- update_chart(state.chart_data)
619
- end
620
- end
621
-
622
- def update_chart(new_data)
623
- @chart.data.datasets[0].data = new_data
624
- @chart.update()
625
- end
626
- ```
627
-
628
- ### See Also
629
-
630
- For more details on JavaScript interoperability:
631
- - [picoruby-wasm/docs/callback.md](../../picoruby-wasm/docs/callback.md) - Callback system
632
- - [picoruby-wasm/docs/interoperability_between_js_and_ruby.md](../../picoruby-wasm/docs/interoperability_between_js_and_ruby.md) - JS::Bridge for converting Ruby objects to JS