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
data/lib/funicular/version.rb
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
class FunicularChatComponent < Funicular::Component
|
|
2
|
-
styles do
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
css.define :error, "funicular-chat__error"
|
|
2
|
+
styles do
|
|
3
|
+
shell "funicular-chat"
|
|
4
|
+
panel "funicular-chat__panel"
|
|
5
|
+
header "funicular-chat__header"
|
|
6
|
+
title "funicular-chat__title"
|
|
7
|
+
subtitle "funicular-chat__subtitle"
|
|
8
|
+
messages "funicular-chat__messages"
|
|
9
|
+
empty "funicular-chat__empty"
|
|
10
|
+
message "funicular-chat__message"
|
|
11
|
+
name "funicular-chat__message-name"
|
|
12
|
+
body "funicular-chat__message-body"
|
|
13
|
+
form "funicular-chat__form"
|
|
14
|
+
row "funicular-chat__form-row"
|
|
15
|
+
input "funicular-chat__input"
|
|
16
|
+
button base: "funicular-chat__button",
|
|
17
|
+
variants: {
|
|
18
|
+
enabled: "funicular-chat__button--enabled",
|
|
19
|
+
disabled: "funicular-chat__button--disabled"
|
|
20
|
+
}
|
|
21
|
+
error "funicular-chat__error"
|
|
23
22
|
end
|
|
24
23
|
|
|
25
24
|
def initialize_state
|
|
@@ -95,37 +94,37 @@ class FunicularChatComponent < Funicular::Component
|
|
|
95
94
|
end
|
|
96
95
|
end
|
|
97
96
|
|
|
98
|
-
def render
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
def render
|
|
98
|
+
div(class: styles.shell) do
|
|
99
|
+
div(class: styles.panel) do
|
|
100
|
+
div(class: styles.header) do
|
|
101
|
+
h1(class: styles.title) { "Funicular Chat" }
|
|
102
|
+
p(class: styles.subtitle) do
|
|
104
103
|
state[:connected] ? "Realtime Ruby chat over ActionCable" : "Connecting..."
|
|
105
104
|
end
|
|
106
105
|
end
|
|
107
106
|
|
|
108
|
-
|
|
107
|
+
div(class: styles.messages) do
|
|
109
108
|
if state[:messages].empty?
|
|
110
|
-
|
|
109
|
+
p(class: styles.empty) { "No messages yet." }
|
|
111
110
|
else
|
|
112
111
|
state[:messages].each do |message|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
div(class: styles.message, key: message["id"]) do
|
|
113
|
+
div(class: styles.name) { message["name"] }
|
|
114
|
+
div(class: styles.body) { message["body"] }
|
|
116
115
|
end
|
|
117
116
|
end
|
|
118
117
|
end
|
|
119
118
|
end
|
|
120
119
|
|
|
121
|
-
|
|
120
|
+
form(onsubmit: :send_message, class: styles.form) do
|
|
122
121
|
if state[:error]
|
|
123
|
-
|
|
122
|
+
div(class: styles.error) { state[:error] }
|
|
124
123
|
end
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
div(class: styles.row) do
|
|
125
|
+
input(type: "text", value: state[:name], placeholder: "Name", oninput: :update_name, class: styles.input)
|
|
126
|
+
input(type: "text", value: state[:body], placeholder: "Message", oninput: :update_body, class: styles.input)
|
|
127
|
+
button(type: "submit", class: styles.button(state[:sending] ? :disabled : :enabled), disabled: state[:sending]) do
|
|
129
128
|
state[:sending] ? "Sending..." : "Send"
|
|
130
129
|
end
|
|
131
130
|
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# Bareword component DSL (0.4.0), CRuby/SSR side. Twin of test/dsl_test.rb;
|
|
6
|
+
# the attr_accessor case differs by VM: CRuby fires method_added for attr_*
|
|
7
|
+
# so the collision surfaces at class-definition time, while mruby relies on
|
|
8
|
+
# the mount-time sweep.
|
|
9
|
+
class DSLTest < Minitest::Test
|
|
10
|
+
def setup
|
|
11
|
+
Funicular::SSR::Runtime.load_framework!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_bareword_render_builds_nested_tree
|
|
15
|
+
klass = Class.new(Funicular::Component) do
|
|
16
|
+
def initialize_state
|
|
17
|
+
{ items: ["a", "b"] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render
|
|
21
|
+
div(class: "list") do
|
|
22
|
+
h1 { "title" }
|
|
23
|
+
ul do
|
|
24
|
+
state[:items].each_with_index do |item, i|
|
|
25
|
+
li(key: i) { item }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
vnode = klass.new.build_vdom
|
|
33
|
+
|
|
34
|
+
assert_equal "div", vnode.tag
|
|
35
|
+
assert_equal ["h1", "ul"], vnode.children.map(&:tag)
|
|
36
|
+
assert_equal 2, vnode.children[1].children.size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_defining_reserved_tag_name_raises_at_class_definition
|
|
40
|
+
assert_raises(Funicular::DSLCollisionError) do
|
|
41
|
+
Class.new(Funicular::Component) do
|
|
42
|
+
def label
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_attr_accessor_collision_raises_at_class_definition_on_cruby
|
|
49
|
+
# CRuby fires method_added for attr_*, so this is caught immediately
|
|
50
|
+
# (on mruby the mount-time sweep catches it instead).
|
|
51
|
+
assert_raises(Funicular::DSLCollisionError) do
|
|
52
|
+
Class.new(Funicular::Component) do
|
|
53
|
+
attr_accessor :label
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_included_module_collision_is_caught_at_first_build
|
|
59
|
+
mod = Module.new do
|
|
60
|
+
def select
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
klass = Class.new(Funicular::Component) do
|
|
64
|
+
def render
|
|
65
|
+
div { "x" }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
klass.include(mod)
|
|
69
|
+
|
|
70
|
+
assert_raises(Funicular::DSLCollisionError) { klass.new.build_vdom }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_allow_dsl_override_restores_user_method_and_tag_escape_hatch
|
|
74
|
+
klass = Class.new(Funicular::Component) do
|
|
75
|
+
allow_dsl_override :label
|
|
76
|
+
|
|
77
|
+
def label
|
|
78
|
+
"custom"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render
|
|
82
|
+
div { tag(:label) { label } }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
vnode = klass.new.build_vdom
|
|
87
|
+
|
|
88
|
+
assert_equal "label", vnode.children.first.tag
|
|
89
|
+
assert_equal ["custom"], vnode.children.first.children
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_render_with_parameter_raises_migration_error
|
|
93
|
+
klass = Class.new(Funicular::Component) do
|
|
94
|
+
def render(h)
|
|
95
|
+
h
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
error = assert_raises(Funicular::DSLCollisionError) { klass.new.build_vdom }
|
|
100
|
+
assert_match(/must not take parameters/, error.message)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_p_with_non_hash_raises_with_hint
|
|
104
|
+
klass = Class.new(Funicular::Component) do
|
|
105
|
+
def render
|
|
106
|
+
div { p "debug me" }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
error = assert_raises(ArgumentError) { klass.new.build_vdom }
|
|
111
|
+
assert_match(/puts x.inspect/, error.message)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_p_with_hash_builds_paragraph
|
|
115
|
+
klass = Class.new(Funicular::Component) do
|
|
116
|
+
def render
|
|
117
|
+
p(class: "para") { "text" }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
vnode = klass.new.build_vdom
|
|
122
|
+
|
|
123
|
+
assert_equal "p", vnode.tag
|
|
124
|
+
assert_equal ["text"], vnode.children
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_tag_outside_render_raises
|
|
128
|
+
klass = Class.new(Funicular::Component) do
|
|
129
|
+
def render
|
|
130
|
+
div { "x" }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
component = klass.new
|
|
134
|
+
component.build_vdom
|
|
135
|
+
|
|
136
|
+
assert_raises(Funicular::RenderContextError) { component.div { "outside" } }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_bareword_styles_definition_and_method_access
|
|
140
|
+
klass = Class.new(Funicular::Component) do
|
|
141
|
+
styles do
|
|
142
|
+
shell "app-shell"
|
|
143
|
+
display "display-class"
|
|
144
|
+
button base: "btn", variants: { primary: "btn--primary" }, active: "btn--on"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render
|
|
148
|
+
div(class: styles.shell) do
|
|
149
|
+
span(class: styles.display) { "d" }
|
|
150
|
+
button(class: styles.button(:primary)) { "b" }
|
|
151
|
+
a(class: styles[:button, true] | "extra") { "x" }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
vnode = klass.new.build_vdom
|
|
157
|
+
|
|
158
|
+
assert_equal "app-shell", vnode.props[:class]
|
|
159
|
+
# :display would resolve to Object#display on an Object-based accessor;
|
|
160
|
+
# the BasicObject accessor keeps it a plain style name under CRuby SSR,
|
|
161
|
+
# matching the mruby client.
|
|
162
|
+
assert_equal "display-class", vnode.children[0].props[:class]
|
|
163
|
+
assert_equal "btn btn--primary", vnode.children[1].props[:class]
|
|
164
|
+
assert_equal "btn btn--on extra", vnode.children[2].props[:class]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def test_unknown_style_raises
|
|
168
|
+
klass = Class.new(Funicular::Component) do
|
|
169
|
+
styles do
|
|
170
|
+
shell "app-shell"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def render
|
|
174
|
+
div { "x" }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
component = klass.new
|
|
178
|
+
|
|
179
|
+
assert_raises(NoMethodError) { component.styles.typo_name }
|
|
180
|
+
assert_raises(ArgumentError) { component.styles[:typo] }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def test_bareword_helper_call_inside_styles_block_raises
|
|
184
|
+
assert_raises(ArgumentError) do
|
|
185
|
+
Class.new(Funicular::Component) do
|
|
186
|
+
styles do
|
|
187
|
+
button base: helper_that_does_not_exist("x")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def test_duplicate_style_definition_raises
|
|
194
|
+
assert_raises(ArgumentError) do
|
|
195
|
+
Class.new(Funicular::Component) do
|
|
196
|
+
styles do
|
|
197
|
+
shell "a"
|
|
198
|
+
shell "b"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def test_suspense_fallback_runs_bareword_in_own_component
|
|
205
|
+
klass = Class.new(Funicular::Component) do
|
|
206
|
+
use_suspense :user, ->(resolve, _reject) { resolve.call("u") }
|
|
207
|
+
|
|
208
|
+
def render
|
|
209
|
+
div do
|
|
210
|
+
suspense(:user, fallback: -> { span { "loading" } }) do |res|
|
|
211
|
+
span { res[:user] }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
component = klass.new
|
|
218
|
+
vnode = component.build_vdom
|
|
219
|
+
assert_equal "loading", vnode.children.first.children.first
|
|
220
|
+
|
|
221
|
+
component.instance_variable_get(:@suspense_data)[:user] = "u"
|
|
222
|
+
component.instance_variable_get(:@suspense_states)[:user] = :resolved
|
|
223
|
+
vnode = component.build_vdom
|
|
224
|
+
assert_equal "u", vnode.children.first.children.first
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def test_error_boundary_fallback_receives_view_context
|
|
228
|
+
fallback = ->(h, error) { h.div(class: "custom-error") { error.message } }
|
|
229
|
+
boundary = Funicular::ErrorBoundary.new(fallback: fallback)
|
|
230
|
+
boundary.catch_error(RuntimeError.new("boom"))
|
|
231
|
+
vnode = boundary.build_vdom
|
|
232
|
+
|
|
233
|
+
assert_equal "div", vnode.tag
|
|
234
|
+
assert_equal "custom-error", vnode.props[:class]
|
|
235
|
+
assert_equal ["boom"], vnode.children
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -3,12 +3,12 @@ class GreetingComponent < Funicular::Component
|
|
|
3
3
|
{ title: "Default Title", items: [] }
|
|
4
4
|
end
|
|
5
5
|
|
|
6
|
-
def render
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
def render
|
|
7
|
+
div(class: "greeting") do
|
|
8
|
+
h1 { state[:title] }
|
|
9
|
+
ul do
|
|
10
10
|
state[:items].each do |item|
|
|
11
|
-
|
|
11
|
+
li(key: item["id"]) { item["name"] }
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
data/minitest/form_for_test.rb
CHANGED
|
@@ -19,8 +19,8 @@ class FormForTest < Minitest::Test
|
|
|
19
19
|
@submitted = data
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def render
|
|
23
|
-
|
|
22
|
+
def render
|
|
23
|
+
form_for(:comment, on_submit: :handle_submit) do |f|
|
|
24
24
|
f.textarea(:body)
|
|
25
25
|
f.submit("Post")
|
|
26
26
|
end
|
data/minitest/hydration_test.rb
CHANGED
|
@@ -25,8 +25,8 @@ class HydrationMatchTest < Minitest::Test
|
|
|
25
25
|
# invoked here (we feed vnodes directly); it only satisfies the abstract API.
|
|
26
26
|
def probe
|
|
27
27
|
klass = Class.new(Funicular::Component) do
|
|
28
|
-
def render
|
|
29
|
-
|
|
28
|
+
def render
|
|
29
|
+
div { "x" }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def match?(vnode, dom)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# The tag whitelist lives in mrblib/0_tags.rb (runtime) and is manually
|
|
6
|
+
# enumerated in sig/tags.rbs and sig/view_context.rbs (types). These drifted
|
|
7
|
+
# in the past; fail loudly when they do again.
|
|
8
|
+
class SigTagsTest < Minitest::Test
|
|
9
|
+
ROOT = File.expand_path("..", __dir__)
|
|
10
|
+
|
|
11
|
+
def setup
|
|
12
|
+
Funicular::SSR::Runtime.load_framework!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def assert_sig_covers_all_tags(sig_path)
|
|
16
|
+
sig = File.read(File.join(ROOT, sig_path))
|
|
17
|
+
missing = Funicular::Tags::HTML_TAGS.reject do |tag|
|
|
18
|
+
sig.include?("def #{tag}: ")
|
|
19
|
+
end
|
|
20
|
+
assert_empty missing, "#{sig_path} is missing tag signatures: #{missing.join(', ')}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_sig_tags_rbs_covers_all_tags
|
|
24
|
+
assert_sig_covers_all_tags("sig/tags.rbs")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_sig_view_context_rbs_covers_all_tags
|
|
28
|
+
assert_sig_covers_all_tags("sig/view_context.rbs")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -7,7 +7,7 @@ class ViewContextTest < Minitest::Test
|
|
|
7
7
|
Funicular::SSR::Runtime.load_framework!
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def test_state_styles_and_resources_are_reachable_alongside_tags
|
|
11
11
|
klass = Class.new(Funicular::Component) do
|
|
12
12
|
styles { |css| css.define :hash, "hash-class" }
|
|
13
13
|
|
|
@@ -17,9 +17,9 @@ class ViewContextTest < Minitest::Test
|
|
|
17
17
|
{ class: "state-class", hash: "state-hash" }
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def render
|
|
21
|
-
|
|
22
|
-
"#{state[:class]} #{state[:hash]} #{
|
|
20
|
+
def render
|
|
21
|
+
data(class: styles[:hash]) do
|
|
22
|
+
"#{state[:class]} #{state[:hash]} #{resources[:data]}"
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -37,15 +37,15 @@ class ViewContextTest < Minitest::Test
|
|
|
37
37
|
|
|
38
38
|
def test_component_children_are_vdom_children_not_props
|
|
39
39
|
child = Class.new(Funicular::Component) do
|
|
40
|
-
def render
|
|
41
|
-
|
|
40
|
+
def render
|
|
41
|
+
div { children }
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
parent = Class.new(Funicular::Component) do
|
|
46
|
-
define_method(:render) do
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
define_method(:render) do
|
|
47
|
+
component(child, title: "x") do
|
|
48
|
+
span { "inside" }
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
@@ -70,23 +70,23 @@ class ViewContextTest < Minitest::Test
|
|
|
70
70
|
component_a.runtime = Funicular::Runtime.new(router_a)
|
|
71
71
|
component_b.runtime = Funicular::Runtime.new(router_b)
|
|
72
72
|
|
|
73
|
-
assert_equal "/users/7",
|
|
74
|
-
assert_equal "/accounts/7",
|
|
73
|
+
assert_equal "/users/7", component_a.routes.user_path(7)
|
|
74
|
+
assert_equal "/accounts/7", component_b.routes.user_path(7)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def test_child_collector_is_restored_when_render_raises
|
|
78
78
|
klass = Class.new(Funicular::Component) do
|
|
79
79
|
attr_reader :captured
|
|
80
80
|
|
|
81
|
-
def render
|
|
81
|
+
def render
|
|
82
82
|
begin
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
div do
|
|
84
|
+
span { "before" }
|
|
85
85
|
raise "boom"
|
|
86
86
|
end
|
|
87
87
|
rescue
|
|
88
88
|
@captured = current_children
|
|
89
|
-
|
|
89
|
+
div { "after" }
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
end
|
data/mrblib/0_tags.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
# Raised at class-definition time (method_added) or first mount when a
|
|
3
|
+
# component defines a method whose name collides with the view DSL.
|
|
4
|
+
class DSLCollisionError < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a tag helper is called while the component is not rendering
|
|
7
|
+
# (e.g. from a stale proc or an event handler outside build_vdom).
|
|
8
|
+
class RenderContextError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Bareword tag DSL mixed into Funicular::Component. Tags are real public
|
|
11
|
+
# instance methods so `div(...)` works with self = the component inside
|
|
12
|
+
# `render`. Element construction is delegated to the component's internal
|
|
13
|
+
# ViewContext, which remains the single element factory (FormBuilder and
|
|
14
|
+
# the framework internals keep using it directly).
|
|
15
|
+
module Tags
|
|
16
|
+
# Single source of truth for the tag whitelist. sig/tags.rbs must list
|
|
17
|
+
# one method per entry; test/sig_tags_test.rb enforces that in CI.
|
|
18
|
+
HTML_TAGS = %w[
|
|
19
|
+
div span p a data
|
|
20
|
+
h1 h2 h3 h4 h5 h6
|
|
21
|
+
ul ol li
|
|
22
|
+
table thead tbody tr th td
|
|
23
|
+
form input textarea button select option label
|
|
24
|
+
header footer nav section article aside
|
|
25
|
+
img video audio canvas
|
|
26
|
+
br hr
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
HELPER_NAMES = %w[
|
|
30
|
+
tag component form_for link_to button_to suspense
|
|
31
|
+
state props styles resources routes patch
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Symbol => :tag | :helper. Component.method_added consults this to turn
|
|
35
|
+
# silent shadowing into a load-time error.
|
|
36
|
+
RESERVED_DSL = {} #: Hash[Symbol, Symbol]
|
|
37
|
+
HTML_TAGS.each { |name| RESERVED_DSL[name.to_sym] = :tag }
|
|
38
|
+
HELPER_NAMES.each { |name| RESERVED_DSL[name.to_sym] = :helper }
|
|
39
|
+
|
|
40
|
+
HTML_TAGS.each do |tag_name|
|
|
41
|
+
define_method(tag_name) do |props = {}, &block|
|
|
42
|
+
# @type self: Funicular::Component
|
|
43
|
+
unless props.is_a?(Hash)
|
|
44
|
+
hint = if tag_name == "p"
|
|
45
|
+
" Inside a component, `p` builds a <p> element; use `puts x.inspect` to debug."
|
|
46
|
+
else
|
|
47
|
+
""
|
|
48
|
+
end
|
|
49
|
+
raise ArgumentError, "#{tag_name}() expects an attributes Hash, got #{props.class}.#{hint}"
|
|
50
|
+
end
|
|
51
|
+
__view__.tag(tag_name, props, &block)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Escape hatch: emit any element, including custom elements outside the
|
|
56
|
+
# whitelist or a tag whose bareword is shadowed via allow_dsl_override.
|
|
57
|
+
def tag(name, props = {}, &block)
|
|
58
|
+
# @type self: Funicular::Component
|
|
59
|
+
__view__.tag(name, props, &block)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|