funicular 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- metadata +119 -8
data/demo/test_vdom.html
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>Test VDOM - PicoRuby Funicular</title>
|
|
6
|
+
<link rel="stylesheet" href="../test.css">
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div><a href="../index.html">Back</a></div>
|
|
10
|
+
<h1>VDOM Test Suite</h1>
|
|
11
|
+
<div id="test-results"></div>
|
|
12
|
+
<div id="container"></div>
|
|
13
|
+
|
|
14
|
+
<script type="text/ruby">
|
|
15
|
+
require 'js'
|
|
16
|
+
require 'funicular'
|
|
17
|
+
|
|
18
|
+
doc = JS.document
|
|
19
|
+
results = doc.getElementById('test-results')
|
|
20
|
+
container = doc.getElementById('container')
|
|
21
|
+
|
|
22
|
+
def test_case(name, &block)
|
|
23
|
+
doc = JS.document
|
|
24
|
+
results = doc.getElementById('test-results')
|
|
25
|
+
div = doc.createElement('div')
|
|
26
|
+
div.className = 'test-case'
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
block.call
|
|
30
|
+
div.className = 'test-case pass'
|
|
31
|
+
div.textContent = "PASS: #{name}"
|
|
32
|
+
rescue => e
|
|
33
|
+
div.className = 'test-case fail'
|
|
34
|
+
div.textContent = "FAIL: #{name} - #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
results.appendChild(div)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test_case("Create Element VNode") do
|
|
41
|
+
vnode = Funicular::VDOM.create_element('div', { id: 'test' }, 'Hello')
|
|
42
|
+
raise "Element creation failed" unless vnode.is_a?(Funicular::VDOM::Element)
|
|
43
|
+
raise "Tag mismatch" unless vnode.tag == 'div'
|
|
44
|
+
raise "Props mismatch" unless vnode.props[:id] == 'test'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
test_case("Create Text VNode") do
|
|
48
|
+
vnode = Funicular::VDOM.create_text('Hello World')
|
|
49
|
+
raise "Text creation failed" unless vnode.is_a?(Funicular::VDOM::Text)
|
|
50
|
+
raise "Content mismatch" unless vnode.content == 'Hello World'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
test_case("Render simple element") do
|
|
54
|
+
container = doc.getElementById('container')
|
|
55
|
+
vnode = Funicular::VDOM.create_element('p', {}, 'Test paragraph')
|
|
56
|
+
Funicular::VDOM.render(vnode, container)
|
|
57
|
+
raise "Rendering failed" unless container.children.length.to_i > 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
test_case("Render element with attributes") do
|
|
61
|
+
container = doc.getElementById('container')
|
|
62
|
+
vnode = Funicular::VDOM.create_element('div', { id: 'test-id', class: 'test-class' })
|
|
63
|
+
Funicular::VDOM.render(vnode, container)
|
|
64
|
+
child = container.children[0]
|
|
65
|
+
raise "Attribute not set" unless child && child[:id].to_s == 'test-id'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
test_case("Render nested elements") do
|
|
69
|
+
container = doc.getElementById('container')
|
|
70
|
+
vnode = Funicular::VDOM.create_element(
|
|
71
|
+
'div',
|
|
72
|
+
{},
|
|
73
|
+
Funicular::VDOM.create_element('h2', {}, 'Title'),
|
|
74
|
+
Funicular::VDOM.create_element('p', {}, 'Content')
|
|
75
|
+
)
|
|
76
|
+
Funicular::VDOM.render(vnode, container)
|
|
77
|
+
child = container.children[0]
|
|
78
|
+
raise "Nested rendering failed" unless child && child[:children].length.to_i == 2
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
test_case("Render text nodes") do
|
|
82
|
+
container = doc.getElementById('container')
|
|
83
|
+
vnode = Funicular::VDOM.create_element(
|
|
84
|
+
'p',
|
|
85
|
+
{},
|
|
86
|
+
Funicular::VDOM.create_text('Plain text')
|
|
87
|
+
)
|
|
88
|
+
Funicular::VDOM.render(vnode, container)
|
|
89
|
+
child = container.children[0]
|
|
90
|
+
raise "Text rendering failed" unless child && child[:textContent].to_s == 'Plain text'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
summary = doc.createElement('h2')
|
|
94
|
+
summary.textContent = 'All tests completed!'
|
|
95
|
+
summary.style = 'color: green; margin-top: 20px;'
|
|
96
|
+
results.appendChild(summary)
|
|
97
|
+
</script>
|
|
98
|
+
<script src="../init.iife.js"></script>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>Tic-Tac-Toe - Funicular</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<div><a href="../index.html">Back</a></div>
|
|
9
|
+
<h1>Tic-Tac-Toe (Funicular)</h1>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<p>This app is ported from the ReactJS Tic-Tac-Toe tutorial: <a href="https://react.dev/learn/tutorial-tic-tac-toe" target="_blank">https://react.dev/learn/tutorial-tic-tac-toe</a></p>
|
|
12
|
+
|
|
13
|
+
<script type="text/ruby">
|
|
14
|
+
def calculate_winner(squares)
|
|
15
|
+
lines = [
|
|
16
|
+
[0, 1, 2],
|
|
17
|
+
[3, 4, 5],
|
|
18
|
+
[6, 7, 8],
|
|
19
|
+
[0, 3, 6],
|
|
20
|
+
[1, 4, 7],
|
|
21
|
+
[2, 5, 8],
|
|
22
|
+
[0, 4, 8],
|
|
23
|
+
[2, 4, 6],
|
|
24
|
+
]
|
|
25
|
+
lines.each do |line|
|
|
26
|
+
a, b, c = line
|
|
27
|
+
if squares[a] && squares[a] == squares[b] && squares[a] == squares[c]
|
|
28
|
+
return squares[a]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Square < Funicular::Component
|
|
35
|
+
def handle_click(event)
|
|
36
|
+
event.preventDefault
|
|
37
|
+
on_click = props[:on_click]
|
|
38
|
+
on_click.call if on_click
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def render
|
|
42
|
+
button(class: 'square', onclick: :handle_click) do
|
|
43
|
+
props[:value] || ''
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Board < Funicular::Component
|
|
49
|
+
def handle_click(i)
|
|
50
|
+
-> {
|
|
51
|
+
squares = props[:squares]
|
|
52
|
+
return if calculate_winner(squares) || squares[i]
|
|
53
|
+
on_play = props[:on_play]
|
|
54
|
+
on_play.call(i) if on_play
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render
|
|
59
|
+
squares = props[:squares]
|
|
60
|
+
x_is_next = props[:x_is_next]
|
|
61
|
+
|
|
62
|
+
winner = calculate_winner(squares)
|
|
63
|
+
status = if winner
|
|
64
|
+
"Winner: #{winner}"
|
|
65
|
+
else
|
|
66
|
+
"Next player: #{x_is_next ? 'X' : 'O'}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
div do
|
|
70
|
+
div(class: 'status') { status }
|
|
71
|
+
div(class: 'board-row') do
|
|
72
|
+
component(Square, value: squares[0], on_click: handle_click(0))
|
|
73
|
+
component(Square, value: squares[1], on_click: handle_click(1))
|
|
74
|
+
component(Square, value: squares[2], on_click: handle_click(2))
|
|
75
|
+
end
|
|
76
|
+
div(class: 'board-row') do
|
|
77
|
+
component(Square, value: squares[3], on_click: handle_click(3))
|
|
78
|
+
component(Square, value: squares[4], on_click: handle_click(4))
|
|
79
|
+
component(Square, value: squares[5], on_click: handle_click(5))
|
|
80
|
+
end
|
|
81
|
+
div(class: 'board-row') do
|
|
82
|
+
component(Square, value: squares[6], on_click: handle_click(6))
|
|
83
|
+
component(Square, value: squares[7], on_click: handle_click(7))
|
|
84
|
+
component(Square, value: squares[8], on_click: handle_click(8))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class Game < Funicular::Component
|
|
91
|
+
def initialize_state
|
|
92
|
+
{
|
|
93
|
+
history: [Array.new(9, nil)],
|
|
94
|
+
current_move: 0
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle_play(i)
|
|
99
|
+
history = state.history
|
|
100
|
+
current_move = state.current_move
|
|
101
|
+
current_squares = history[current_move].dup
|
|
102
|
+
|
|
103
|
+
current_squares[i] = (current_move % 2 == 0) ? 'X' : 'O'
|
|
104
|
+
|
|
105
|
+
next_history = history[0..current_move] + [current_squares]
|
|
106
|
+
patch(
|
|
107
|
+
history: next_history,
|
|
108
|
+
current_move: next_history.length - 1
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def jump_to(move)
|
|
113
|
+
-> {
|
|
114
|
+
patch(current_move: move)
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def render
|
|
119
|
+
history = state.history
|
|
120
|
+
current_move = state.current_move
|
|
121
|
+
x_is_next = (current_move % 2 == 0)
|
|
122
|
+
current_squares = history[current_move]
|
|
123
|
+
|
|
124
|
+
div(class: 'game') do
|
|
125
|
+
div(class: 'game-board') do
|
|
126
|
+
component(Board,
|
|
127
|
+
x_is_next: x_is_next,
|
|
128
|
+
squares: current_squares,
|
|
129
|
+
on_play: ->(i) { handle_play(i) }
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
div(class: 'game-info') do
|
|
133
|
+
ol do
|
|
134
|
+
history.each_with_index do |_squares, move|
|
|
135
|
+
description = if move > 0
|
|
136
|
+
"Go to move ##{move}"
|
|
137
|
+
else
|
|
138
|
+
'Go to game start'
|
|
139
|
+
end
|
|
140
|
+
li(key: move) do
|
|
141
|
+
button(onclick: jump_to(move)) { description }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
puts "Starting Tic-Tac-Toe..."
|
|
151
|
+
Funicular.start(Game, container: 'app')
|
|
152
|
+
puts "Game started!"
|
|
153
|
+
</script>
|
|
154
|
+
<script src="../init.iife.js"></script>
|
|
155
|
+
</body>
|
|
156
|
+
<style>
|
|
157
|
+
* {
|
|
158
|
+
box-sizing: border-box;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
body {
|
|
162
|
+
font-family: sans-serif;
|
|
163
|
+
margin: 20px;
|
|
164
|
+
padding: 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.square {
|
|
168
|
+
background: #fff;
|
|
169
|
+
border: 1px solid #999;
|
|
170
|
+
float: left;
|
|
171
|
+
font-size: 24px;
|
|
172
|
+
font-weight: bold;
|
|
173
|
+
line-height: 34px;
|
|
174
|
+
height: 34px;
|
|
175
|
+
margin-right: -1px;
|
|
176
|
+
margin-top: -1px;
|
|
177
|
+
padding: 0;
|
|
178
|
+
text-align: center;
|
|
179
|
+
width: 34px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.board-row:after {
|
|
183
|
+
clear: both;
|
|
184
|
+
content: '';
|
|
185
|
+
display: table;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.status {
|
|
189
|
+
margin-bottom: 10px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.game {
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: row;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.game-info {
|
|
198
|
+
margin-left: 20px;
|
|
199
|
+
}
|
|
200
|
+
</style>
|
|
201
|
+
</html>
|
data/docs/README.md
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# Funicular
|
|
2
|
+
|
|
3
|
+
Funicular is named after a cable-driven railway, it lets you build apps using pure Ruby, with no JavaScript or HTML required.[^1]
|
|
4
|
+
|
|
5
|
+
[^1]: 子曰知止而后有定 / Confucius, *When one knows where to stop, one can be steady.*
|
|
6
|
+
|
|
7
|
+
## Architecture Overview
|
|
8
|
+
|
|
9
|
+
Funicular is a **unidirectional, Virtual DOM-based SPA framework** that adopts design patterns similar to React while embracing Ruby's expressiveness and integrating seamlessly with Rails.
|
|
10
|
+
|
|
11
|
+
### Core Design Decisions
|
|
12
|
+
|
|
13
|
+
| Aspect | Choice | Philosophy |
|
|
14
|
+
|--------|--------|------------|
|
|
15
|
+
| **Data Flow** | Unidirectional | Explicit state updates via `patch()` for predictability |
|
|
16
|
+
| **Rendering** | Virtual DOM + Diffing | Efficient DOM updates, declarative UI |
|
|
17
|
+
| **Components** | Class-based | Clear organization with lifecycle hooks |
|
|
18
|
+
| **State Management** | Local + Props | Simple by default, no global store |
|
|
19
|
+
| **Routing** | Client-side (History API) | SPA navigation with Rails-style DSL |
|
|
20
|
+
| **Templates** | Ruby DSL | Full Ruby expressiveness, no JSX |
|
|
21
|
+
| **Build Strategy** | Runtime (No build step) | Instant feedback during development |
|
|
22
|
+
| **Reactivity** | Explicit | Manual `patch()` calls, no auto-tracking magic |
|
|
23
|
+
|
|
24
|
+
### Framework Comparison
|
|
25
|
+
|
|
26
|
+
**Funicular vs React**:
|
|
27
|
+
- Similar: Unidirectional flow, VDOM, component-based
|
|
28
|
+
- Different: Ruby DSL instead of JSX, no build step, Rails integration
|
|
29
|
+
|
|
30
|
+
**Funicular vs Vue**:
|
|
31
|
+
- Similar: Component-based, developer-friendly
|
|
32
|
+
- Different: Unidirectional (not v-model), explicit updates (not reactive proxy)
|
|
33
|
+
|
|
34
|
+
**Key Differentiator**: Pure Ruby browser applications with zero JavaScript, powered by PicoRuby.wasm.
|
|
35
|
+
|
|
36
|
+
**Read more**: [Architecture Deep Dive](docs/architecture.md)
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **[Pure Ruby Browser App](#pure-ruby-browser-app)** - Write frontend code entirely in Ruby
|
|
41
|
+
- **[Object-REST Mapper](#object-rest-mapper-orm)** - ActiveRecord-style API for REST backends
|
|
42
|
+
- **[ActionCable WebSocket](#actioncable-compatible-websocket)** - Real-time features with Rails integration
|
|
43
|
+
- **[Rails Integration](#rails-integration)** - Seamless Rails API communication with CSRF handling
|
|
44
|
+
- **[CSS-in-Ruby](#css-in-ruby-with-styles-dsl)** - Scoped styles with conditional variants
|
|
45
|
+
- **[Form Builder](#rails-style-form-builder)** - Rails-style forms with validation errors
|
|
46
|
+
- **[Routing & Navigation](#routing-and-navigation)** - Client-side routing with URL helpers
|
|
47
|
+
- **[Error Boundary](#error-boundary)** - Graceful error handling for component failures
|
|
48
|
+
- **[Suspense](#suspense--loading-state)** - Declarative async data loading with loading states
|
|
49
|
+
- **[JS Integration](#js-integration)** - Delegation model for Chart.js, D3.js, etc.
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
### Hello World
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class Counter < Funicular::Component
|
|
57
|
+
def initialize_state
|
|
58
|
+
{ count: 0 }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def increment
|
|
62
|
+
patch(count: state.count + 1)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def decrement
|
|
66
|
+
patch(count: state.count - 1)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render
|
|
70
|
+
div do
|
|
71
|
+
h1 { "Counter" }
|
|
72
|
+
p { "Current count: #{state.count}" }
|
|
73
|
+
button(onclick: :increment) { "Increment" }
|
|
74
|
+
button(onclick: :decrement) { "Decrement" }
|
|
75
|
+
# Or use inline lambda for simple logic
|
|
76
|
+
# button(onclick: -> { patch(count: state.count + 1) }) { "Increment" }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Funicular.start(Counter, container: "app")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### With Router
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
Funicular.start(container: 'app') do |router|
|
|
88
|
+
router.get('/login', to: LoginComponent, as: 'login')
|
|
89
|
+
router.get('/dashboard', to: DashboardComponent, as: 'dashboard')
|
|
90
|
+
router.set_default('/login')
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Pure Ruby Browser App
|
|
95
|
+
|
|
96
|
+
Funicular is a component-based frontend framework that allows you to build browser applications entirely in Ruby, powered by PicoRuby.wasm. It uses a Virtual DOM (VDOM) to efficiently update the UI.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class TodoApp < Funicular::Component
|
|
100
|
+
def initialize_state
|
|
101
|
+
{ todos: [], input: "" }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def handle_input(event)
|
|
105
|
+
patch(input: event.target[:value])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_add
|
|
109
|
+
new_todo = { id: Time.now.to_i, text: state.input, done: false }
|
|
110
|
+
patch(todos: state.todos + [new_todo], input: "")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render
|
|
114
|
+
div do
|
|
115
|
+
h1 { "Todo List" }
|
|
116
|
+
input(
|
|
117
|
+
value: state.input,
|
|
118
|
+
oninput: :handle_input
|
|
119
|
+
)
|
|
120
|
+
button(onclick: :handle_add) { "Add" }
|
|
121
|
+
|
|
122
|
+
state.todos.each do |todo|
|
|
123
|
+
div(key: todo[:id]) { todo[:text] }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Learn more**: [Components and State Management](docs/components-and-state.md)
|
|
131
|
+
|
|
132
|
+
## Object-REST Mapper (O-R-M)
|
|
133
|
+
|
|
134
|
+
Funicular includes a built-in Object-REST Mapper that provides an ActiveRecord-like interface for interacting with REST APIs.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class User < Funicular::Model
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Fetch all users
|
|
141
|
+
User.all do |users, error|
|
|
142
|
+
patch(users: users)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Find specific user
|
|
146
|
+
User.find(123) do |user, error|
|
|
147
|
+
patch(user: user)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Create user
|
|
151
|
+
User.create(name: "Alice", email: "alice@example.com") do |user, errors|
|
|
152
|
+
if errors
|
|
153
|
+
patch(errors: errors)
|
|
154
|
+
else
|
|
155
|
+
patch(user: user, success: "User created!")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Update user
|
|
160
|
+
user.update(email: "newemail@example.com") do |updated_user, errors|
|
|
161
|
+
patch(user: updated_user)
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Learn more**: [Data Fetching](docs/data-fetching.md)
|
|
166
|
+
|
|
167
|
+
## ActionCable-compatible WebSocket
|
|
168
|
+
|
|
169
|
+
Real-time features powered by ActionCable-compatible WebSocket client.
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
class ChatComponent < Funicular::Component
|
|
173
|
+
def component_mounted
|
|
174
|
+
consumer = Funicular::Cable.create_consumer("/cable")
|
|
175
|
+
|
|
176
|
+
@subscription = consumer.subscriptions.create(
|
|
177
|
+
channel: "ChatChannel",
|
|
178
|
+
room: "lobby"
|
|
179
|
+
) do |message|
|
|
180
|
+
patch(messages: state.messages + [message])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def handle_input(event)
|
|
185
|
+
patch(input: event.target[:value])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def handle_send
|
|
189
|
+
@subscription.perform("speak", message: state.input)
|
|
190
|
+
patch(input: "")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def render
|
|
194
|
+
div do
|
|
195
|
+
state.messages.each { |msg| div { msg["content"] } }
|
|
196
|
+
input(value: state.input, oninput: :handle_input)
|
|
197
|
+
button(onclick: :handle_send) { "Send" }
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Learn more**: [ActionCable Integration](docs/realtime.md)
|
|
204
|
+
|
|
205
|
+
## Rails Integration
|
|
206
|
+
|
|
207
|
+
- **CSRF Token Handling**: Automatic inclusion in POST/PATCH/PUT/DELETE requests
|
|
208
|
+
- **ActionCable Compatible**: Real-time features with Rails channels
|
|
209
|
+
- **Schema Loading**: Dynamic model attribute definitions from Rails API
|
|
210
|
+
- **Zero JS Workflow**: Full-stack Ruby development
|
|
211
|
+
|
|
212
|
+
**Learn more**: [Data Fetching](docs/data-fetching.md), [Real-time Features](docs/realtime.md)
|
|
213
|
+
|
|
214
|
+
## CSS-in-Ruby with Styles DSL
|
|
215
|
+
|
|
216
|
+
Keep your styles organized and scoped within each component.
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
class LoginComponent < Funicular::Component
|
|
220
|
+
styles do
|
|
221
|
+
container "min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600"
|
|
222
|
+
card "bg-white p-8 rounded-lg shadow-2xl w-96"
|
|
223
|
+
title "text-3xl font-bold text-center mb-8 text-gray-800"
|
|
224
|
+
|
|
225
|
+
# Conditional styles
|
|
226
|
+
button base: "px-4 py-2 rounded font-semibold",
|
|
227
|
+
variants: {
|
|
228
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
|
229
|
+
danger: "bg-red-600 text-white hover:bg-red-700"
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def render
|
|
234
|
+
div(class: s.container) do
|
|
235
|
+
div(class: s.card) do
|
|
236
|
+
h1(class: s.title) { "Welcome" }
|
|
237
|
+
button(class: s.button(:primary)) { "Login" }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Learn more**: [Styling Guide](docs/styling.md)
|
|
245
|
+
|
|
246
|
+
## Rails-style Form Builder
|
|
247
|
+
|
|
248
|
+
Automatic form state management and error display.
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
class SignupComponent < Funicular::Component
|
|
252
|
+
def initialize_state
|
|
253
|
+
{ user: { username: "", email: "" }, errors: {} }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def handle_submit(form_data)
|
|
257
|
+
User.create(form_data) do |user, errors|
|
|
258
|
+
if errors
|
|
259
|
+
patch(errors: errors)
|
|
260
|
+
else
|
|
261
|
+
Funicular.router.navigate('/dashboard')
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def render
|
|
267
|
+
form_for(:user, on_submit: :handle_submit) do |f|
|
|
268
|
+
f.label(:username)
|
|
269
|
+
f.text_field(:username, autofocus: true)
|
|
270
|
+
|
|
271
|
+
f.label(:email)
|
|
272
|
+
f.email_field(:email)
|
|
273
|
+
|
|
274
|
+
f.submit("Sign Up")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Learn more**: [Forms Guide](docs/forms.md)
|
|
281
|
+
|
|
282
|
+
## Routing and Navigation
|
|
283
|
+
|
|
284
|
+
Client-side routing with Rails-style DSL and URL helpers.
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
Funicular.start(container: 'app') do |router|
|
|
288
|
+
router.get('/users/:id', to: UserProfileComponent, as: 'user')
|
|
289
|
+
router.get('/settings', to: SettingsComponent, as: 'settings')
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Use URL helpers
|
|
293
|
+
include Funicular::RouteHelpers
|
|
294
|
+
|
|
295
|
+
link_to user_path(user), navigate: true do
|
|
296
|
+
span { user.name }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
link_to settings_path, navigate: true, class: "nav-link" do
|
|
300
|
+
span { "Settings" }
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Learn more**: [Routing and Navigation](docs/routing-and-navigation.md)
|
|
305
|
+
|
|
306
|
+
## Error Boundary
|
|
307
|
+
|
|
308
|
+
Catch errors from child components and display fallback UI.
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
component(Funicular::ErrorBoundary,
|
|
312
|
+
fallback: ->(error) {
|
|
313
|
+
div(class: "error") do
|
|
314
|
+
h3 { "Oops! Something went wrong" }
|
|
315
|
+
p { "Error: #{error.message}" }
|
|
316
|
+
end
|
|
317
|
+
}
|
|
318
|
+
) do
|
|
319
|
+
component(RiskyComponent)
|
|
320
|
+
end
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Learn more**: [Advanced Features - Error Boundary](docs/advanced-features.md#error-boundary)
|
|
324
|
+
|
|
325
|
+
## Suspense / Loading State
|
|
326
|
+
|
|
327
|
+
Declarative async data loading with loading states.
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
class UserProfile < Funicular::Component
|
|
331
|
+
use_suspense :user,
|
|
332
|
+
->(resolve, reject) {
|
|
333
|
+
User.find(props[:id]) do |user, error|
|
|
334
|
+
error ? reject.call(error) : resolve.call(user)
|
|
335
|
+
end
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
def render
|
|
339
|
+
suspense(
|
|
340
|
+
fallback: -> { div { "Loading..." } },
|
|
341
|
+
error: ->(e) { div { "Failed: #{e}" } }
|
|
342
|
+
) do
|
|
343
|
+
div do
|
|
344
|
+
h1 { user.name }
|
|
345
|
+
p { user.email }
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Learn more**: [Data Fetching - Suspense](docs/data-fetching.md#suspense--loading-states)
|
|
353
|
+
|
|
354
|
+
## JS Integration
|
|
355
|
+
|
|
356
|
+
Delegation model for integrating JavaScript libraries (Chart.js, D3.js, etc.).
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
class ChartComponent < Funicular::Component
|
|
360
|
+
def component_mounted
|
|
361
|
+
canvas = refs[:chart_canvas]
|
|
362
|
+
@chart = JS.global.Chart.new(canvas, {
|
|
363
|
+
type: 'bar',
|
|
364
|
+
data: { labels: state.labels, datasets: [...] }
|
|
365
|
+
})
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def component_unmounted
|
|
369
|
+
@chart&.destroy()
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def render
|
|
373
|
+
div { canvas(ref: :chart_canvas) }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Learn more**: [Advanced Features - JS Integration](docs/advanced-features.md#js-integration-via-delegation-model)
|
|
379
|
+
|
|
380
|
+
## Documentation
|
|
381
|
+
|
|
382
|
+
### Architecture
|
|
383
|
+
- [**Architecture Deep Dive**](docs/architecture.md) - Design decisions, comparisons, trade-offs
|
|
384
|
+
|
|
385
|
+
### Core Concepts
|
|
386
|
+
- [**Components and State**](docs/components-and-state.md) - Component lifecycle, state management, props
|
|
387
|
+
- [**Styling**](docs/styling.md) - CSS-in-Ruby Styles DSL, conditional styles, variants
|
|
388
|
+
- [**Forms**](docs/forms.md) - Form builder, validation, error handling
|
|
389
|
+
|
|
390
|
+
### Features
|
|
391
|
+
- [**Routing and Navigation**](docs/routing-and-navigation.md) - Router, URL helpers, link_to
|
|
392
|
+
- [**Data Fetching**](docs/data-fetching.md) - HTTP client, Model (O-R-M), Suspense
|
|
393
|
+
- [**Real-time**](docs/realtime.md) - ActionCable WebSocket integration
|
|
394
|
+
|
|
395
|
+
### Advanced
|
|
396
|
+
- [**Advanced Features**](docs/advanced-features.md) - Error Boundary, CSS Transitions, JS Integration
|
|
397
|
+
|
|
398
|
+
### Related Documentation
|
|
399
|
+
- [picoruby-wasm/docs/callback.md](../picoruby-wasm/docs/callback.md) - Callback system
|
|
400
|
+
- [picoruby-wasm/docs/interoperability_between_js_and_ruby.md](../picoruby-wasm/docs/interoperability_between_js_and_ruby.md) - JS::Bridge for Ruby/JS interop
|
|
401
|
+
|
|
402
|
+
## Best Use Cases
|
|
403
|
+
|
|
404
|
+
### Well-Suited For
|
|
405
|
+
|
|
406
|
+
- Rails applications with SPA features
|
|
407
|
+
- Small to medium SPAs (dashboards, admin panels, chat apps)
|
|
408
|
+
- Ruby teams doing frontend development
|
|
409
|
+
- Rapid prototyping of interactive UIs
|
|
410
|
+
|
|
411
|
+
### Not Ideal For
|
|
412
|
+
|
|
413
|
+
- SEO-critical applications (no SSR support for now, but planning...)
|
|
414
|
+
- Large-scale SPAs with complex state management needs
|
|
415
|
+
- Performance-critical applications (WebAssembly overhead)
|
|
416
|
+
- Mobile applications (use React Native or native solutions)
|
|
417
|
+
|
|
418
|
+
**Read more**: [Architecture - Trade-offs](docs/architecture.md#trade-offs)
|
|
419
|
+
|