funicular 0.0.1 → 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 +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -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/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -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 +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -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/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 +6423 -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/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 +32 -1
- 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 +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -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/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -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 +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -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>
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
|
105
|
+
```
|
|
106
|
+
|
|
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.
|
|
110
|
+
|
|
111
|
+
PicoGem dependencies are declared in `mrbgem.rake` (picoruby-wasm,
|
|
112
|
+
picoruby-indexeddb, picoruby-json, and the mruby `*-ext` gems).
|
|
113
|
+
|
|
114
|
+
## Testing
|
|
115
|
+
|
|
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.
|
data/exe/funicular
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "funicular"
|
|
6
|
+
|
|
7
|
+
command = ARGV[0]
|
|
8
|
+
|
|
9
|
+
case command
|
|
10
|
+
when "routes"
|
|
11
|
+
require_relative "../lib/funicular/commands/routes"
|
|
12
|
+
Funicular::Commands::Routes.new.execute
|
|
13
|
+
when "version", "-v", "--version"
|
|
14
|
+
puts Funicular::VERSION
|
|
15
|
+
when "help", "-h", "--help", nil
|
|
16
|
+
puts <<~HELP
|
|
17
|
+
Funicular CLI
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
funicular routes Show all Funicular routes
|
|
21
|
+
funicular version Show Funicular version
|
|
22
|
+
funicular help Show this help message
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
-h, --help Show help
|
|
26
|
+
-v, --version Show version
|
|
27
|
+
HELP
|
|
28
|
+
else
|
|
29
|
+
puts "Unknown command: #{command}"
|
|
30
|
+
puts "Run 'funicular help' for usage"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Exclude app/funicular from autoloading (PicoRuby.wasm code, not for CRuby)
|
|
4
|
+
Rails.autoloaders.main.ignore(Rails.root.join("app/funicular"))
|
|
5
|
+
|
|
6
|
+
# Choose where picoruby_include_tag loads PicoRuby.wasm from, per environment.
|
|
7
|
+
#
|
|
8
|
+
# Available sources:
|
|
9
|
+
# :local_debug - public/picoruby/debug/init.iife.js (debug build, larger, with symbols)
|
|
10
|
+
# :local_dist - public/picoruby/dist/init.iife.js (production build, smaller)
|
|
11
|
+
# :cdn - https://cdn.jsdelivr.net/npm/@picoruby/wasm-wasi@<version>/dist/init.iife.js
|
|
12
|
+
#
|
|
13
|
+
# Defaults:
|
|
14
|
+
# development -> :local_debug
|
|
15
|
+
# test -> :local_debug
|
|
16
|
+
# production -> :local_dist
|
|
17
|
+
#
|
|
18
|
+
# Funicular.configure do |config|
|
|
19
|
+
# config.production_source = :cdn
|
|
20
|
+
# # config.cdn_version = "4.0.0" # defaults to the version vendored in the gem
|
|
21
|
+
# end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* Funicular Component Highlighter - Development Mode Only */
|
|
2
|
+
/* Highlights components with data-component attributes for easier debugging */
|
|
3
|
+
|
|
4
|
+
.funicular-debug-highlight {
|
|
5
|
+
outline: 2px solid var(--funicular-highlight-color, #00ff00) !important;
|
|
6
|
+
outline-offset: -2px !important;
|
|
7
|
+
position: relative !important;
|
|
8
|
+
box-shadow: inset 0 0 0 2px rgba(0, 255, 0, 0.2) !important;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.funicular-debug-indicator {
|
|
12
|
+
position: absolute !important;
|
|
13
|
+
bottom: 0 !important;
|
|
14
|
+
right: 0 !important;
|
|
15
|
+
width: 0 !important;
|
|
16
|
+
height: 0 !important;
|
|
17
|
+
border-style: solid !important;
|
|
18
|
+
border-width: 0 0 14px 14px !important;
|
|
19
|
+
border-color: transparent transparent var(--funicular-highlight-color, #00ff00) transparent !important;
|
|
20
|
+
pointer-events: auto !important;
|
|
21
|
+
z-index: 999999 !important;
|
|
22
|
+
cursor: help !important;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.funicular-debug-indicator::before {
|
|
26
|
+
content: attr(data-tooltip) !important;
|
|
27
|
+
position: absolute !important;
|
|
28
|
+
bottom: -14px !important;
|
|
29
|
+
right: 0 !important;
|
|
30
|
+
background-color: rgba(0, 0, 0, 0.95) !important;
|
|
31
|
+
color: white !important;
|
|
32
|
+
padding: 6px 10px !important;
|
|
33
|
+
border-radius: 4px !important;
|
|
34
|
+
border: 1px solid var(--funicular-highlight-color, #00ff00) !important;
|
|
35
|
+
font-size: 13px !important;
|
|
36
|
+
font-family: 'Courier New', monospace !important;
|
|
37
|
+
white-space: nowrap !important;
|
|
38
|
+
opacity: 0 !important;
|
|
39
|
+
pointer-events: none !important;
|
|
40
|
+
transform: translateY(8px) !important;
|
|
41
|
+
transition: opacity 0.2s, transform 0.2s !important;
|
|
42
|
+
z-index: 1000000 !important;
|
|
43
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.5) !important;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.funicular-debug-indicator:hover::before {
|
|
47
|
+
opacity: 1 !important;
|
|
48
|
+
transform: translateY(0) !important;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* PicoRuby DevTools Selected Component Highlight */
|
|
52
|
+
.picoruby-devtools-selected {
|
|
53
|
+
position: relative !important;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.picoruby-devtools-selected::after {
|
|
57
|
+
content: '' !important;
|
|
58
|
+
position: absolute !important;
|
|
59
|
+
top: 0 !important;
|
|
60
|
+
left: 0 !important;
|
|
61
|
+
right: 0 !important;
|
|
62
|
+
bottom: 0 !important;
|
|
63
|
+
background-color: rgba(0, 122, 204, 0.1) !important;
|
|
64
|
+
background-image: repeating-linear-gradient(
|
|
65
|
+
45deg,
|
|
66
|
+
transparent,
|
|
67
|
+
transparent 10px,
|
|
68
|
+
rgba(0, 122, 204, 0.15) 10px,
|
|
69
|
+
rgba(0, 122, 204, 0.15) 20px
|
|
70
|
+
) !important;
|
|
71
|
+
pointer-events: none !important;
|
|
72
|
+
z-index: 999998 !important;
|
|
73
|
+
}
|