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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42012485f28870d0316fa30ba1fbebd4699c07cb8e61e5d201874d2eafd50d04
|
|
4
|
+
data.tar.gz: ab7f7e473a32d0a00b8bca5b0c67fc7e7b91a9a7860724e53631ea7d08dbcc41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
58
|
+
User documentation is hosted on **picoruby.org**:
|
|
59
59
|
|
|
60
|
-
|
|
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
|
-
#
|
|
2
|
-
|
|
3
|
-
This document
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
##
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- `
|
|
69
|
-
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
111
|
+
PicoGem dependencies are declared in `mrbgem.rake` (picoruby-wasm,
|
|
112
|
+
picoruby-indexeddb, picoruby-json, and the mruby `*-ext` gems).
|
|
406
113
|
|
|
407
|
-
|
|
114
|
+
## Testing
|
|
408
115
|
|
|
409
|
-
|
|
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
|
+
}
|