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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/demo/test_chartjs.html +9 -9
  4. data/demo/test_component.html +8 -8
  5. data/demo/test_error_boundary.html +44 -41
  6. data/demo/test_router.html +48 -48
  7. data/demo/tic-tac-toe.html +25 -25
  8. data/docs/architecture.md +46 -9
  9. data/lib/funicular/ssr/runtime.rb +1 -0
  10. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  11. data/lib/funicular/vendor/mrbc/mrbc.js +613 -486
  12. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  13. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  14. data/lib/funicular/vendor/picoruby/debug/picoruby.js +669 -449
  15. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  16. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  17. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  19. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +921 -629
  20. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
  22. data/lib/funicular/version.rb +1 -1
  23. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  24. data/minitest/dsl_test.rb +237 -0
  25. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  26. data/minitest/form_for_test.rb +2 -2
  27. data/minitest/hydration_test.rb +2 -2
  28. data/minitest/sig_tags_test.rb +30 -0
  29. data/minitest/view_context_test.rb +15 -15
  30. data/mrblib/0_tags.rb +62 -0
  31. data/mrblib/component.rb +113 -23
  32. data/mrblib/error_boundary.rb +25 -19
  33. data/mrblib/form_builder.rb +10 -10
  34. data/mrblib/funicular.rb +1 -1
  35. data/mrblib/styles.rb +102 -12
  36. data/mrblib/view_context.rb +3 -32
  37. data/sig/component.rbs +18 -4
  38. data/sig/error_boundary.rbs +4 -4
  39. data/sig/styles.rbs +18 -5
  40. data/sig/tags.rbs +54 -0
  41. data/sig/view_context.rbs +47 -34
  42. metadata +5 -1
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Funicular
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,25 +1,24 @@
1
1
  class FunicularChatComponent < Funicular::Component
2
- styles do |css|
3
- css.define :shell, "funicular-chat"
4
- css.define :panel, "funicular-chat__panel"
5
- css.define :header, "funicular-chat__header"
6
- css.define :title, "funicular-chat__title"
7
- css.define :subtitle, "funicular-chat__subtitle"
8
- css.define :messages, "funicular-chat__messages"
9
- css.define :empty, "funicular-chat__empty"
10
- css.define :message, "funicular-chat__message"
11
- css.define :name, "funicular-chat__message-name"
12
- css.define :body, "funicular-chat__message-body"
13
- css.define :form, "funicular-chat__form"
14
- css.define :row, "funicular-chat__form-row"
15
- css.define :input, "funicular-chat__input"
16
- css.define :button,
17
- base: "funicular-chat__button",
18
- variants: {
19
- enabled: "funicular-chat__button--enabled",
20
- disabled: "funicular-chat__button--disabled"
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(h)
99
- h.div(class: h.styles[:shell]) do |hh|
100
- hh.div(class: h.styles[:panel]) do |panel|
101
- panel.div(class: h.styles[:header]) do |header|
102
- header.h1(class: h.styles[:title]) { "Funicular Chat" }
103
- header.p(class: h.styles[:subtitle]) do
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
- panel.div(class: h.styles[:messages]) do |messages|
107
+ div(class: styles.messages) do
109
108
  if state[:messages].empty?
110
- messages.p(class: h.styles[:empty]) { "No messages yet." }
109
+ p(class: styles.empty) { "No messages yet." }
111
110
  else
112
111
  state[:messages].each do |message|
113
- messages.div(class: h.styles[:message], key: message["id"]) do |item|
114
- item.div(class: h.styles[:name]) { message["name"] }
115
- item.div(class: h.styles[:body]) { message["body"] }
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
- panel.form(onsubmit: :send_message, class: h.styles[:form]) do |form|
120
+ form(onsubmit: :send_message, class: styles.form) do
122
121
  if state[:error]
123
- form.div(class: h.styles[:error]) { state[:error] }
122
+ div(class: styles.error) { state[:error] }
124
123
  end
125
- form.div(class: h.styles[:row]) do |row|
126
- row.input(type: "text", value: state[:name], placeholder: "Name", oninput: :update_name, class: h.styles[:input])
127
- row.input(type: "text", value: state[:body], placeholder: "Message", oninput: :update_body, class: h.styles[:input])
128
- row.button(type: "submit", class: h.styles[:button, state[:sending] ? :disabled : :enabled], disabled: state[:sending]) do
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(h)
7
- h.div(class: "greeting") do |hh|
8
- hh.h1 { state[:title] }
9
- hh.ul do |hhh|
6
+ def render
7
+ div(class: "greeting") do
8
+ h1 { state[:title] }
9
+ ul do
10
10
  state[:items].each do |item|
11
- hhh.li(key: item["id"]) { item["name"] }
11
+ li(key: item["id"]) { item["name"] }
12
12
  end
13
13
  end
14
14
  end
@@ -19,8 +19,8 @@ class FormForTest < Minitest::Test
19
19
  @submitted = data
20
20
  end
21
21
 
22
- def render(h)
23
- h.form_for(:comment, on_submit: :handle_submit) do |f|
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
@@ -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(h)
29
- h.div { "x" }
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 test_explicit_state_styles_and_resources_do_not_collide_with_html_methods
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(h)
21
- h.data(class: h.styles[:hash]) do
22
- "#{state[:class]} #{state[:hash]} #{h.resources[:data]}"
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(h)
41
- h.div { children }
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 |h|
47
- h.component(child, title: "x") do |hh|
48
- hh.span { "inside" }
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", Funicular::ViewContext.new(component_a).routes.user_path(7)
74
- assert_equal "/accounts/7", Funicular::ViewContext.new(component_b).routes.user_path(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(h)
81
+ def render
82
82
  begin
83
- h.div do |hh|
84
- hh.span { "before" }
83
+ div do
84
+ span { "before" }
85
85
  raise "boom"
86
86
  end
87
87
  rescue
88
88
  @captured = current_children
89
- h.div { "after" }
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