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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c20fca7e6590276dff1b8d10dcec31cc33a7c89070f2d515b28a39123358a7bb
4
- data.tar.gz: f96247a21b413702c3e999a0588b4366e7cb356535eca53d472ca36abd331c62
3
+ metadata.gz: 42012485f28870d0316fa30ba1fbebd4699c07cb8e61e5d201874d2eafd50d04
4
+ data.tar.gz: ab7f7e473a32d0a00b8bca5b0c67fc7e7b91a9a7860724e53631ea7d08dbcc41
5
5
  SHA512:
6
- metadata.gz: f592cbc26f7266a421846e0cb7f9445dcbc34458025d33d3da0ba140a5c3a3b5ee1c7b646ae56d7d5097162ed587ec92bcb402cc5da113d905deaae8bdb18a6c
7
- data.tar.gz: 9967e115be234ee8c29e3a605192a398cef6f6de7413d833beaf08b23829752f049e9ced1590761a8b7b1f1cbaccc515efdc9ec5ff0fa431879c83e06c37c88f
6
+ metadata.gz: 5488c967e48dd01768dc05acba5a350e07fb2fb5fc9ede01f5e06dd1274c1c4ee1fd7f0f4e76a2c616b74b8eff10260f5a14108d83798a34e25c3f785c4be7c7
7
+ data.tar.gz: 653010659b471071850c77dd459ff17984b906485cfcc9b396c3ecf1558686c353ca2ec65cc3940dad4497c79318515b833ff4219f7f9e301e93a041abbfbb30
data/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## [Unreleased]
2
+
3
+ ### Added
4
+
5
+ - **Funicular::Store DSL**: Declarative client-side stores backed by
6
+ IndexedDB. Subclass `Funicular::Store::Singleton` (one value per scope)
7
+ or `Funicular::Store::Collection` (ordered list per scope) and use
8
+ class-level DSL (`database`, `scope`, `limit`, `key`, `expires_in`,
9
+ `cleared_on`, `subscribes_to`) to wire up persistence, TTL, event-based
10
+ clearing, and ActionCable integration.
11
+ - `Funicular::Store.dispatch(:event)` for coordinated store clearing
12
+ (e.g., logout wipes all stores registered with `cleared_on :logout`)
13
+ - `subscribes_to` DSL for embedding Cable message handling directly in
14
+ store classes; scopes gain `subscribe!` / `unsubscribe!` / `subscribed?`
15
+ - Lazy KVS initialization: stores open IndexedDB on first access, removing
16
+ the need for explicit `init!` calls in application initializers
17
+ - `Funicular::Store::Scope#on_change` / `off_change` for reactive UI
18
+ updates when store data changes
19
+
20
+ ### Changed
21
+
22
+ - `Funicular::Cable::Consumer` now automatically resubscribes all active
23
+ subscriptions after WebSocket reconnect (`resubscribe_all`)
24
+
1
25
  ## [0.1.0] - 2026-04-20
2
26
 
3
27
  ### Added
data/README.md CHANGED
@@ -55,9 +55,12 @@ The others are common resources.
55
55
 
56
56
  ## Documentation
57
57
 
58
- You may want to see Tic-Tac-Toe tutorial first: [https://picoruby.org/funicular](https://picoruby.org/funicular)
58
+ User documentation is hosted on **picoruby.org**:
59
59
 
60
- Then, dig [docs/](docs/).
60
+ - [Getting Started with Funicular](https://picoruby.org/funicular-getting-started) — a standalone, no-Rails tutorial
61
+ - [Funicular on Rails](https://picoruby.org/funicular-on-rails) — installation, the asset pipeline, and a feature-by-feature tutorial plus reference (components, routing, forms, data fetching, stores, realtime, SSR, styling, debugging)
62
+
63
+ For contributors working on the gem itself, see [docs/architecture.md](docs/architecture.md).
61
64
 
62
65
  ## Development
63
66
 
@@ -72,6 +75,11 @@ cd picoruby/mrbgems/picoruby-funicular
72
75
  The CRubyGem side (`lib/`, `funicular.gemspec`, etc.) can be developed and tested independently inside that directory, but `rake copy_wasm` — which vendorsthe PicoRuby.wasm and picorbc wasm artifacts into the gem — relies on sibling directories within the picoruby repository (`mrbgems/picoruby-wasm/npm/`).
73
76
  Running it from a standalone checkout will fail.
74
77
 
78
+ ## Testing
79
+
80
+ - CRubygem (Rails integration) test: `rake test` in this repository
81
+ - PicoGem Funicular test: `rake test:gems:picoruby[picoruby-funicular]` in picoruby where mrbgems/picoruby-funicular exists as a submodule
82
+
75
83
  ## Contributing
76
84
 
77
85
  Bug reports and pull requests are welcome on GitHub at https://github.com/picoruby/funicular.
data/Rakefile CHANGED
@@ -78,6 +78,35 @@ task :copy_wasm do
78
78
  File.chmod(0755, File.join(picorbc_dest, "picorbc.js"))
79
79
  File.write(File.join(picorbc_dest, "VERSION"), "#{picorbc_version}\n")
80
80
  puts " copied picorbc (#{picorbc_version})"
81
+
82
+ # ------------------------------------------------------------------
83
+ # 3) PicoRuby runtime for DOM-backed Node.js tests
84
+ # ------------------------------------------------------------------
85
+ test_runtime_src = ENV["PICORUBY_WASM_TEST_DIR"] ||
86
+ File.expand_path("../../build/picoruby-wasm-test/bin", __dir__)
87
+ test_runtime_dest = File.join(vendor_root, "picoruby-test-node")
88
+ test_runtime_files = %w[picoruby.js picoruby.wasm]
89
+ optional_test_runtime_files = %w[picoruby.wasm.map]
90
+
91
+ unless Dir.exist?(test_runtime_src)
92
+ abort "PicoRuby WASM test runtime not found: #{test_runtime_src}\n" \
93
+ "Run `MRUBY_CONFIG=picoruby-wasm-test rake all` from the picoruby checkout, " \
94
+ "or set PICORUBY_WASM_TEST_DIR to the directory containing picoruby.js."
95
+ end
96
+
97
+ FileUtils.rm_rf(test_runtime_dest)
98
+ FileUtils.mkdir_p(test_runtime_dest)
99
+ test_runtime_files.each do |fname|
100
+ src_file = File.join(test_runtime_src, fname)
101
+ abort "Missing file: #{src_file}" unless File.exist?(src_file)
102
+ FileUtils.copy_file(src_file, File.join(test_runtime_dest, fname))
103
+ end
104
+ optional_test_runtime_files.each do |fname|
105
+ src_file = File.join(test_runtime_src, fname)
106
+ FileUtils.copy_file(src_file, File.join(test_runtime_dest, fname)) if File.exist?(src_file)
107
+ end
108
+ File.write(File.join(test_runtime_dest, "VERSION"), "#{picoruby_version}\n")
109
+ puts " copied picoruby-test-node (#{picoruby_version})"
81
110
  end
82
111
 
83
112
  # Make sure the wasm artifacts are refreshed before the gem is packaged for release.
data/docs/architecture.md CHANGED
@@ -1,409 +1,118 @@
1
- # Funicular Architecture
2
-
3
- This document provides a comprehensive analysis of Funicular's architectural design decisions and how they compare to other modern SPA frameworks.
4
-
5
- ## Table of Contents
6
-
7
- - [Overview](#overview)
8
- - [Architecture Design Decisions](#architecture-design-decisions)
9
- - [1. Data Flow](#1-data-flow)
10
- - [2. Rendering Strategy](#2-rendering-strategy)
11
- - [3. Reactivity Model](#3-reactivity-model)
12
- - [4. Component Model](#4-component-model)
13
- - [5. State Management](#5-state-management)
14
- - [6. Routing](#6-routing)
15
- - [7. Rendering Mode](#7-rendering-mode)
16
- - [8. Template System](#8-template-system)
17
- - [9. Build Strategy](#9-build-strategy)
18
- - [10. Concurrency](#10-concurrency)
19
- - [11. Event System](#11-event-system)
20
- - [12. Error Handling](#12-error-handling)
21
- - [13. Data Fetching](#13-data-fetching)
22
- - [Comparison with Other Frameworks](#comparison-with-other-frameworks)
23
- - [Design Philosophy](#design-philosophy)
24
- - [Trade-offs](#trade-offs)
25
- - [Best Use Cases](#best-use-cases)
26
-
27
- ## Overview
28
-
29
- Funicular is a **unidirectional, Virtual DOM-based SPA framework** for PicoRuby.wasm. It adopts design patterns similar to React while embracing Ruby's expressiveness and integrating seamlessly with Rails.
30
-
31
- **Core Principle**: Pure Ruby browser applications with explicit, predictable data flow.
32
-
33
- ## Architecture Design Decisions
34
-
35
- ### 1. Data Flow
36
-
37
- **Choice**: Unidirectional (One-way) Data Binding
38
-
39
- ```ruby
40
- # State flows DOWN to UI
41
- state -> VDOM -> DOM rendering
42
-
43
- # Events flow UP to trigger state updates
44
- DOM events -> event handlers -> patch() -> state update -> re-render
45
- ```
46
-
47
- **Evidence**:
48
- - State is immutable, updatable only via `patch()`
49
- - Direct state mutation is blocked by `StateAccessor`
50
- - Form inputs require explicit event handlers + `patch()` calls
51
-
52
- **Comparison**:
53
-
54
- | Framework | Binding Type | Update Mechanism |
55
- |-----------|--------------|------------------|
56
- | **Funicular** | Unidirectional | `patch()` + VDOM diffing |
57
- | React | Unidirectional | `setState()` + VDOM diffing |
58
- | Vue 2 | Bidirectional (v-model) | Reactivity proxy + VDOM |
59
- | Angular | Bidirectional | Two-way data binding |
60
- | Svelte | Unidirectional | Compile-time reactivity |
61
-
62
- ### 2. Rendering Strategy
63
-
64
- **Choice**: Virtual DOM with Diffing Algorithm
65
-
66
- **Implementation**:
67
- - Custom VDOM implementation (`vdom.rb`)
68
- - `Differ.diff()` calculates minimal changes
69
- - `Patcher.apply()` applies patches to real DOM
70
- - **Key-based reconciliation** for efficient list updates
71
-
72
- **Code Reference**:
73
- ```ruby
74
- # Component re-renders when state changes
75
- def re_render
76
- new_vdom = build_vdom
77
- patches = VDOM::Differ.diff(@vdom, new_vdom)
78
- @dom_element = VDOM::Patcher.apply(@dom_element, patches)
79
- @vdom = new_vdom
80
- end
81
- ```
82
-
83
- **Benefits**:
84
- - Efficient DOM updates (minimal mutations)
85
- - Declarative UI (describe what, not how)
86
- - Cross-platform potential (VDOM is abstraction layer)
87
-
88
- ### 3. Reactivity Model
89
-
90
- **Choice**: Explicit Updates (Manual Trigger)
91
-
92
- | Approach | Mechanism | Examples |
93
- |----------|-----------|----------|
94
- | **Explicit Updates** | Manual `setState()`/`patch()` | React, **Funicular** |
95
- | Auto-tracking | Proxy/Getter dependency tracking | Vue 3, Solid.js |
96
- | Compile-time | Compiler analyzes dependencies | Svelte |
97
-
98
- **Example**:
99
- ```ruby
100
- def handle_input(event)
101
- patch(username: event.target[:value]) # Explicit call
102
- end
103
- ```
104
-
105
- **Benefits**:
106
- - Simple, predictable
107
- - Low runtime overhead
108
- - Easy to debug (explicit control flow)
109
-
110
- ### 4. Component Model
111
-
112
- **Choice**: Class-based Components
113
-
114
- ```ruby
115
- class ChatComponent < Funicular::Component
116
- def initialize_state
117
- { messages: [] }
118
- end
119
-
120
- def component_mounted
121
- # Lifecycle hook
122
- end
123
-
124
- def render
125
- div { "Chat UI" }
126
- end
127
- end
128
- ```
129
-
130
- **Features**:
131
- - Lifecycle hooks: `component_mounted`, `component_unmounted`
132
- - Instance variables for component-local data
133
- - Suspense support for async data loading
134
- - Similar to React Class Components
135
-
136
- ### 5. State Management
137
-
138
- **Choice**: Local State + Props Drilling + Model Layer
139
-
140
- | Approach | Description | Example |
141
- |----------|-------------|---------|
142
- | **Local State** | Component-scoped `@state` | **Funicular** |
143
- | Global Store | Centralized state tree | Redux, Vuex |
144
- | Context/DI | Share state within tree | React Context |
145
-
146
- **Architecture**:
147
- - Components manage their own state (`@state`)
148
- - Parent-to-child communication via `props`
149
- - Server state managed by `Model` layer (ActiveRecord-style API)
150
-
151
- **No Global Store**: Funicular intentionally omits Redux-style global state to keep things simple. For shared state, use:
152
- - Props drilling
153
- - Model layer for server data
154
- - Component composition
155
-
156
- ### 6. Routing
157
-
158
- **Choice**: Client-Side Routing (History API)
159
-
160
- ```ruby
161
- Funicular.start(container: 'app') do |router|
162
- router.get('/chat/:channel_id', to: ChatComponent, as: 'chat_channel')
163
- end
164
- ```
165
-
166
- **Features**:
167
- - Rails-style DSL
168
- - URL parameter extraction (`/chat/:id` -> `{ id: "123" }`)
169
- - Auto-generated URL helpers: `chat_channel_path(id)`
170
- - History API for SPA navigation
171
-
172
- ### 7. Rendering Mode
173
-
174
- **Choice**: Pure Client-Side Rendering (CSR)
175
-
176
- | Mode | Description | Funicular Support |
177
- |------|-------------|-------------------|
178
- | **CSR** | Render in browser | ✅ Yes |
179
- | SSR | Server-side rendering | ❌ No (for now...) |
180
- | SSG | Static site generation | ❌ No |
181
- | ISR | Incremental static regen | ❌ No |
182
-
183
- **Reason**: PicoRuby.wasm runs only in browsers. Rails serves JSON APIs + assets.
184
-
185
- ### 8. Template System
186
-
187
- **Choice**: Ruby DSL (Not JSX or Template Strings)
188
-
189
- ```ruby
190
- def render
191
- div(class: 'container') do
192
- h1 { 'Welcome' }
193
- input(value: state.username, oninput: :handle_input)
194
- end
195
- end
196
- ```
197
-
198
- **Features**:
199
- - HTML tag names are Ruby methods
200
- - Blocks for child elements
201
- - Event handlers as symbols or Procs
202
- - Full Ruby expressiveness (loops, conditionals, etc.)
203
-
204
- **Comparison**:
205
-
206
- | Framework | Template Syntax |
207
- |-----------|-----------------|
208
- | React | JSX (XML-like) |
209
- | Vue | Template strings with directives |
210
- | Svelte | HTML-like template syntax |
211
- | **Funicular** | **Ruby DSL** |
212
-
213
- ### 9. Build Strategy
214
-
215
- **Choice**: Runtime Execution (No Build Step)
216
-
217
- | Approach | Description | Examples |
218
- |----------|-------------|----------|
219
- | **Runtime** | Code runs directly in browser | Vue (CDN), **Funicular** |
220
- | Compile-time | Pre-build required | Svelte, Angular |
221
- | Hybrid | JSX compiled, runtime VDOM | React |
222
-
223
- **Funicular's Approach**:
224
- - `app/funicular/**/*.rb` files are compiled to mruby bytecode (`.mrb`) via `picorbc`
225
- - Compilation output: `app/assets/builds/app.mrb`
226
- - Rails autoloading is explicitly disabled for `app/funicular/` directory
227
- - Asset pipeline automatically handles compilation (hooks into `assets:precompile`)
228
- - Developers don't need to manually compile (transparent automation)
229
-
230
- **Build Process**:
231
- ```bash
232
- # Automatic in production
233
- rake assets:precompile # -> calls funicular:compile
234
-
235
- # Manual compilation (if needed)
236
- rake funicular:compile
237
- ```
238
-
239
- **Benefits**:
240
- - Automated compilation via asset pipeline
241
- - Developers work with plain Ruby files
242
- - Production serves optimized bytecode
243
- - No separate build tool required (uses existing Rails toolchain)
244
-
245
- ### 10. Concurrency
246
-
247
- **Choice**: Synchronous Rendering + Async Data Fetching
248
-
249
- - `patch()` triggers immediate `re_render()`
250
- - No batching or scheduling
251
- - Suspense feature for async data with loading states
252
- - `min_delay` option prevents spinner flickering
253
-
254
- ```ruby
255
- use_suspense :user,
256
- ->(resolve, reject) { User.find(id, &resolve) },
257
- min_delay: 300 # Minimum loading spinner duration
258
- ```
259
-
260
- ### 11. Event System
261
-
262
- **Choice**: Native DOM Events (Not Synthetic Events)
263
-
264
- - Direct `addEventListener` usage
265
- - Event listeners re-bound on each re-render
266
- - `cleanup_events()` prevents memory leaks
267
- - No event pooling or synthetic event objects
268
-
269
- **Unified Callback Handling**:
270
-
271
- All event handlers accept `Symbol | Method | Proc`:
272
-
273
- ```ruby
274
- # All valid:
275
- button(onclick: :handle_click) # Symbol (recommended)
276
- button(onclick: method(:handle_click)) # Method (explicit)
277
- button(onclick: -> { handle_click }) # Proc (inline)
278
- button(onclick: -> { patch(count: count + 1) }) # Proc (inline logic)
279
- ```
280
-
281
- **Recommendation**:
282
- - Use `:symbol` for method references (concise)
283
- - Use `-> { }` for inline logic
284
- - Use `method(:name)` when passing callbacks to child components
285
-
286
- ### 12. Error Handling
287
-
288
- **Choice**: Error Boundary Pattern (React-style)
289
-
290
- ```ruby
291
- component(Funicular::ErrorBoundary,
292
- fallback: ->(error) { div { "Error: #{error.message}" } }
293
- ) do
294
- component(RiskyComponent)
295
- end
296
- ```
297
-
298
- - Catches errors from child components
299
- - Displays fallback UI
300
- - Prevents entire app crash
301
- - Stack-based boundary resolution
302
-
303
- ### 13. Data Fetching
304
-
305
- **Choice**: Manual Fetch + Model Abstraction Layer
306
-
307
- **Low-level**: `HTTP.get`, `HTTP.post`
308
- **High-level**: `Model.all`, `Model.find`, `Model.create`
309
-
310
- ```ruby
311
- # ActiveRecord-style API
312
- User.all do |users, error|
313
- patch(users: users)
314
- end
315
- ```
316
-
317
- **Features**:
318
- - Callback-based (not Promise-based)
319
- - Automatic CSRF token handling
320
- - Rails API integration
321
- - No built-in caching (like SWR/React Query)
322
-
323
- ## Comparison with Other Frameworks
324
-
325
- ### Funicular vs React
326
-
327
- | Aspect | Funicular | React |
328
- |--------|-----------|-------|
329
- | Language | Ruby | JavaScript/JSX |
330
- | Components | Class-based | Function (Hooks) or Class |
331
- | State Update | `patch()` | `setState()` / `useState()` |
332
- | VDOM | Custom implementation | Custom implementation |
333
- | Data Fetching | Model layer | Manual / libraries |
334
- | Routing | Built-in | Separate library |
335
- | Build Step | Asset Pipeline integration | Required (Babel/JSX) |
336
-
337
- ### Funicular vs Vue
338
-
339
- | Aspect | Funicular | Vue |
340
- |--------|-----------|-----|
341
- | Data Binding | Unidirectional | Bidirectional (v-model) |
342
- | Reactivity | Explicit `patch()` | Auto-tracking (Proxy) |
343
- | Templates | Ruby DSL | HTML-like templates |
344
- | SSR | No | Yes |
345
-
346
- ### Funicular vs Svelte
347
-
348
- | Aspect | Funicular | Svelte |
349
- |--------|-----------|--------|
350
- | VDOM | Yes | No (compiles to imperative code) |
351
- | Reactivity | Explicit | Compile-time analysis |
352
- | Build Step | Asset Pipeline integration | Required (compiler) |
353
-
354
- ## Design Philosophy
355
-
356
- Funicular embodies **"PicoRuby.wasm-based React"** with these principles:
357
-
358
- 1. **Ruby First**: Leverage Ruby's expressiveness for frontend development
359
- 2. **Explicit Over Magic**: Predictable, explicit state updates
360
- 3. **Rails Integration**: Seamless Rails API + ActionCable + Asset Pipeline integration
361
- 4. **Simple by Default**: No global state, no complex build tools
362
- 5. **Progressive Enhancement**: Start simple, add complexity when needed
363
-
364
- ## Trade-offs
365
-
366
- ### Strengths
367
-
368
- ✅ **Ruby Expressiveness**: Full Ruby syntax in templates
369
- ```ruby
370
- state.messages.map { |msg| div(key: msg.id) { msg.content } }
1
+ # Architecture (contributor guide)
2
+
3
+ This document is for people working **on** Funicular itself. User-facing
4
+ documentation -- how to build apps with Funicular -- lives at
5
+ [picoruby.org/wasm](https://picoruby.org/funicular-getting-started).
6
+
7
+ Funicular is a unidirectional, Virtual DOM-based SPA framework for
8
+ PicoRuby.wasm. State flows down to the DOM; events flow up through `patch()` to
9
+ update state and trigger a re-render. There is no global store, no auto-tracking
10
+ reactivity, and no separate build tool -- compilation rides on the Rails asset
11
+ pipeline.
12
+
13
+ ## Two sides of one repository
14
+
15
+ Funicular ships as two cooperating pieces (plus a Chrome extension):
16
+
17
+ - **PicoGem `picoruby-funicular`** (`mrblib/`) -- the runtime that executes in
18
+ the browser under PicoRuby.wasm. This is the framework proper.
19
+ - **CRubyGem `funicular`** (`lib/`) -- the Rails integration: the compiler
20
+ wrapper, middleware, railtie, view helpers, and the server-side rendering
21
+ runtime.
22
+
23
+ The same `mrblib/` code also runs under CRuby during SSR (see below), so it must
24
+ stay free of browser-only calls on any server code path
25
+ (`Funicular.server?` is true there).
26
+
27
+ ## `mrblib/` runtime: responsibilities
28
+
29
+ | File(s) | Responsibility |
30
+ |--------------------------------------------------|---------------------------------------------------------------------------|
31
+ | `funicular.rb` | Top-level module: `start`, `router`, `server?`, `debug_color` export |
32
+ | `component.rb` | `Funicular::Component` base: state, props, lifecycle, suspense, refs, styles |
33
+ | `vdom.rb` | Virtual DOM nodes and the element-factory DSL (`div`, `button`, ...) |
34
+ | `differ.rb` | `Differ.diff(old, new)` -- minimal patch set, key-based list reconciliation |
35
+ | `patcher.rb` | `Patcher.apply(dom, patches)` -- apply patches to the real DOM |
36
+ | `html_serializer.rb` | `VDOM::HTMLSerializer` -- VDOM to HTML string (used by SSR) |
37
+ | `router.rb` | Client-side router, route DSL, `RouteHelpers` generation, History API |
38
+ | `model.rb` | Object-REST Mapper (`all`/`find`/`create`/`update`/`destroy`) |
39
+ | `http.rb` | Low-level fetch wrapper, CSRF, IndexedDB response cache |
40
+ | `cable.rb` | ActionCable-compatible consumer/subscription client |
41
+ | `store.rb`, `store_singleton.rb`, `store_collection.rb` | IndexedDB-backed stores, scope API, `subscribes_to`, event dispatch |
42
+ | `form_builder.rb` | `form_for` and field helpers with inline error rendering |
43
+ | `0_validations.rb`, `1_validators.rb` | ActiveModel-style validators and `errors` |
44
+ | `styles.rb` | CSS-in-Ruby `styles` DSL and the `s` helper |
45
+ | `error_boundary.rb` | `ErrorBoundary` component |
46
+ | `file_upload.rb` | File / FormData upload helper |
47
+ | `debug.rb` | Development-only component/error registry for the DevTools extension |
48
+ | `environment_inquirer.rb` | Environment detection (`server?`, `development?`) |
49
+
50
+ The render cycle: a state change calls `patch()`, which rebuilds the component's
51
+ VDOM, diffs it against the previous VDOM with `Differ`, and applies the result
52
+ with `Patcher`. Event handlers are native DOM listeners, re-bound on each render.
53
+
54
+ ## `lib/` Rails integration
55
+
56
+ - `compiler.rb` -- runs the vendored `picorbc` (WebAssembly, via Node.js) to
57
+ compile `app/funicular/**/*.rb` (models, then stores, then components, then
58
+ initializers) into a single `app/assets/builds/app.mrb`. `-g` is added in
59
+ development for debug symbols.
60
+ - `middleware.rb` -- development only; watches `app/funicular/` and recompiles on
61
+ change, then invalidates the Propshaft asset cache.
62
+ - `railtie.rb` -- inserts the middleware, exposes view helpers, loads the rake
63
+ tasks.
64
+ - `helpers/picoruby_helper.rb` -- `picoruby_include_tag`,
65
+ `funicular_app_container`, `funicular_state_tag`.
66
+ - `configuration.rb` -- per-environment runtime source selection
67
+ (`:local_debug` / `:local_dist` / `:cdn`).
68
+ - `ssr.rb`, `ssr/runtime.rb` -- load the `mrblib/` runtime into the Rails process
69
+ and render a route's VDOM to HTML, injecting state for client hydration.
70
+ - `schema.rb` -- introspect an ActiveRecord model's `validators_on` and emit
71
+ client-side validators inline with the schema.
72
+
73
+ ## Vendored artifacts
74
+
75
+ `rake copy_wasm` (run by `rake build`) copies the PicoRuby.wasm runtime and the
76
+ `picorbc` compiler from the sibling `mrbgems/picoruby-wasm/npm/` directory into
77
+ `lib/funicular/vendor/`:
78
+
79
+ - `vendor/picoruby/dist/` -- production runtime build
80
+ - `vendor/picoruby/debug/` -- development runtime build (debug symbols)
81
+ - `vendor/picorbc/` -- the mruby compiler (run through Node.js)
82
+
83
+ Because `copy_wasm` reads sibling directories inside the picoruby repository, it
84
+ only works from within that checkout -- see Development below.
85
+
86
+ ## Server-side rendering, briefly
87
+
88
+ For SSR the `mrblib/` framework is loaded into the Rails process under CRuby.
89
+ `Funicular::SSR.render(path:, state:)` resolves the path against the routes in
90
+ `app/funicular/initializer.rb`, builds the component's VDOM, and serializes it
91
+ with `HTMLSerializer`. The state is also embedded as `window.__FUNICULAR_STATE__`
92
+ so the browser can hydrate the markup rather than rebuild it. Keep `render`
93
+ deterministic and free of browser-only calls so the same code is safe on both
94
+ sides.
95
+
96
+ ## Development
97
+
98
+ This repository is a submodule of
99
+ [picoruby/picoruby](https://github.com/picoruby/picoruby). Do not check it out
100
+ standalone; clone the parent and work from there:
101
+
102
+ ```console
103
+ git clone --recurse-submodules https://github.com/picoruby/picoruby.git
104
+ cd picoruby/mrbgems/picoruby-funicular
371
105
  ```
372
106
 
373
- **No Build Step**: Instant development feedback
374
-
375
- **Rails Integration**: CSRF tokens, ActiveRecord-style APIs
376
-
377
- ✅ **Lightweight Dependencies**: Minimal gem dependencies
378
-
379
- ### Limitations
380
-
381
- ❌ **No SSR**: PicoRuby.wasm is browser-only (for now...)
382
-
383
- ❌ **No npm Ecosystem**: Limited to Ruby gems
384
-
385
- ❌ **State Management**: No global store (can be complex at scale)
386
-
387
- ❌ **Performance Overhead**: WebAssembly Ruby execution slower than native JS
388
-
389
- ## Best Use Cases
390
-
391
- ### ✅ Well-Suited For:
392
-
393
- - **Rails SPA Features**: Adding interactive UI to Rails apps
394
- - **Small to Medium SPAs**: Dashboard, admin panels, chat apps
395
- - **Ruby Teams**: Frontend work by Ruby developers
396
- - **Rapid Prototyping**: Quick interactive UI development
397
-
398
- ### ❌ Not Ideal For:
399
-
400
- - **SEO-Critical Apps**: Needs SSR (use Rails views or Next.js)
401
- - **Large-Scale SPAs**: Complex state management requirements
402
- - **Mobile Apps**: Use React Native or native solutions
403
- - **Performance-Critical**: Real-time games, high-frequency updates
107
+ The CRubyGem side (`lib/`, `funicular.gemspec`) can be developed and tested
108
+ independently inside that directory, but `rake copy_wasm` relies on sibling
109
+ directories within the picoruby repository and fails from a standalone checkout.
404
110
 
405
- ## Conclusion
111
+ PicoGem dependencies are declared in `mrbgem.rake` (picoruby-wasm,
112
+ picoruby-indexeddb, picoruby-json, and the mruby `*-ext` gems).
406
113
 
407
- Funicular adopts proven patterns from React (unidirectional flow, VDOM) while providing unique value through **Ruby ecosystem integration**. It's not trying to replace JavaScript frameworks for all use cases, but rather offers a compelling alternative for Ruby teams building interactive web applications.
114
+ ## Testing
408
115
 
409
- The architecture prioritizes **simplicity, predictability, and Rails integration** over features like maximum performance. This makes it an excellent choice for its target use cases: Ruby teams building SPAs as part of Rails applications.
116
+ - CRubyGem (Rails integration): `rake test` in this repository.
117
+ - PicoGem runtime: `rake test:gems:picoruby[picoruby-funicular]` in the parent
118
+ picoruby repository, where `mrbgems/picoruby-funicular` exists as a submodule.
@@ -0,0 +1,23 @@
1
+ /* Funicular base styles.
2
+ *
3
+ * Injected into the page by picoruby_include_tag so that class names emitted
4
+ * from inside the gem (which the host app's CSS pipeline never sees -- e.g.
5
+ * Tailwind only scans the app's own sources) still render. Keep this minimal
6
+ * and namespaced under .funicular-* so it cannot clash with app styles.
7
+ *
8
+ * Apps that prefer their own utilities can override per form via
9
+ * form_for(..., field_error_class: "...", error_class: "..."). */
10
+
11
+ .funicular-field-error {
12
+ border-color: #ef4444 !important; /* red-500, wins over a base border color */
13
+ background-color: #fef2f2; /* red-50 */
14
+ box-shadow: 0 0 0 1px #ef4444;
15
+ }
16
+
17
+ .funicular-error {
18
+ margin-top: 0.25rem;
19
+ color: #dc2626; /* red-600 */
20
+ font-size: 0.875rem;
21
+ line-height: 1.25rem;
22
+ font-weight: 500;
23
+ }