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,539 +0,0 @@
1
- # Components and State Management
2
-
3
- This guide covers how to create components, manage state, and handle component lifecycle in Funicular.
4
-
5
- ## Table of Contents
6
-
7
- - [Creating Components](#creating-components)
8
- - [Event Handlers](#event-handlers)
9
- - [State Management](#state-management)
10
- - [Props](#props)
11
- - [Component Lifecycle](#component-lifecycle)
12
- - [Component Communication](#component-communication)
13
- - [Refs](#refs)
14
-
15
- ## Creating Components
16
-
17
- Components are Ruby classes that inherit from `Funicular::Component`. Each component must implement a `render` method that returns a VDOM representation of the UI.
18
-
19
- ### Basic Component
20
-
21
- ```ruby
22
- class Counter < Funicular::Component
23
- def initialize_state
24
- { count: 0 }
25
- end
26
-
27
- def increment
28
- patch(count: state.count + 1)
29
- end
30
-
31
- def decrement
32
- patch(count: state.count - 1)
33
- end
34
-
35
- def render
36
- div do
37
- h1 { "Counter" }
38
- p { "Current count: #{state.count}" }
39
- button(onclick: :increment) { "Increment" }
40
- button(onclick: :decrement) { "Decrement" }
41
- end
42
- end
43
- end
44
- ```
45
-
46
- ### Mounting a Component
47
-
48
- ```ruby
49
- # Mount to a container element
50
- Funicular.start(Counter, container: "app")
51
-
52
- # Or use the router (recommended for SPAs)
53
- Funicular.start(container: 'app') do |router|
54
- router.get('/counter', to: Counter, as: 'counter')
55
- end
56
- ```
57
-
58
- ## Event Handlers
59
-
60
- Funicular provides a unified callback system for handling events. All event handlers accept **Symbol**, **Method**, or **Proc**.
61
-
62
- ### Three Ways to Handle Events
63
-
64
- ```ruby
65
- class ExampleComponent < Funicular::Component
66
- def handle_click(event)
67
- puts "Button clicked!"
68
- patch(clicked: true)
69
- end
70
-
71
- def render
72
- div do
73
- # 1. Symbol (recommended for method references)
74
- button(onclick: :handle_click) { "Click Me" }
75
-
76
- # 2. Method object (useful for passing to child components)
77
- button(onclick: method(:handle_click)) { "Click Me" }
78
-
79
- # 3. Proc/Lambda (for inline logic)
80
- button(onclick: -> { patch(count: state.count + 1) }) { "Increment" }
81
- button(onclick: ->(e) { puts e.target[:value] }) { "With Event" }
82
- end
83
- end
84
- end
85
- ```
86
-
87
- ### When to Use Each
88
-
89
- **Use Symbol (`:method_name`)** - Most common:
90
- - Clean and concise
91
- - Best for method references
92
- - Works for all event types
93
-
94
- ```ruby
95
- button(onclick: :handle_submit) { "Submit" }
96
- input(oninput: :handle_input, onchange: :handle_change)
97
- form_for(:user, on_submit: :handle_submit)
98
- ```
99
-
100
- **Use Method (`method(:name)`)** - For passing to child components:
101
- - Explicitly creates a Method object
102
- - Preserves `self` binding
103
- - Useful when passing callbacks as props
104
-
105
- ```ruby
106
- component(ChildComponent, {
107
- on_delete: method(:handle_delete),
108
- on_update: method(:handle_update)
109
- })
110
- ```
111
-
112
- **Use Proc/Lambda** - For inline logic:
113
- - Short, one-off handlers
114
- - Direct state updates
115
- - Complex logic that doesn't warrant a separate method
116
-
117
- ```ruby
118
- button(onclick: -> { patch(count: state.count + 1) }) { "+" }
119
- button(onclick: -> { Funicular.router.navigate('/home') }) { "Home" }
120
- ```
121
-
122
- ### Event Object
123
-
124
- When using Symbol or Method, the event object is automatically passed:
125
-
126
- ```ruby
127
- def handle_input(event)
128
- value = event.target[:value]
129
- patch(search_query: value)
130
- end
131
-
132
- # Event passed automatically
133
- input(oninput: :handle_input)
134
- ```
135
-
136
- With Proc/Lambda, you can optionally receive the event:
137
-
138
- ```ruby
139
- # With event
140
- input(oninput: ->(e) { patch(value: e.target[:value]) })
141
-
142
- # Without event (arity = 0)
143
- button(onclick: -> { patch(clicked: true) })
144
- ```
145
-
146
- ### Form Submissions
147
-
148
- The `form_for` helper also accepts all three types:
149
-
150
- ```ruby
151
- # Symbol (recommended)
152
- form_for(:user, on_submit: :handle_submit) do |f|
153
- # ...
154
- end
155
-
156
- # Method object
157
- form_for(:user, on_submit: method(:handle_submit)) do |f|
158
- # ...
159
- end
160
-
161
- # Proc
162
- form_for(:user, on_submit: ->(data) { User.create(data) }) do |f|
163
- # ...
164
- end
165
- ```
166
-
167
- ## State Management
168
-
169
- State is component-local data that determines what gets rendered. When state changes, the component re-renders.
170
-
171
- ### Initializing State
172
-
173
- Override `initialize_state` to set initial state:
174
-
175
- ```ruby
176
- class TodoList < Funicular::Component
177
- def initialize_state
178
- {
179
- todos: [],
180
- input: "",
181
- filter: :all
182
- }
183
- end
184
- end
185
- ```
186
-
187
- ### Reading State
188
-
189
- Access state via the `state` accessor (read-only):
190
-
191
- ```ruby
192
- def render
193
- div do
194
- p { "Total: #{state.todos.length}" }
195
- p { "Input: #{state.input}" }
196
- end
197
- end
198
- ```
199
-
200
- ### Updating State
201
-
202
- Use `patch()` to update state. The component will automatically re-render:
203
-
204
- ```ruby
205
- def handle_input(event)
206
- patch(input: event.target[:value])
207
- end
208
-
209
- def add_todo
210
- new_todo = { id: Time.now.to_i, text: state.input, done: false }
211
- patch(
212
- todos: state.todos + [new_todo],
213
- input: ""
214
- )
215
- end
216
- ```
217
-
218
- **Important**: `patch()` merges the new state with existing state (shallow merge). Direct state mutation is blocked:
219
-
220
- ```ruby
221
- # ❌ This will raise an error
222
- state.count = 5
223
-
224
- # ✅ Use patch() instead
225
- patch(count: 5)
226
- ```
227
-
228
- ### Updating Nested State
229
-
230
- For deeply nested state, manually construct the new structure:
231
-
232
- ```ruby
233
- def initialize_state
234
- {
235
- user: {
236
- profile: { name: "Alice", age: 30 },
237
- settings: { theme: "dark" }
238
- }
239
- }
240
- end
241
-
242
- def update_name(new_name)
243
- patch(
244
- user: state.user.merge(
245
- profile: state.user[:profile].merge(name: new_name)
246
- )
247
- )
248
- end
249
- ```
250
-
251
- ## Props
252
-
253
- Props are data passed from parent to child components. They are immutable within the child component.
254
-
255
- ### Passing Props
256
-
257
- ```ruby
258
- # Parent component
259
- class App < Funicular::Component
260
- def render
261
- div do
262
- component(UserCard, {
263
- name: "Alice",
264
- email: "alice@example.com",
265
- avatar_url: "/images/alice.png"
266
- })
267
- end
268
- end
269
- end
270
- ```
271
-
272
- ### Receiving Props
273
-
274
- Access props via the `props` accessor:
275
-
276
- ```ruby
277
- class UserCard < Funicular::Component
278
- def render
279
- div(class: "card") do
280
- img(src: props[:avatar_url])
281
- h3 { props[:name] }
282
- p { props[:email] }
283
- end
284
- end
285
- end
286
- ```
287
-
288
- ### Passing Callbacks
289
-
290
- Props can include Proc/Lambda callbacks for child-to-parent communication:
291
-
292
- ```ruby
293
- # Parent
294
- class TodoApp < Funicular::Component
295
- def handle_delete(id)
296
- patch(todos: state.todos.reject { |t| t[:id] == id })
297
- end
298
-
299
- def render
300
- state.todos.map do |todo|
301
- component(TodoItem, {
302
- todo: todo,
303
- on_delete: method(:handle_delete)
304
- })
305
- end
306
- end
307
- end
308
-
309
- # Child
310
- class TodoItem < Funicular::Component
311
- def handle_delete_click
312
- props[:on_delete].call(props[:todo][:id])
313
- end
314
-
315
- def render
316
- div do
317
- span { props[:todo][:text] }
318
- button(onclick: :handle_delete_click) { "Delete" }
319
- end
320
- end
321
- end
322
- ```
323
-
324
- ## Component Lifecycle
325
-
326
- Funicular provides lifecycle hooks for component initialization and cleanup.
327
-
328
- ### component_mounted
329
-
330
- Called after the component is mounted to the DOM:
331
-
332
- ```ruby
333
- class ChatComponent < Funicular::Component
334
- def component_mounted
335
- # Subscribe to WebSocket
336
- @subscription = Cable.subscribe("ChatChannel") do |message|
337
- patch(messages: state.messages + [message])
338
- end
339
-
340
- # Load initial data
341
- load_suspense_data if self.class.suspense_definitions.any?
342
- end
343
- end
344
- ```
345
-
346
- ### component_unmounted
347
-
348
- Called before the component is removed from the DOM:
349
-
350
- ```ruby
351
- class ChatComponent < Funicular::Component
352
- def component_unmounted
353
- # Cleanup: unsubscribe from WebSocket
354
- @subscription&.unsubscribe
355
-
356
- # Clear timers
357
- @timer_ids.each { |id| JS.global.clearTimeout(id) }
358
- end
359
- end
360
- ```
361
-
362
- ### Lifecycle Order
363
-
364
- ```
365
- 1. new(props) # Constructor
366
- 2. initialize_state # Define initial state
367
- 3. render # First render
368
- 4. component_mounted # After DOM insertion
369
- 5. [state changes...] # User interactions
370
- 6. render (re-renders) # On each state update
371
- 7. component_unmounted # Before removal
372
- ```
373
-
374
- ## Component Communication
375
-
376
- ### Parent to Child: Props
377
-
378
- Pass data down via props (see [Props](#props) section above).
379
-
380
- ### Child to Parent: Callbacks
381
-
382
- Pass callback Procs via props:
383
-
384
- ```ruby
385
- # Parent defines handler
386
- def handle_change(new_value)
387
- patch(value: new_value)
388
- end
389
-
390
- # Parent passes callback
391
- component(InputField, {
392
- value: state.value,
393
- on_change: ->(new_value) { handle_change(new_value) }
394
- })
395
-
396
- # Child calls callback
397
- props[:on_change].call(new_value)
398
- ```
399
-
400
- ### Sibling Communication
401
-
402
- Lift state to common parent:
403
-
404
- ```ruby
405
- class Dashboard < Funicular::Component
406
- def initialize_state
407
- { selected_item: nil }
408
- end
409
-
410
- def render
411
- div do
412
- component(ItemList, {
413
- items: state.items,
414
- on_select: ->(item) { patch(selected_item: item) }
415
- })
416
-
417
- component(ItemDetails, {
418
- item: state.selected_item
419
- })
420
- end
421
- end
422
- end
423
- ```
424
-
425
- ## Refs
426
-
427
- Refs provide direct access to DOM elements, useful for:
428
- - Focus management
429
- - Text selection
430
- - Integrating with JavaScript libraries
431
-
432
- ### Creating Refs
433
-
434
- Use the `ref` prop and access via `refs` accessor:
435
-
436
- ```ruby
437
- class SearchBox < Funicular::Component
438
- def component_mounted
439
- # Focus input on mount
440
- refs[:search_input]&.focus()
441
- end
442
-
443
- def render
444
- div do
445
- input(ref: :search_input, type: "text", placeholder: "Search...")
446
- button(onclick: -> { refs[:search_input].focus() }) { "Focus" }
447
- end
448
- end
449
- end
450
- ```
451
-
452
- ### Refs with JavaScript Libraries
453
-
454
- Refs are essential for integrating JS libraries:
455
-
456
- ```ruby
457
- class ChartComponent < Funicular::Component
458
- def component_mounted
459
- # Delegate to Chart.js
460
- canvas = refs[:chart_canvas]
461
- @chart = JS.global.Chart.new(canvas, chart_config)
462
- end
463
-
464
- def component_unmounted
465
- @chart&.destroy()
466
- end
467
-
468
- def render
469
- div do
470
- canvas(ref: :chart_canvas, width: 400, height: 300)
471
- end
472
- end
473
- end
474
- ```
475
-
476
- ## Best Practices
477
-
478
- ### Keep Components Small
479
-
480
- Break large components into smaller, focused ones:
481
-
482
- ```ruby
483
- # ❌ Too large
484
- class UserDashboard < Funicular::Component
485
- def render
486
- div do
487
- # 500 lines of mixed UI code
488
- end
489
- end
490
- end
491
-
492
- # ✅ Decomposed
493
- class UserDashboard < Funicular::Component
494
- def render
495
- div do
496
- component(UserProfile)
497
- component(ActivityFeed)
498
- component(SettingsPanel)
499
- end
500
- end
501
- end
502
- ```
503
-
504
- ### Avoid Deep Props Drilling
505
-
506
- If passing props through many levels, consider:
507
- - Component composition
508
- - Lifting state higher
509
- - Model layer for shared server data
510
-
511
- ### Use Descriptive State Keys
512
-
513
- ```ruby
514
- # ❌ Unclear
515
- { x: true, y: 5, z: [] }
516
-
517
- # ✅ Descriptive
518
- { is_loading: true, page_number: 5, search_results: [] }
519
- ```
520
-
521
- ### Normalize State Shape
522
-
523
- Prefer flat structures over deep nesting:
524
-
525
- ```ruby
526
- # ❌ Deeply nested
527
- {
528
- users: [
529
- { id: 1, posts: [{ id: 10, comments: [...] }] }
530
- ]
531
- }
532
-
533
- # ✅ Normalized
534
- {
535
- users: { 1 => { id: 1, name: "Alice" } },
536
- posts: { 10 => { id: 10, user_id: 1 } },
537
- comments: { 100 => { id: 100, post_id: 10 } }
538
- }
539
- ```