funicular 0.3.0 → 0.4.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 +57 -0
- data/demo/test_chartjs.html +9 -9
- data/demo/test_component.html +8 -8
- data/demo/test_error_boundary.html +44 -41
- data/demo/test_router.html +48 -48
- data/demo/tic-tac-toe.html +25 -25
- data/docs/architecture.md +46 -9
- data/lib/funicular/ssr/runtime.rb +1 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +613 -486
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +669 -449
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +921 -629
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
- data/lib/funicular/version.rb +1 -1
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
- data/minitest/dsl_test.rb +237 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
- data/minitest/form_for_test.rb +2 -2
- data/minitest/hydration_test.rb +2 -2
- data/minitest/sig_tags_test.rb +30 -0
- data/minitest/view_context_test.rb +15 -15
- data/mrblib/0_tags.rb +62 -0
- data/mrblib/component.rb +113 -23
- data/mrblib/error_boundary.rb +25 -19
- data/mrblib/form_builder.rb +10 -10
- data/mrblib/funicular.rb +1 -1
- data/mrblib/styles.rb +102 -12
- data/mrblib/view_context.rb +3 -32
- data/sig/component.rbs +18 -4
- data/sig/error_boundary.rbs +4 -4
- data/sig/styles.rbs +18 -5
- data/sig/tags.rbs +54 -0
- data/sig/view_context.rbs +47 -34
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5816e8e43d54e6bec4b6bb8a06da6165ab138e549cdbad649d2acba002a5fec4
|
|
4
|
+
data.tar.gz: 51ed5ca984f12669bfd1587bf384ea685c54e2d03dff9552d3cc49485d29081f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 360c39bdc7375ae5967c37f0b24437fa6d254f8814520ae984cc455195ac957b1279988a430f752e731eb40eab62cbeba178bc1ad08dc8836c7ce64c0d0810d5
|
|
7
|
+
data.tar.gz: 31bb97a1b6fbedbf3d1b1539c3e8ea5a8169c91095733c09d38f465c463a56071ecb766524945e307b66998708e09fff0ce0f6388e009f54b9d9945169922259
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
### Added
|
|
4
4
|
|
|
5
|
+
- 0.4.0 bareword component DSL: `render` (zero-arity) runs with `self` as
|
|
6
|
+
the component, so HTML tags, `component`, `form_for`, `link_to`,
|
|
7
|
+
`button_to`, `suspense`, `state`, `props`, `styles`, `resources`, and
|
|
8
|
+
`routes` are all called bareword, without the 0.3.0 `h.` receiver.
|
|
9
|
+
- DSL collision detection: tag and helper names are reserved inside
|
|
10
|
+
component classes. Defining one raises `Funicular::DSLCollisionError` at
|
|
11
|
+
class-definition time (`method_added`) or at first mount
|
|
12
|
+
(`validate_dsl_conflicts!`, covering `attr_*` on mruby and included
|
|
13
|
+
modules). `allow_dsl_override :name` opts out per class; the shadowed
|
|
14
|
+
element stays reachable via `tag(:name, ...)`.
|
|
15
|
+
- Bareword style definitions: the class-level `styles do ... end` block
|
|
16
|
+
runs on a `BasicObject` cleanroom builder, so any name (including
|
|
17
|
+
`display`, `hash`, ...) defines a style identically on mruby and CRuby.
|
|
18
|
+
The explicit `styles { |css| css.define(...) }` form remains for
|
|
19
|
+
computed values.
|
|
20
|
+
- Generated style accessors: each declared style name becomes a real
|
|
21
|
+
method on a per-component accessor, e.g. `styles.button(:disabled)`;
|
|
22
|
+
the `styles[:name, variant]` form is kept.
|
|
23
|
+
|
|
24
|
+
### Breaking Changes
|
|
25
|
+
|
|
26
|
+
- **0.4.0 is a breaking DSL change against 0.3.0. Components written for
|
|
27
|
+
0.3.0 migrate mechanically: delete the `render(h)` parameter, drop the
|
|
28
|
+
`h.` receivers, convert `css.define :name, "..."` to bareword
|
|
29
|
+
`name "..."`, and `h.styles[:name, variant]` to
|
|
30
|
+
`styles.name(variant)`.**
|
|
31
|
+
- `p` inside a component builds a `<p>` element. Debug with
|
|
32
|
+
`puts x.inspect`; a non-Hash argument to any tag raises `ArgumentError`
|
|
33
|
+
with a hint.
|
|
34
|
+
- A local variable named after a tag shadows the zero-paren call form
|
|
35
|
+
(plain Ruby scoping); write `option()` or rename the local.
|
|
36
|
+
- Tag and helper names (RESERVED_DSL) can no longer be defined as
|
|
37
|
+
component methods without `allow_dsl_override`.
|
|
38
|
+
- Style lookups of unknown names raise (`NoMethodError` for
|
|
39
|
+
`styles.typo`, `ArgumentError` for `styles[:typo]`) instead of
|
|
40
|
+
silently returning an empty class string. Style definition values are
|
|
41
|
+
validated (String / Hash / keyword options; unknown option keys raise).
|
|
42
|
+
- Tag helpers called while the component is not rendering raise
|
|
43
|
+
`Funicular::RenderContextError` instead of being silently dropped.
|
|
44
|
+
- `ErrorBoundary` `fallback:`/`error:` procs keep an explicit view
|
|
45
|
+
context (`->(h, error) { h.div { ... } }`): they are created in the
|
|
46
|
+
parent's scope but run during the boundary's render, so barewords
|
|
47
|
+
cannot work there by design.
|
|
48
|
+
- `Component#render_suspense` no longer takes a view context; suspense
|
|
49
|
+
`fallback:`/`error:`/content procs run bareword in their own component
|
|
50
|
+
(`fallback: -> { div { "Loading" } }`).
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Requires picoruby-wasm with `JS::Object < BasicObject` (picoruby
|
|
55
|
+
9e69333f): Kernel names (`hash`, `send`, `open`, ...) no longer shadow
|
|
56
|
+
JS property access, and unknown `?`/`!` methods on JS values raise.
|
|
57
|
+
|
|
58
|
+
## [0.3.0] - 2026-07-13
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
5
62
|
- 0.3.0 rendering architecture: `render(h)` now receives a `ViewContext`
|
|
6
63
|
facade for elements, components, forms, styles, resources, and routes.
|
|
7
64
|
- Per-app `Runtime` context for route helpers and renderer/serializer
|
data/demo/test_chartjs.html
CHANGED
|
@@ -118,17 +118,17 @@
|
|
|
118
118
|
puts "update_chart: Chart.js update('active') called"
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
def render
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
def render
|
|
122
|
+
div(id: "dashboard") do
|
|
123
|
+
h2 { "Sales Dashboard" }
|
|
124
|
+
div(id: "chart-container") do
|
|
125
|
+
canvas(ref: :chart_canvas, id: "chart")
|
|
126
126
|
end
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
div do
|
|
128
|
+
button(id: "prev-btn", onclick: :prev_data_set) { "Previous Quarter" }
|
|
129
|
+
button(id: "next-btn", onclick: :next_data_set) { "Next Quarter" }
|
|
130
130
|
end
|
|
131
|
-
|
|
131
|
+
div(id: "info") do
|
|
132
132
|
current = state[:data_sets][state[:month_index]]
|
|
133
133
|
"Current data: #{current[:labels].join(', ')}"
|
|
134
134
|
end
|
data/demo/test_component.html
CHANGED
|
@@ -48,17 +48,17 @@
|
|
|
48
48
|
patch(count: state[:count] - 1)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def render
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
def render
|
|
52
|
+
div(id: "counter-app") do
|
|
53
|
+
h2 { "Counter: #{state[:count]}" }
|
|
54
|
+
div(id: "status") do
|
|
55
55
|
"Mounted: #{state[:mounted]}"
|
|
56
56
|
end
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
div do
|
|
58
|
+
button(id: "increment-btn", onclick: :increment) { "+1" }
|
|
59
|
+
button(id: "decrement-btn", onclick: :decrement) { "-1" }
|
|
60
60
|
end
|
|
61
|
-
|
|
61
|
+
div(ref: :display, id: "display") do
|
|
62
62
|
"Current value: #{state[:count]}"
|
|
63
63
|
end
|
|
64
64
|
end
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
<script type="text/ruby">
|
|
92
92
|
# A component that always throws an error during render
|
|
93
93
|
class BrokenComponent < Funicular::Component
|
|
94
|
-
def render
|
|
94
|
+
def render
|
|
95
95
|
raise "BrokenComponent intentionally threw this error!"
|
|
96
96
|
end
|
|
97
97
|
end
|
|
@@ -107,80 +107,83 @@
|
|
|
107
107
|
patch(count: state[:count] + 1)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
def render
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
110
|
+
def render
|
|
111
|
+
div(class: 'demo-card') do
|
|
112
|
+
h3 { "Working Component" }
|
|
113
|
+
p { "Count: #{state[:count]}" }
|
|
114
|
+
button(onclick: :increment) { "Increment" }
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
# Main demo application
|
|
120
120
|
class ErrorBoundaryDemo < Funicular::Component
|
|
121
|
-
def render
|
|
121
|
+
def render
|
|
122
122
|
error_handler = ->(error, info) {
|
|
123
123
|
puts "[Demo] Error caught by ErrorBoundary:"
|
|
124
124
|
puts " Error: #{error.message}"
|
|
125
125
|
puts " Component: #{info&.dig(:component_class)}"
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
# NOTE: an ErrorBoundary fallback runs during the BOUNDARY's render,
|
|
129
|
+
# not this component's, so it cannot use bareword tags. It receives
|
|
130
|
+
# the boundary's view context and builds elements through it.
|
|
131
|
+
custom_fallback = ->(h, error) {
|
|
132
|
+
h.div(style: 'background: linear-gradient(135deg, #ff6b6b, #ee5a5a); color: white; padding: 20px; border-radius: 8px;') do
|
|
133
|
+
h.h3(style: 'margin-top: 0;') { "Custom Error Handler" }
|
|
134
|
+
h.p { "Caught: #{error.message}" }
|
|
135
|
+
h.p(style: 'font-size: 12px; opacity: 0.8;') { "This is a custom fallback provided via props." }
|
|
133
136
|
end
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
div do
|
|
140
|
+
h2 { "Error Boundary Demonstrations" }
|
|
138
141
|
|
|
139
142
|
# Demo 1: Default fallback
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
div(class: 'demo-card') do
|
|
144
|
+
h3 { "Demo 1: Default Fallback UI" }
|
|
145
|
+
p { "ErrorBoundary wraps a component that always throws. Default fallback is shown." }
|
|
146
|
+
component(Funicular::ErrorBoundary, on_error: error_handler) do
|
|
147
|
+
component(BrokenComponent)
|
|
145
148
|
end
|
|
146
149
|
end
|
|
147
150
|
|
|
148
151
|
# Demo 2: Custom fallback
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
div(class: 'demo-card') do
|
|
153
|
+
h3 { "Demo 2: Custom Fallback UI" }
|
|
154
|
+
p { "ErrorBoundary with a custom fallback prop." }
|
|
155
|
+
component(Funicular::ErrorBoundary,
|
|
153
156
|
fallback: custom_fallback,
|
|
154
157
|
on_error: error_handler
|
|
155
|
-
) do
|
|
156
|
-
|
|
158
|
+
) do
|
|
159
|
+
component(BrokenComponent)
|
|
157
160
|
end
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
# Demo 3: Mixed content - some work, some fail
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
div(class: 'demo-card') do
|
|
165
|
+
h3 { "Demo 3: Isolated Error Boundaries" }
|
|
166
|
+
p { "Each component has its own ErrorBoundary. One failure doesn't affect others." }
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
div(style: 'display: flex; gap: 15px; flex-wrap: wrap;') do
|
|
169
|
+
div(style: 'flex: 1; min-width: 200px;') do
|
|
170
|
+
h4 { "Working:" }
|
|
171
|
+
component(Funicular::ErrorBoundary) do
|
|
172
|
+
component(WorkingComponent)
|
|
170
173
|
end
|
|
171
174
|
end
|
|
172
175
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
div(style: 'flex: 1; min-width: 200px;') do
|
|
177
|
+
h4 { "Broken:" }
|
|
178
|
+
component(Funicular::ErrorBoundary) do
|
|
179
|
+
component(BrokenComponent)
|
|
177
180
|
end
|
|
178
181
|
end
|
|
179
182
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
div(style: 'flex: 1; min-width: 200px;') do
|
|
184
|
+
h4 { "Also Working:" }
|
|
185
|
+
component(Funicular::ErrorBoundary) do
|
|
186
|
+
component(WorkingComponent)
|
|
184
187
|
end
|
|
185
188
|
end
|
|
186
189
|
end
|
data/demo/test_router.html
CHANGED
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
}
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def render
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
def render
|
|
43
|
+
nav do
|
|
44
|
+
a(href: '/login', onclick: go_to('/login')) { 'Login' }
|
|
45
|
+
a(href: '/home', onclick: go_to('/home')) { 'Home' }
|
|
46
|
+
a(href: '/settings', onclick: go_to('/settings')) { 'Settings' }
|
|
47
|
+
a(href: '/users', onclick: go_to('/users')) { 'Users' }
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
end
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
Funicular.router.navigate('/home')
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
def render
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
def render
|
|
67
|
+
div(class: 'login-page') do
|
|
68
|
+
h2(class: 'page-title') { 'Login Page' }
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
div do
|
|
71
|
+
input(
|
|
72
72
|
type: 'text',
|
|
73
73
|
placeholder: 'Username',
|
|
74
74
|
value: state[:username],
|
|
@@ -76,11 +76,11 @@
|
|
|
76
76
|
)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
div do
|
|
80
|
+
button(onclick: :on_login_click) { 'Login' }
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
div(class: 'status') do
|
|
84
84
|
"Username: #{state[:username]}"
|
|
85
85
|
end
|
|
86
86
|
end
|
|
@@ -102,20 +102,20 @@
|
|
|
102
102
|
Funicular.router.navigate('/settings')
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
def render
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
def render
|
|
106
|
+
div(class: 'home-page') do
|
|
107
|
+
h2(class: 'page-title') { 'Home Page' }
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
div do
|
|
110
110
|
"Counter: #{state[:count]}"
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
div do
|
|
114
|
+
button(onclick: :increment) { 'Increment' }
|
|
115
|
+
button(onclick: :go_to_settings) { 'Go to Settings' }
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
div(class: 'status') do
|
|
119
119
|
"You are on the home page. Click around to test routing!"
|
|
120
120
|
end
|
|
121
121
|
end
|
|
@@ -143,25 +143,25 @@
|
|
|
143
143
|
Funicular.router.navigate('/home')
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
def render
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
def render
|
|
147
|
+
div(class: 'settings-page') do
|
|
148
|
+
h2(class: 'page-title') { 'Settings Page' }
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
div do
|
|
151
|
+
span { "Theme: #{state[:theme]}" }
|
|
152
|
+
button(onclick: :toggle_theme) { 'Toggle Theme' }
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
div do
|
|
156
|
+
span { "Notifications: #{state[:notifications] ? 'ON' : 'OFF'}" }
|
|
157
|
+
button(onclick: :toggle_notifications) { 'Toggle Notifications' }
|
|
158
158
|
end
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
div do
|
|
161
|
+
button(onclick: :go_to_home) { 'Back to Home' }
|
|
162
162
|
end
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
div(class: 'status') do
|
|
165
165
|
"Configure your settings here"
|
|
166
166
|
end
|
|
167
167
|
end
|
|
@@ -185,21 +185,21 @@
|
|
|
185
185
|
}
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
-
def render
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
def render
|
|
189
|
+
div(class: 'users-page') do
|
|
190
|
+
h2(class: 'page-title') { 'Users List' }
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
ul do
|
|
193
193
|
state[:users].each do |user|
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
li do
|
|
195
|
+
a(href: "/users/#{user[:id]}", onclick: go_to_user(user[:id])) do
|
|
196
196
|
user[:name]
|
|
197
197
|
end
|
|
198
198
|
end
|
|
199
199
|
end
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
div(class: 'status') do
|
|
203
203
|
"Click a user to see dynamic route with :id parameter"
|
|
204
204
|
end
|
|
205
205
|
end
|
|
@@ -216,19 +216,19 @@
|
|
|
216
216
|
Funicular.router.navigate('/users')
|
|
217
217
|
end
|
|
218
218
|
|
|
219
|
-
def render
|
|
220
|
-
|
|
221
|
-
|
|
219
|
+
def render
|
|
220
|
+
div(class: 'user-detail-page') do
|
|
221
|
+
h2(class: 'page-title') { "User Detail" }
|
|
222
222
|
|
|
223
|
-
|
|
223
|
+
div do
|
|
224
224
|
"Viewing user ID: #{state[:user_id]}"
|
|
225
225
|
end
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
div do
|
|
228
|
+
button(onclick: :go_back) { 'Back to Users' }
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
div(class: 'status') do
|
|
232
232
|
"This page demonstrates dynamic routing with params[:id] = #{state[:user_id]}"
|
|
233
233
|
end
|
|
234
234
|
end
|
data/demo/tic-tac-toe.html
CHANGED
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
on_click.call if on_click
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def render
|
|
42
|
-
|
|
41
|
+
def render
|
|
42
|
+
button(class: 'square', onclick: :handle_click) do
|
|
43
43
|
props[:value] || ''
|
|
44
44
|
end
|
|
45
45
|
end
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
}
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def render
|
|
58
|
+
def render
|
|
59
59
|
squares = props[:squares]
|
|
60
60
|
x_is_next = props[:x_is_next]
|
|
61
61
|
|
|
@@ -66,22 +66,22 @@
|
|
|
66
66
|
"Next player: #{x_is_next ? 'X' : 'O'}"
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
75
|
end
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
80
|
end
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
85
|
end
|
|
86
86
|
end
|
|
87
87
|
end
|
|
@@ -115,30 +115,30 @@
|
|
|
115
115
|
}
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
def render
|
|
118
|
+
def render
|
|
119
119
|
history = state[:history]
|
|
120
120
|
current_move = state[:current_move]
|
|
121
121
|
x_is_next = (current_move % 2 == 0)
|
|
122
122
|
current_squares = history[current_move]
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
div(class: 'game') do
|
|
125
|
+
div(class: 'game-board') do
|
|
126
|
+
component(Board,
|
|
127
127
|
x_is_next: x_is_next,
|
|
128
128
|
squares: current_squares,
|
|
129
129
|
on_play: ->(i) { handle_play(i) }
|
|
130
130
|
)
|
|
131
131
|
end
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
div(class: 'game-info') do
|
|
133
|
+
ol do
|
|
134
134
|
history.each_with_index do |_squares, move|
|
|
135
135
|
description = if move > 0
|
|
136
136
|
"Go to move ##{move}"
|
|
137
137
|
else
|
|
138
138
|
'Go to game start'
|
|
139
139
|
end
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
li(key: move) do
|
|
141
|
+
button(onclick: jump_to(move)) { description }
|
|
142
142
|
end
|
|
143
143
|
end
|
|
144
144
|
end
|
data/docs/architecture.md
CHANGED
|
@@ -30,7 +30,8 @@ stay free of browser-only calls on any server code path
|
|
|
30
30
|
|---------------------------------------------------------|-------------------------------------------------------------------------------------------------|
|
|
31
31
|
| `funicular.rb` | Top-level module: `start`, `router`, `server?`, `debug_color` export |
|
|
32
32
|
| `runtime.rb` | Per-app runtime context propagated through render/SSR/hydration |
|
|
33
|
-
| `
|
|
33
|
+
| `0_tags.rb` | Bareword tag DSL mixed into `Component`; reserved-name list and collision errors |
|
|
34
|
+
| `view_context.rb` | Internal element factory shared by the tag DSL, FormBuilder, and framework helpers |
|
|
34
35
|
| `component.rb` | `Funicular::Component` base: state, props, lifecycle, suspense loading, refs |
|
|
35
36
|
| `vdom.rb` | Virtual DOM nodes, including component vnodes with ordinary `children` |
|
|
36
37
|
| `differ.rb` | `Differ.diff(old, new)` -- minimal patch set, key-based list reconciliation |
|
|
@@ -41,25 +42,44 @@ stay free of browser-only calls on any server code path
|
|
|
41
42
|
| `http.rb` | Low-level fetch wrapper, CSRF, IndexedDB response cache |
|
|
42
43
|
| `cable.rb` | ActionCable-compatible consumer/subscription client |
|
|
43
44
|
| `store.rb`, `store_singleton.rb`, `store_collection.rb` | IndexedDB-backed stores, scope API, `subscribes_to`, event dispatch |
|
|
44
|
-
| `form_builder.rb` | `
|
|
45
|
+
| `form_builder.rb` | `form_for` field helpers with inline error rendering |
|
|
45
46
|
| `0_validations.rb`, `1_validators.rb` | ActiveModel-style validators and `errors` |
|
|
46
|
-
| `styles.rb` | CSS-in-Ruby `styles
|
|
47
|
+
| `styles.rb` | CSS-in-Ruby bareword `styles do ... end` builder and generated `styles.name` accessors |
|
|
47
48
|
| `error_boundary.rb` | `ErrorBoundary` component |
|
|
48
49
|
| `file_upload.rb` | File / FormData upload helper |
|
|
49
50
|
| `debug.rb` | Development-only component/error registry for the DevTools extension |
|
|
50
51
|
| `environment_inquirer.rb` | Environment detection (`server?`, `development?`) |
|
|
51
52
|
|
|
52
53
|
The render cycle: a state change calls `patch()`, which rebuilds the component's
|
|
53
|
-
VDOM by calling `render
|
|
54
|
+
VDOM by calling `render`, diffs it against the previous VDOM with `Differ`,
|
|
54
55
|
and applies the result with `Patcher`. Event handlers are native DOM listeners,
|
|
55
56
|
re-bound on each render.
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`
|
|
60
|
-
`
|
|
58
|
+
Inside `render` (zero-arity as of 0.4.0), `self` is the component, so the
|
|
59
|
+
DSL is bareword: HTML is authored as `div`, custom elements as
|
|
60
|
+
`tag(:custom_element)`, child components as `component`, forms as `form_for`,
|
|
61
|
+
styles as `styles.name(variant)` or `styles[:name]`, resources as
|
|
62
|
+
`resources[:name]`, and routes as `routes.user_path(id)`. Component state is
|
|
61
63
|
explicitly read with `state[:name]` or `state.fetch(:name)`.
|
|
62
64
|
|
|
65
|
+
Tag and helper names (~46 words) are reserved inside component classes:
|
|
66
|
+
defining one raises `DSLCollisionError` at class-definition time
|
|
67
|
+
(`method_added`) or at first mount (`validate_dsl_conflicts!`, which also
|
|
68
|
+
covers `attr_*` on mruby and included modules). `allow_dsl_override :name`
|
|
69
|
+
opts out per class; the shadowed element stays reachable via `tag(:name)`.
|
|
70
|
+
Two caveats are inherent to barewords: `p` builds a `<p>` element (use
|
|
71
|
+
`puts x.inspect` for debugging; non-Hash arguments raise with a hint), and
|
|
72
|
+
a local variable named after a tag shadows the zero-paren call form (write
|
|
73
|
+
`option()` or rename the local). Procs handed to ANOTHER component --
|
|
74
|
+
ErrorBoundary's `fallback:` -- run under that component's cursor and
|
|
75
|
+
therefore receive an explicit view context instead of barewords.
|
|
76
|
+
|
|
77
|
+
Style definitions are bareword too: the class-level `styles do ... end`
|
|
78
|
+
block runs on a BasicObject cleanroom builder, so any name (including
|
|
79
|
+
`display`, `hash`, ...) defines a style identically on mruby and CRuby.
|
|
80
|
+
Computed values need the explicit form `styles { |css| css.define(...) }`.
|
|
81
|
+
Unknown style lookups raise instead of returning an empty class string.
|
|
82
|
+
|
|
63
83
|
Component children are ordinary VDOM children stored on
|
|
64
84
|
`VDOM::Component#children`; there is no delayed `children_block` prop. This keeps
|
|
65
85
|
SSR, diffing, ErrorBoundary rendering, and hydration on the same data model.
|
|
@@ -96,6 +116,23 @@ SSR, diffing, ErrorBoundary rendering, and hydration on the same data model.
|
|
|
96
116
|
Because `copy_wasm` reads sibling directories inside the picoruby repository, it
|
|
97
117
|
only works from within that checkout -- see Development below.
|
|
98
118
|
|
|
119
|
+
## JavaScript interop contract
|
|
120
|
+
|
|
121
|
+
As of picoruby commit 9e69333f, `JS::Object` inherits `BasicObject` instead of
|
|
122
|
+
`Object`. Consequences for framework code:
|
|
123
|
+
|
|
124
|
+
- Dot access on JS values is reliable for names Kernel used to shadow
|
|
125
|
+
(`hash`, `send`, `open`, `class`, `method`, ...): they now reach the JS side
|
|
126
|
+
via `method_missing`.
|
|
127
|
+
- The Ruby protocol predicates `nil?`, `is_a?`, `kind_of?`, `instance_of?`, and
|
|
128
|
+
`respond_to?` are defined in C on `JS::Object` (a `?` suffix is illegal in a
|
|
129
|
+
JS identifier, so they can never shadow a JS property). `respond_to?` does a
|
|
130
|
+
real method-table lookup only; it does not report JS properties.
|
|
131
|
+
- Any other name ending in `?` or `!` raises `NoMethodError` instead of being
|
|
132
|
+
forwarded to JS, so typos fail loudly rather than silently returning nil.
|
|
133
|
+
- `==`, `to_s`, `inspect`, `[]`, `[]=`, `to_a`, and `typeof` are defined
|
|
134
|
+
directly on `JS::Object` and behave as before.
|
|
135
|
+
|
|
99
136
|
## Server-side rendering, briefly
|
|
100
137
|
|
|
101
138
|
For SSR the `mrblib/` framework is loaded into the Rails process under CRuby.
|
|
@@ -103,7 +140,7 @@ For SSR the `mrblib/` framework is loaded into the Rails process under CRuby.
|
|
|
103
140
|
`app/funicular/initializer.rb`, builds a `Runtime` around that router, builds the
|
|
104
141
|
component's VDOM, and serializes it with `HTMLSerializer`. The state is also
|
|
105
142
|
embedded as `window.__FUNICULAR_STATE__` so the browser can hydrate the markup
|
|
106
|
-
rather than rebuild it. Keep `render
|
|
143
|
+
rather than rebuild it. Keep `render` deterministic and free of browser-only
|
|
107
144
|
calls so the same code is safe on both sides.
|
|
108
145
|
|
|
109
146
|
## Development
|
|
@@ -1 +1 @@
|
|
|
1
|
-
4.0.
|
|
1
|
+
4.0.2
|