funicular 0.2.1 → 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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +2 -2
  4. data/Rakefile +23 -15
  5. data/demo/test_chartjs.html +8 -8
  6. data/demo/test_component.html +8 -8
  7. data/demo/test_error_boundary.html +8 -5
  8. data/demo/test_router.html +11 -11
  9. data/demo/tic-tac-toe.html +4 -4
  10. data/docs/architecture.md +80 -30
  11. data/lib/funicular/compiler.rb +13 -13
  12. data/lib/funicular/helpers/picoruby_helper.rb +24 -1
  13. data/lib/funicular/ssr/runtime.rb +3 -0
  14. data/lib/funicular/ssr.rb +2 -1
  15. data/lib/funicular/testing/node_runner.rb +1 -1
  16. data/lib/funicular/vendor/mrbc/VERSION +1 -0
  17. data/lib/funicular/vendor/{picorbc/picorbc.js → mrbc/mrbc.js} +626 -486
  18. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  19. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  20. data/lib/funicular/vendor/picoruby/debug/init.iife.js +19 -4
  21. data/lib/funicular/vendor/picoruby/debug/picoruby.js +675 -449
  22. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  23. data/lib/funicular/vendor/picoruby/dist/init.iife.js +19 -4
  24. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  25. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +860 -567
  28. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
  30. data/lib/funicular/version.rb +1 -1
  31. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +25 -25
  32. data/lib/tasks/funicular.rake +1 -1
  33. data/minitest/commands_routes_test.rb +97 -0
  34. data/minitest/compiler_test.rb +195 -0
  35. data/minitest/configuration_test.rb +64 -0
  36. data/minitest/dsl_test.rb +237 -0
  37. data/minitest/fixtures/funicular_app/components/greeting_component.rb +2 -2
  38. data/minitest/funicular_test.rb +28 -2
  39. data/minitest/middleware_test.rb +154 -0
  40. data/minitest/picoruby_helper_test.rb +139 -0
  41. data/minitest/route_parser_test.rb +139 -0
  42. data/minitest/schema_test.rb +23 -0
  43. data/minitest/sig_tags_test.rb +30 -0
  44. data/minitest/ssr_test.rb +57 -0
  45. data/minitest/support/rails_stub.rb +59 -0
  46. data/minitest/test_helper.rb +20 -0
  47. data/minitest/testing_test.rb +267 -0
  48. data/minitest/view_context_test.rb +101 -0
  49. data/mrblib/0_tags.rb +62 -0
  50. data/mrblib/component.rb +255 -244
  51. data/mrblib/debug.rb +7 -6
  52. data/mrblib/differ.rb +3 -1
  53. data/mrblib/error_boundary.rb +11 -13
  54. data/mrblib/form_builder.rb +28 -24
  55. data/mrblib/funicular.rb +2 -1
  56. data/mrblib/html_serializer.rb +14 -13
  57. data/mrblib/http.rb +12 -1
  58. data/mrblib/patcher.rb +12 -9
  59. data/mrblib/router.rb +9 -5
  60. data/mrblib/runtime.rb +28 -0
  61. data/mrblib/styles.rb +107 -21
  62. data/mrblib/vdom.rb +90 -9
  63. data/mrblib/view_context.rb +106 -0
  64. data/sig/component.rbs +47 -84
  65. data/sig/form_builder.rbs +2 -1
  66. data/sig/html_serializer.rbs +2 -1
  67. data/sig/http.rbs +1 -0
  68. data/sig/patcher.rbs +1 -1
  69. data/sig/router.rbs +2 -13
  70. data/sig/runtime.rbs +13 -0
  71. data/sig/styles.rbs +19 -7
  72. data/sig/tags.rbs +54 -0
  73. data/sig/vdom.rbs +11 -2
  74. data/sig/view_context.rbs +60 -0
  75. metadata +22 -5
  76. data/lib/funicular/vendor/picorbc/VERSION +0 -1
  77. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
data/mrblib/component.rb CHANGED
@@ -5,46 +5,128 @@ module Funicular
5
5
  @state = state_hash
6
6
  end
7
7
 
8
- # Support dynamic key access: state[:key] or state[variable_key]
9
8
  def [](key)
10
9
  @state[key]
11
10
  end
12
- def method_missing(method, *args)
13
- if method.to_s.end_with?('=')
14
- key = method.to_s[0..-2] || method
15
- raise "Use patch(#{key}: value) to update state"
16
- else
17
- @state[method]
18
- end
11
+
12
+ def fetch(key, default = nil)
13
+ return @state.fetch(key) if default.nil? && @state.key?(key)
14
+ @state.fetch(key, default)
15
+ end
16
+
17
+ def key?(key)
18
+ @state.key?(key)
19
+ end
20
+
21
+ def to_h
22
+ @state
23
+ end
24
+ end
25
+
26
+ class ResourceAccessor
27
+ def initialize(data, states, errors)
28
+ @data = data
29
+ @states = states
30
+ @errors = errors
31
+ end
32
+
33
+ def [](key)
34
+ @data[key]
35
+ end
36
+
37
+ def fetch(key, default = nil)
38
+ return @data.fetch(key) if default.nil? && @data.key?(key)
39
+ @data.fetch(key, default)
40
+ end
41
+
42
+ def loading?(key)
43
+ @states[key] == :pending || @states[key] == :loading
44
+ end
45
+
46
+ def error?(key)
47
+ @states[key] == :rejected
19
48
  end
20
49
 
21
- def respond_to_missing?(method, include_private = false)
22
- return false if method.to_s.end_with?('=')
23
- @state.key?(method) || super
50
+ def error(key)
51
+ @errors[key]
24
52
  end
25
53
  end
26
54
 
27
- attr_accessor :props, :vdom, :dom_element, :mounted
55
+ include Tags
56
+
57
+ attr_accessor :props, :vdom, :dom_element, :mounted, :runtime, :children, :current_children
28
58
  attr_reader :refs
29
59
 
60
+ # Opt out of DSL collision detection for the given method names. The
61
+ # shadowed tag remains reachable through tag(:name, ...).
62
+ def self.allow_dsl_override(*names)
63
+ @dsl_overrides ||= [] #: Array[Symbol]
64
+ @dsl_overrides.concat(names)
65
+ end
66
+
67
+ def self.dsl_overrides
68
+ @dsl_overrides ||= [] #: Array[Symbol]
69
+ end
70
+
71
+ # Layer 1: catches `def`, `define_method`, and `alias` in subclasses at
72
+ # class-definition time. attr_* does not fire this hook on mruby, and
73
+ # included modules never do; validate_dsl_conflicts! covers those.
74
+ def self.method_added(name)
75
+ if self != Funicular::Component && Funicular::Tags::RESERVED_DSL[name] && !dsl_overrides.include?(name)
76
+ kind = Funicular::Tags::RESERVED_DSL[name] == :tag ? "<#{name}> tag helper" : "##{name} helper"
77
+ raise Funicular::DSLCollisionError,
78
+ "#{self}##{name} collides with the Funicular DSL (#{kind}). " \
79
+ "Rename it (e.g. `#{name}_value`), or declare `allow_dsl_override :#{name}` " \
80
+ "and use `tag(:#{name}, ...)` to emit the element."
81
+ end
82
+ super
83
+ end
84
+
85
+ # Layer 2: once per class, sweep methods the method_added hook cannot
86
+ # see (attr_* on mruby, user-included modules).
87
+ def self.validate_dsl_conflicts!
88
+ return if @dsl_validated
89
+ if instance_method(:render).arity != 0
90
+ raise Funicular::DSLCollisionError,
91
+ "#{self}#render must not take parameters as of Funicular 0.4.0: " \
92
+ "delete the parameter (`def render`) and drop the `h.` receivers " \
93
+ "inside; tags are bareword methods on the component now."
94
+ end
95
+ ancestors.each do |mod|
96
+ break if mod == Funicular::Component || mod == Funicular::Tags
97
+ next unless mod.is_a?(Module)
98
+ methods = mod.instance_methods(false) + mod.private_instance_methods(false)
99
+ offenders = methods.select { |m| Funicular::Tags::RESERVED_DSL[m] } - dsl_overrides
100
+ next if offenders.empty?
101
+ raise Funicular::DSLCollisionError,
102
+ "#{mod} defines methods that collide with the Funicular DSL: " \
103
+ "#{offenders.map { |m| m.to_s }.join(', ')}. Rename them, or declare " \
104
+ "`allow_dsl_override` and use `tag(...)` to emit the element."
105
+ end
106
+ @dsl_validated = true
107
+ end
108
+
30
109
  def initialize(props = {})
31
110
  @props = props
32
111
  @state = initialize_state || {}
33
112
  @state_accessor = nil
113
+ @resource_accessor = nil
34
114
  @style_accessor = nil
115
+ @runtime = Funicular::Runtime.new
116
+ @children = [] #: Array[Funicular::VDOM::child_t]
35
117
  @vdom = nil
36
118
  @dom_element = nil
37
- @refs = {}
38
- @event_listeners = []
119
+ @refs = {} #: Hash[Symbol, JS::Element]
120
+ @event_listeners = [] #: Array[untyped]
39
121
  @mounted = false
40
122
  @updating = false
41
- @child_components = []
123
+ @child_components = [] #: Array[Funicular::Component]
42
124
 
43
125
  # Initialize suspense state
44
- @suspense_data = {}
45
- @suspense_states = {} # :pending, :loading, :resolved, :rejected
46
- @suspense_errors = {}
47
- @suspense_pending_timers = [] # Track pending setTimeout IDs for cleanup
126
+ @suspense_data = {} #: Hash[Symbol, untyped]
127
+ @suspense_states = {} #: Hash[Symbol, Symbol]
128
+ @suspense_errors = {} #: Hash[Symbol, untyped]
129
+ @suspense_pending_timers = [] #: Array[untyped]
48
130
  self.class.suspense_definitions.each_key do |name|
49
131
  @suspense_states[name] = :pending
50
132
  end
@@ -57,6 +139,14 @@ module Funicular
57
139
  @state_accessor ||= StateAccessor.new(@state)
58
140
  end
59
141
 
142
+ def resources
143
+ @resource_accessor ||= ResourceAccessor.new(@suspense_data, @suspense_states, @suspense_errors)
144
+ end
145
+
146
+ def styles
147
+ @style_accessor ||= self.class.style_accessor_class.new(self.class.styles_definitions)
148
+ end
149
+
60
150
  # Override this method in subclasses to define initial state
61
151
  def initialize_state
62
152
  {}
@@ -66,7 +156,7 @@ module Funicular
66
156
  # Used by SSR to inject server data, and by client hydration to restore
67
157
  # the same state from window.__FUNICULAR_STATE__ so the first client
68
158
  # render matches the server HTML. Top-level keys are symbolized so they
69
- # are reachable via state.foo (StateAccessor uses symbol keys); nested
159
+ # are reachable via state[:foo] (StateAccessor uses symbol keys); nested
70
160
  # values are left untouched (components read them with string keys).
71
161
  def seed_state(state_hash)
72
162
  return self if state_hash.nil?
@@ -191,46 +281,49 @@ module Funicular
191
281
  # ) do
192
282
  # div { user.name }
193
283
  # end
194
- def suspense(fallback:, error: nil, &block)
284
+ def render_suspense(name, fallback:, error: nil, &block)
195
285
  current_children = @current_children
196
286
  child_count_before = current_children&.size
197
287
  result = nil
198
288
 
199
- # Check for any rejected suspense
200
- rejected_name = self.class.suspense_definitions.keys.find { |name| @suspense_states[name] == :rejected }
201
- if rejected_name
289
+ if @suspense_states[name] == :rejected
202
290
  result = if error
203
- error.call(@suspense_errors[rejected_name])
291
+ error.call(@suspense_errors[name])
204
292
  else
205
293
  fallback.call
206
294
  end
207
- elsif suspense_loading?
208
- # Check if any suspense is still loading
209
- # Note: Loading is started in mount(), not here
295
+ elsif suspense_loading?(name)
210
296
  result = fallback.call
211
297
  else
212
- # All suspense data loaded, render content
213
- result = block.call
298
+ result = block.call(resources)
214
299
  end
215
300
 
216
301
  if current_children && current_children.size == child_count_before
217
- add_child(result)
302
+ add_child_from_view(result)
218
303
  end
219
304
 
220
305
  result
221
306
  end
222
307
 
223
- # Class methods for styles DSL
308
+ # Class methods for styles DSL. The block is instance_exec'd on a
309
+ # StyleBuilder cleanroom so barewords define styles; it also receives
310
+ # the builder for the explicit `styles { |css| css.define(...) }` form.
224
311
  def self.styles(&block)
225
312
  builder = StyleBuilder.new
226
- builder.instance_eval(&block)
313
+ builder.instance_exec(builder, &block) # steep:ignore
227
314
  @styles_definitions = builder.to_definitions
315
+ @style_accessor_class = nil
228
316
  end
229
317
 
230
318
  def self.styles_definitions
231
319
  @styles_definitions ||= {}
232
320
  end
233
321
 
322
+ # Per-class accessor with one real method per declared style name.
323
+ def self.style_accessor_class
324
+ @style_accessor_class ||= StyleAccessor.accessor_for(styles_definitions)
325
+ end
326
+
234
327
  # Suspense DSL - register async data loaders
235
328
  #
236
329
  # @param name [Symbol] Name of the suspense data (becomes accessible as method)
@@ -256,12 +349,7 @@ module Funicular
256
349
  end
257
350
 
258
351
  def self.suspense_definitions
259
- @suspense_definitions ||= {} # steep:ignore UnannotatedEmptyCollection
260
- end
261
-
262
- # Instance method to access styles
263
- def s
264
- @style_accessor ||= StyleAccessor.new(self.class.styles_definitions)
352
+ @suspense_definitions ||= {}
265
353
  end
266
354
 
267
355
  # Update state and trigger re-render
@@ -301,7 +389,7 @@ module Funicular
301
389
 
302
390
  @container = container
303
391
  new_vdom = build_vdom
304
- @dom_element = VDOM::Renderer.new.render(new_vdom)
392
+ @dom_element = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
305
393
  bind_events(@dom_element, new_vdom)
306
394
  collect_refs(@dom_element, new_vdom)
307
395
  collect_child_components(new_vdom)
@@ -397,7 +485,7 @@ module Funicular
397
485
  @child_components.each do |child|
398
486
  child.unmount if child.respond_to?(:unmount)
399
487
  end
400
- @child_components = []
488
+ @child_components = [] #: Array[Funicular::Component]
401
489
 
402
490
  cleanup_events
403
491
  cleanup_suspense_timers
@@ -419,16 +507,23 @@ module Funicular
419
507
 
420
508
  # Override this method in subclasses to define render logic
421
509
  def render
422
- raise "Subclasses must implement the render method"
510
+ raise "Subclasses must implement render"
423
511
  end
424
512
 
425
513
  # Build VDOM tree from render method
426
514
  # Called by VDOM::Renderer, Differ, and Patcher
427
515
  def build_vdom
516
+ self.class.validate_dsl_conflicts!
517
+ previous_rendering = @rendering
518
+ previous_children = @current_children
428
519
  @rendering = true
429
520
  @current_children = nil
430
- result = render
431
- @rendering = false
521
+ begin
522
+ result = render
523
+ ensure
524
+ @rendering = previous_rendering
525
+ @current_children = previous_children
526
+ end
432
527
 
433
528
  # Convert render result to VNode
434
529
  vnode = normalize_vnode(result)
@@ -452,7 +547,7 @@ module Funicular
452
547
 
453
548
  vnode.props.each do |key, value|
454
549
  key_str = key.to_s
455
- next unless key_str.start_with?('on')
550
+ next unless VDOM.event_attribute?(key_str)
456
551
 
457
552
  event_name = key_str[2..-1]&.downcase || ""
458
553
  event_types << event_name
@@ -563,7 +658,7 @@ module Funicular
563
658
  @event_listeners.each do |callback_id|
564
659
  JS::Object.removeEventListener(callback_id)
565
660
  end
566
- @event_listeners = []
661
+ @event_listeners = [] #: Array[untyped]
567
662
 
568
663
  # NOTE: Do NOT cleanup child component events here!
569
664
  # Child components manage their own events and will cleanup
@@ -576,7 +671,110 @@ module Funicular
576
671
  @suspense_pending_timers.each do |timer_id|
577
672
  JS.global.clearTimeout(timer_id)
578
673
  end
579
- @suspense_pending_timers = []
674
+ @suspense_pending_timers = [] #: Array[untyped]
675
+ end
676
+
677
+ private
678
+
679
+ public
680
+
681
+ def normalize_vnode_for_view(value)
682
+ normalize_vnode(value)
683
+ end
684
+
685
+ # Internal element factory shared by the Tags mixin, FormBuilder, and
686
+ # the framework helpers. One instance per component; ViewContext itself
687
+ # is stateless (the render cursor lives on the component).
688
+ def __view__
689
+ @__view__ ||= ViewContext.new(self)
690
+ end
691
+
692
+ def routes
693
+ @runtime.routes
694
+ end
695
+
696
+ # Bareword DSL helpers available inside render (self is the component).
697
+ def component(component_class, props = {}, &block)
698
+ __view__.component(component_class, props, &block)
699
+ end
700
+
701
+ def form_for(model_key, options = {}, &block)
702
+ build_form_for(__view__, model_key, options, &block)
703
+ end
704
+
705
+ def link_to(path, **options, &block)
706
+ build_link_to(__view__, path, **options, &block)
707
+ end
708
+
709
+ def button_to(path, method: :post, **options, &block)
710
+ build_button_to(__view__, path, method: method, **options, &block)
711
+ end
712
+
713
+ def suspense(name, fallback:, error: nil, &block)
714
+ render_suspense(name, fallback: fallback, error: error, &block)
715
+ end
716
+
717
+ def add_child_from_view(child)
718
+ unless @rendering
719
+ raise Funicular::RenderContextError,
720
+ "view DSL called outside render (tags may only be used while the component is rendering)"
721
+ end
722
+ current_children = @current_children
723
+ return unless current_children
724
+
725
+ normalized = normalize_vnode(child)
726
+ current_children << normalized if normalized
727
+ end
728
+
729
+ def build_form_for(h, model_key, options = {}, &block)
730
+ on_submit = options.delete(:on_submit)
731
+ form_class = options.delete(:class)
732
+
733
+ submit_handler = if on_submit
734
+ ->(event) do
735
+ event.preventDefault
736
+ form_data = collect_form_data(event, model_key)
737
+
738
+ case on_submit
739
+ when Symbol
740
+ send(on_submit, form_data)
741
+ when Method
742
+ on_submit.call(form_data)
743
+ when Proc
744
+ on_submit.call(form_data)
745
+ else
746
+ raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
747
+ end
748
+ end
749
+ else
750
+ ->(event) { event.preventDefault }
751
+ end
752
+
753
+ h.form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
754
+ builder = Funicular::FormBuilder.new(self, h, model_key, options)
755
+ block.call(builder)
756
+ end
757
+ end
758
+
759
+ def build_link_to(h, path, method: :get, **options, &block)
760
+ merged_options = options.merge(href: path)
761
+ merged_options[:onclick] = ->(event) {
762
+ event.preventDefault
763
+ if method.to_s.downcase.to_sym == :get
764
+ handle_link_click(path)
765
+ else
766
+ handle_link_with_method(path, method)
767
+ end
768
+ }
769
+ h.a(merged_options, &block)
770
+ end
771
+
772
+ def build_button_to(h, path, method: :post, **options, &block)
773
+ merged_options = options.merge(
774
+ type: options[:type] || "button",
775
+ onclick: -> { handle_link_with_method(path, method) }
776
+ )
777
+ h.button(merged_options, &block)
580
778
  end
581
779
 
582
780
  private
@@ -615,7 +813,7 @@ module Funicular
615
813
  cleanup_events
616
814
 
617
815
  unless patches.empty?
618
- new_dom_element = VDOM::Patcher.new.apply(@dom_element, patches)
816
+ new_dom_element = VDOM::Patcher.new(nil, @runtime).apply(@dom_element, patches)
619
817
  # apply returns JS::Object (it must accept text-node patches), but
620
818
  # the component's root is always an Element. Narrow to JS::Element.
621
819
  @dom_element = new_dom_element if new_dom_element.is_a?(JS::Element)
@@ -698,7 +896,7 @@ module Funicular
698
896
  # appendChild: the stale node already has a place in the document, so we
699
897
  # replaceChild instead.
700
898
  def full_render_fallback(new_vdom, server_dom)
701
- fresh = VDOM::Renderer.new.render(new_vdom)
899
+ fresh = VDOM::Renderer.new(nil, @runtime).render(new_vdom)
702
900
  parent = server_dom.parentNode
703
901
  parent.replaceChild(fresh, server_dom) if parent
704
902
  bind_events(fresh, new_vdom)
@@ -721,6 +919,8 @@ module Funicular
721
919
 
722
920
  if child.is_a?(VDOM::Component)
723
921
  instance = child.component_class.new(child.props)
922
+ instance.runtime = child.runtime || @runtime
923
+ instance.children = child.children
724
924
  child.instance = instance
725
925
  instance.hydrate(child_dom)
726
926
  elsif child.is_a?(VDOM::Element)
@@ -731,7 +931,7 @@ module Funicular
731
931
 
732
932
  # Collect child component instances from VDOM tree
733
933
  def collect_child_components(vnode)
734
- @child_components = []
934
+ @child_components = [] #: Array[Funicular::Component]
735
935
  collect_child_components_recursive(vnode, @child_components)
736
936
  end
737
937
 
@@ -750,135 +950,6 @@ module Funicular
750
950
  end
751
951
  end
752
952
 
753
- # DSL methods for HTML elements
754
- HTML_TAGS = %w[
755
- div span p a
756
- h1 h2 h3 h4 h5 h6
757
- ul ol li
758
- table thead tbody tr th td
759
- form input textarea button select option label
760
- header footer nav section article aside
761
- img video audio canvas
762
- br hr
763
- ]
764
-
765
- # The HTML tag DSL methods are public: besides being called inside render
766
- # (implicit self), they are invoked by collaborators such as FormBuilder
767
- # with an explicit receiver (@component.div). A private method would forbid
768
- # that under CRuby (it is tolerated under mruby), which would break SSR of
769
- # any component using form_for. Keep them public on both VMs.
770
- public
771
-
772
- HTML_TAGS.each do |tag|
773
- define_method(tag) do |props = {}, &block|
774
- # @type self: Component
775
- children = [] #: Array[Funicular::VDOM::child_t]
776
-
777
- if block
778
- prev_children = @current_children
779
- @current_children = children
780
- # @type var block: Proc
781
- result = block.call
782
- @current_children = prev_children
783
-
784
- # Add block result to children if not already added via add_child
785
- if result && !result.equal?(children) && children.empty?
786
- normalized = normalize_vnode(result)
787
- children << normalized if normalized
788
- end
789
- end
790
-
791
- # Normalize props (convert StyleValue to String for :class)
792
- normalized_props = {}
793
- # @type var props: Hash[Symbol, String]
794
- props.each do |key, value|
795
- if key == :class && value.is_a?(StyleValue)
796
- normalized_props[key] = value.to_s
797
- else
798
- normalized_props[key] = value
799
- end
800
- end
801
-
802
- # @type var normalized_props: Hash[Symbol, String]
803
- element = VDOM::Element.new(tag, normalized_props, children)
804
-
805
- # If we're inside another element's block, add this element to parent's children
806
- if @rendering && @current_children
807
- @current_children << element
808
- end
809
-
810
- element
811
- end
812
- end
813
-
814
- private
815
-
816
- # Helper to add children in DSL blocks
817
- def add_child(child)
818
- return unless @rendering && @current_children
819
-
820
- normalized = normalize_vnode(child)
821
- @current_children << normalized if normalized
822
- end
823
-
824
- # Helper method to render child components in DSL
825
- # Accepts optional block for passing children to the component
826
- def component(component_class, props = {}, &block)
827
- unless component_class.is_a?(Class) && component_class.ancestors.include?(Funicular::Component)
828
- raise "component() expects a Funicular::Component class"
829
- end
830
-
831
- # If a block is provided, store the children builder in props
832
- # This allows components like ErrorBoundary to control child rendering
833
- if block
834
- props = props.merge(children_block: block)
835
- end
836
-
837
- vnode = VDOM::Component.new(component_class, props)
838
-
839
- # If we're inside another element's block, add this component to parent's children
840
- if @rendering && @current_children
841
- @current_children << vnode
842
- end
843
-
844
- vnode
845
- end
846
-
847
- # Rails-style form_for helper
848
- def form_for(model_key, options = {}, &block)
849
- on_submit = options.delete(:on_submit)
850
- form_class = options.delete(:class)
851
-
852
- # Build submit handler
853
- submit_handler = if on_submit
854
- ->(event) do
855
- event.preventDefault
856
-
857
- form_data = collect_form_data(event, model_key)
858
-
859
- # Call the submit handler (Symbol, Method, or Proc)
860
- case on_submit
861
- when Symbol
862
- send(on_submit, form_data)
863
- when Method
864
- on_submit.call(form_data)
865
- when Proc
866
- on_submit.call(form_data)
867
- else
868
- raise "on_submit must be Symbol, Method, or Proc, got #{on_submit.class}"
869
- end
870
- end
871
- else
872
- ->(event) { event.preventDefault }
873
- end
874
-
875
- # Render form
876
- form({ onsubmit: submit_handler, class: form_class }.merge(options)) do
877
- builder = Funicular::FormBuilder.new(self, model_key, options)
878
- block.call(builder)
879
- end
880
- end
881
-
882
953
  def collect_form_data(event, model_key)
883
954
  form_data = collect_dom_form_data(event)
884
955
  return form_data unless form_data.empty?
@@ -906,15 +977,9 @@ module Funicular
906
977
  end
907
978
 
908
979
  def event_target(event)
909
- target = nil
910
- begin
911
- target = event[:target]
912
- rescue
913
- target = nil
914
- end
915
- return target if target
916
-
917
- event.target if event.respond_to?(:target)
980
+ event[:target]
981
+ rescue
982
+ nil
918
983
  end
919
984
 
920
985
  def add_form_field_value(data, field)
@@ -937,7 +1002,7 @@ module Funicular
937
1002
  end
938
1003
 
939
1004
  def collect_state_form_data(model_key)
940
- model_data = state.send(model_key)
1005
+ model_data = state[model_key]
941
1006
  if model_data.is_a?(Hash)
942
1007
  model_data
943
1008
  elsif model_data.respond_to?(:instance_variables)
@@ -952,34 +1017,9 @@ module Funicular
952
1017
  end
953
1018
  end
954
1019
 
955
- # Rails-style link_to helper
956
- # Default behavior: Fetch API for same-page actions (SPA-friendly)
957
- # Use navigate: true for router navigation (different component tree)
958
- def link_to(path, method: :get, navigate: false, **options, &block)
959
- if navigate
960
- # Navigation: Use <a> tag with real href for browser features
961
- # (right-click -> open in new tab, link preview, etc.)
962
- # Note: preventDefault is automatically called by js_add_event_listener
963
- # for <a> tags to enable SPA navigation
964
- merged_options = options.merge(href: path)
965
- merged_options[:onclick] = ->(event) {
966
- event.preventDefault # Called for clarity, but already handled by JS layer
967
- handle_link_click(path)
968
- }
969
- a(merged_options, &block)
970
- else
971
- # Action: Use <div> tag (semantically more appropriate for actions)
972
- # No href needed, purely onclick-driven
973
- merged_options = options.merge(
974
- onclick: -> { handle_link_with_method(path, method) }
975
- )
976
- div(merged_options, &block)
977
- end
978
- end
979
-
980
1020
  # Handle router navigation (navigate using History API)
981
1021
  def handle_link_click(path)
982
- Funicular.router&.navigate(path)
1022
+ @runtime.router&.navigate(path)
983
1023
  end
984
1024
 
985
1025
  # Handle link action via Fetch API
@@ -1008,35 +1048,6 @@ module Funicular
1008
1048
  end
1009
1049
  end
1010
1050
 
1011
- # Enable URL helpers from RouteHelpers module and suspense data accessors
1012
- def method_missing(method, *args)
1013
- # Check for suspense data accessor
1014
- if self.class.suspense_definitions.key?(method)
1015
- return @suspense_data[method]
1016
- end
1017
-
1018
- if Funicular.const_defined?(:RouteHelpers)
1019
- helpers = Funicular::RouteHelpers
1020
- if helpers.instance_methods.include?(method)
1021
- # Include helpers module and retry
1022
- self.class.include(helpers) unless self.class.include?(helpers)
1023
- return send(method, *args)
1024
- end
1025
- end
1026
- super
1027
- end
1028
-
1029
- def respond_to_missing?(method, include_private = false)
1030
- # Check for suspense data accessor
1031
- return true if self.class.suspense_definitions.key?(method)
1032
-
1033
- if Funicular.const_defined?(:RouteHelpers)
1034
- Funicular::RouteHelpers.instance_methods.include?(method) || super
1035
- else
1036
- super
1037
- end
1038
- end
1039
-
1040
1051
  # Transition Helpers
1041
1052
  # These methods provide a declarative way to animate element removal/addition
1042
1053
  # using CSS transitions, inspired by Vue.js and Alpine.js transition systems