funicular 0.2.0 → 0.3.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +5 -5
  4. data/Rakefile +23 -15
  5. data/demo/test_chartjs.html +17 -17
  6. data/demo/test_component.html +15 -15
  7. data/demo/test_error_boundary.html +41 -41
  8. data/demo/test_router.html +57 -57
  9. data/demo/tic-tac-toe.html +29 -29
  10. data/docs/architecture.md +43 -30
  11. data/lib/funicular/compiler.rb +15 -16
  12. data/lib/funicular/helpers/picoruby_helper.rb +24 -1
  13. data/lib/funicular/ssr/runtime.rb +2 -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} +14 -1
  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 +38 -23
  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 +1 -1
  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 +47 -23
  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 +47 -46
  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/fixtures/funicular_app/components/greeting_component.rb +6 -6
  37. data/minitest/form_for_test.rb +106 -0
  38. data/minitest/funicular_test.rb +28 -2
  39. data/minitest/hydration_test.rb +2 -2
  40. data/minitest/middleware_test.rb +154 -0
  41. data/minitest/picoruby_helper_test.rb +139 -0
  42. data/minitest/route_parser_test.rb +139 -0
  43. data/minitest/schema_test.rb +23 -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/component.rb +210 -229
  50. data/mrblib/debug.rb +7 -6
  51. data/mrblib/differ.rb +3 -1
  52. data/mrblib/error_boundary.rb +19 -27
  53. data/mrblib/form_builder.rb +34 -24
  54. data/mrblib/funicular.rb +2 -1
  55. data/mrblib/html_serializer.rb +14 -13
  56. data/mrblib/http.rb +12 -1
  57. data/mrblib/patcher.rb +16 -9
  58. data/mrblib/router.rb +9 -5
  59. data/mrblib/runtime.rb +28 -0
  60. data/mrblib/styles.rb +13 -17
  61. data/mrblib/vdom.rb +92 -9
  62. data/mrblib/view_context.rb +135 -0
  63. data/sig/component.rbs +34 -85
  64. data/sig/error_boundary.rbs +4 -4
  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 +3 -4
  72. data/sig/vdom.rbs +11 -2
  73. data/sig/view_context.rbs +47 -0
  74. metadata +19 -5
  75. data/lib/funicular/vendor/picorbc/VERSION +0 -1
  76. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
@@ -3,13 +3,13 @@ module Funicular
3
3
  # a fallback UI instead of crashing the entire application.
4
4
  #
5
5
  # Usage:
6
- # component(ErrorBoundary) do
7
- # component(RiskyComponent)
6
+ # h.component(ErrorBoundary) do |hh|
7
+ # hh.component(RiskyComponent)
8
8
  # end
9
9
  #
10
10
  # With custom fallback:
11
- # component(ErrorBoundary, fallback: ->(error) { div { "Error: #{error.message}" } }) do
12
- # component(RiskyComponent)
11
+ # h.component(ErrorBoundary, fallback: ->(h, error) { h.div { "Error: #{error.message}" } }) do |hh|
12
+ # hh.component(RiskyComponent)
13
13
  # end
14
14
  #
15
15
  # Props:
@@ -60,56 +60,48 @@ module Funicular
60
60
  end
61
61
  end
62
62
 
63
- def render
63
+ def render(h)
64
64
  if state[:has_error]
65
- render_fallback
65
+ render_fallback(h)
66
66
  else
67
- render_children
67
+ render_children(h)
68
68
  end
69
69
  end
70
70
 
71
71
  private
72
72
 
73
- def render_fallback
73
+ def render_fallback(h)
74
74
  if props[:fallback]
75
- result = props[:fallback].call(state[:error])
75
+ result = props[:fallback].call(h, state[:error])
76
76
  if result.is_a?(VDOM::VNode)
77
77
  result
78
78
  else
79
- div { result.to_s }
79
+ h.div { result.to_s }
80
80
  end
81
81
  else
82
- default_fallback
82
+ default_fallback(h)
83
83
  end
84
84
  end
85
85
 
86
- def default_fallback
87
- div(class: 'error-boundary-fallback', style: 'padding: 20px; background: #fee; border: 1px solid #f00; border-radius: 4px;') do
88
- h3(style: 'color: #c00; margin: 0 0 10px 0;') { "Something went wrong" }
86
+ def default_fallback(h)
87
+ h.div(class: 'error-boundary-fallback', style: 'padding: 20px; background: #fee; border: 1px solid #f00; border-radius: 4px;') do |hh|
88
+ hh.h3(style: 'color: #c00; margin: 0 0 10px 0;') { "Something went wrong" }
89
89
  if state[:error]
90
- div(style: 'font-family: monospace; white-space: pre-wrap; font-size: 12px; color: #600;') do
90
+ hh.div(style: 'font-family: monospace; white-space: pre-wrap; font-size: 12px; color: #600;') do
91
91
  "#{state[:error].class}: #{state[:error].message}"
92
92
  end
93
93
  end
94
94
  if Funicular.env.development? && state[:error_info]
95
- div(style: 'margin-top: 10px; font-size: 11px; color: #666;') do
95
+ hh.div(style: 'margin-top: 10px; font-size: 11px; color: #666;') do
96
96
  "Component: #{state[:error_info][:component_class]}"
97
97
  end
98
98
  end
99
99
  end
100
100
  end
101
101
 
102
- def render_children
103
- # Children are passed via children_block prop from the component() DSL
104
- # Errors are caught by VDOM::Renderer.render_component
105
- if props[:children_block]
106
- div(class: 'error-boundary-content') do
107
- props[:children_block].call
108
- end
109
- elsif props[:children]
110
- props[:children]
111
- else
112
- div { "" }
102
+ def render_children(h)
103
+ h.div(class: 'error-boundary-content') do
104
+ children
113
105
  end
114
106
  end
115
107
  end
@@ -1,9 +1,10 @@
1
1
  module Funicular
2
2
  class FormBuilder
3
- attr_reader :component, :model_key, :options
3
+ attr_reader :component, :view_context, :model_key, :options
4
4
 
5
- def initialize(component, model_key, options = {})
5
+ def initialize(component, view_context, model_key, options = {})
6
6
  @component = component
7
+ @view_context = view_context
7
8
  @model_key = model_key
8
9
  @options = options
9
10
  # Per-form options win, then the global Funicular.configure_forms config,
@@ -32,7 +33,8 @@ module Funicular
32
33
  end
33
34
 
34
35
  # Check for errors
35
- error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
36
+ errors = @component.state[:errors]
37
+ error_message = errors ? errors[field_key.to_sym] : nil
36
38
  # errors may be a single message (legacy) or an array of messages
37
39
  # (Funicular::Model::Errors#messages). Show the first.
38
40
  error_message = error_message.first if error_message.is_a?(Array)
@@ -53,6 +55,7 @@ module Funicular
53
55
 
54
56
  # Build field attributes
55
57
  attrs = {
58
+ name: field_options[:name] || field_key,
56
59
  type: field_type,
57
60
  value: value,
58
61
  oninput: on_input
@@ -62,10 +65,10 @@ module Funicular
62
65
  attrs[:class] = css_class unless css_class.empty?
63
66
 
64
67
  # Render field + error message
65
- @component.div do
66
- @component.input(attrs)
68
+ @view_context.div do |h|
69
+ h.input(attrs)
67
70
  if has_error
68
- @component.div(class: @error_class) { error_message }
71
+ h.div(class: @error_class) { error_message }
69
72
  end
70
73
  end
71
74
  end
@@ -98,7 +101,8 @@ module Funicular
98
101
  set_nested_value(@model_key, field_key, new_value)
99
102
  end
100
103
 
101
- error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
104
+ errors = @component.state[:errors]
105
+ error_message = errors ? errors[field_key.to_sym] : nil
102
106
  # errors may be a single message (legacy) or an array of messages
103
107
  # (Funicular::Model::Errors#messages). Show the first.
104
108
  error_message = error_message.first if error_message.is_a?(Array)
@@ -117,15 +121,17 @@ module Funicular
117
121
  end
118
122
 
119
123
  attrs = {
124
+ name: options[:name] || field_key,
125
+ value: value,
120
126
  oninput: on_input
121
127
  }.merge(options.reject { |k, _| k == :class })
122
128
 
123
129
  attrs[:class] = css_class unless css_class.empty?
124
130
 
125
- @component.div do
126
- @component.textarea(attrs) { value }
131
+ @view_context.div do |h|
132
+ h.textarea(attrs)
127
133
  if has_error
128
- @component.div(class: @error_class) { error_message }
134
+ h.div(class: @error_class) { error_message }
129
135
  end
130
136
  end
131
137
  end
@@ -142,12 +148,13 @@ module Funicular
142
148
  end
143
149
 
144
150
  attrs = {
151
+ name: options[:name] || field_key,
145
152
  type: "checkbox",
146
153
  checked: value,
147
154
  onchange: on_change
148
155
  }.merge(options)
149
156
 
150
- @component.input(attrs)
157
+ @view_context.input(attrs)
151
158
  end
152
159
 
153
160
  def select(field_name, choices, options = {})
@@ -161,7 +168,8 @@ module Funicular
161
168
  set_nested_value(@model_key, field_key, new_value)
162
169
  end
163
170
 
164
- error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
171
+ errors = @component.state[:errors]
172
+ error_message = errors ? errors[field_key.to_sym] : nil
165
173
  # errors may be a single message (legacy) or an array of messages
166
174
  # (Funicular::Model::Errors#messages). Show the first.
167
175
  error_message = error_message.first if error_message.is_a?(Array)
@@ -180,23 +188,24 @@ module Funicular
180
188
  end
181
189
 
182
190
  attrs = {
191
+ name: options[:name] || field_key,
183
192
  onchange: on_change
184
193
  }.merge(options.reject { |k, _| k == :class })
185
194
 
186
195
  attrs[:class] = css_class unless css_class.empty?
187
196
 
188
- @component.div do
189
- @component.select(attrs) do
197
+ @view_context.div do |h|
198
+ h.select(attrs) do |hh|
190
199
  choices.each do |choice|
191
200
  option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
192
201
  selected = value.to_s == option_value.to_s
193
- @component.option(value: option_value, selected: selected) do
202
+ hh.option(value: option_value, selected: selected) do
194
203
  option_text
195
204
  end
196
205
  end
197
206
  end
198
207
  if has_error
199
- @component.div(class: @error_class) { error_message }
208
+ h.div(class: @error_class) { error_message }
200
209
  end
201
210
  end
202
211
  end
@@ -222,21 +231,22 @@ module Funicular
222
231
  on_change = custom_handler || default_handler
223
232
 
224
233
  attrs = {
234
+ name: options[:name] || field_key,
225
235
  type: "file",
226
236
  onchange: on_change
227
237
  }.merge(options)
228
238
 
229
- @component.input(attrs)
239
+ @view_context.input(attrs)
230
240
  end
231
241
 
232
242
  def submit(label = "Submit", options = {})
233
243
  attrs = { type: "submit" }.merge(options)
234
- @component.button(attrs) { label }
244
+ @view_context.button(attrs) { label }
235
245
  end
236
246
 
237
247
  def label(field_name, text = nil, options = {})
238
248
  text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
239
- @component.label(options) { text }
249
+ @view_context.label(options) { text }
240
250
  end
241
251
 
242
252
  private
@@ -246,10 +256,10 @@ module Funicular
246
256
  keys = key_path.split('.')
247
257
  value = state
248
258
  keys.each do |key|
249
- if value.respond_to?(key.to_sym)
250
- value = value.send(key.to_sym)
251
- elsif value.is_a?(Hash)
259
+ if value.is_a?(Hash)
252
260
  value = value[key.to_sym] || value[key]
261
+ elsif value.respond_to?(:[])
262
+ value = value[key.to_sym]
253
263
  else
254
264
  value = nil
255
265
  end
@@ -264,12 +274,12 @@ module Funicular
264
274
  if field_key.include?('.')
265
275
  # Complex nested update
266
276
  keys = field_key.split('.')
267
- current_model = @component.state.send(model_key.to_sym)
277
+ current_model = @component.state[model_key.to_sym]
268
278
  updated_model = deep_merge_value(current_model, keys, new_value)
269
279
  @component.patch(model_key.to_sym => updated_model)
270
280
  else
271
281
  # Simple update
272
- current_model = @component.state.send(model_key.to_sym)
282
+ current_model = @component.state[model_key.to_sym]
273
283
  if current_model.nil?
274
284
  @component.patch(model_key.to_sym => { field_key.to_sym => new_value })
275
285
  elsif current_model.is_a?(Hash)
data/mrblib/funicular.rb CHANGED
@@ -18,7 +18,7 @@ module Funicular
18
18
  # Guard against redefinition: when the mrblib runtime is loaded into a
19
19
  # CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
20
20
  # VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
21
- VERSION = '0.1.0' unless Funicular.const_defined?(:VERSION)
21
+ VERSION = '0.3.0' unless Funicular.const_defined?(:VERSION)
22
22
 
23
23
  def self.version
24
24
  VERSION
@@ -185,6 +185,7 @@ module Funicular
185
185
  # Otherwise, mount single component (backward compatible)
186
186
  if component_class
187
187
  instance = component_class.new(props)
188
+ instance.runtime = Funicular::Runtime.new(nil)
188
189
  server_root = hydrate ? first_element_child(container_element) : nil
189
190
  if server_root
190
191
  instance.seed_state(window_state)
@@ -16,10 +16,14 @@ module Funicular
16
16
  ]
17
17
 
18
18
  # Props that must never be emitted as HTML attributes.
19
- SKIP_PROPS = %i[ref key children_block]
19
+ SKIP_PROPS = %i[ref key]
20
20
 
21
- def self.serialize(vnode)
22
- new.render(vnode)
21
+ def self.serialize(vnode, runtime = nil)
22
+ new(runtime).render(vnode)
23
+ end
24
+
25
+ def initialize(runtime = nil)
26
+ @runtime = runtime
23
27
  end
24
28
 
25
29
  def render(vnode)
@@ -46,7 +50,7 @@ module Funicular
46
50
  tag = element.tag
47
51
  attrs = serialize_props(element.props)
48
52
 
49
- if VOID_ELEMENTS.include?(tag)
53
+ if VOID_ELEMENTS.include?(tag.downcase)
50
54
  "<#{tag}#{attrs}>"
51
55
  else
52
56
  "<#{tag}#{attrs}>#{render_children(element.children)}</#{tag}>"
@@ -75,6 +79,8 @@ module Funicular
75
79
 
76
80
  def render_component(component_vnode)
77
81
  instance = component_vnode.component_class.new(component_vnode.props)
82
+ instance.runtime = component_vnode.runtime || @runtime || Funicular::Runtime.new
83
+ instance.children = component_vnode.children
78
84
  component_vnode.instance = instance
79
85
  render(instance.build_vdom)
80
86
  end
@@ -83,17 +89,12 @@ module Funicular
83
89
  parts = [] #: Array[String]
84
90
  props.each do |key, value|
85
91
  key_str = key.to_s
86
- next if SKIP_PROPS.include?(key)
92
+ normalized_key = key_str.downcase
93
+ next if SKIP_PROPS.include?(key) || SKIP_PROPS.include?(normalized_key.to_sym)
87
94
  # Event handlers are bound on the client, never serialized.
88
- next if key_str.start_with?("on")
89
-
90
- if URL_ATTRIBUTES.include?(key_str) &&
91
- value.to_s.strip.downcase.start_with?("javascript:")
92
- # Prevent XSS via javascript: URIs (mirrors Renderer#render_element).
93
- next
94
- end
95
+ next if VDOM.blocked_attribute?(normalized_key, value)
95
96
 
96
- if BOOLEAN_ATTRIBUTES.include?(key_str)
97
+ if BOOLEAN_ATTRIBUTES.include?(normalized_key)
97
98
  # Boolean attributes are absent when false/nil, otherwise present.
98
99
  next if value.nil? || value.to_s == "false"
99
100
  parts << " #{key_str}=\"#{key_str}\""
data/mrblib/http.rb CHANGED
@@ -134,6 +134,17 @@ module Funicular
134
134
  block.call(Response.new(status, data)) if block
135
135
  end
136
136
 
137
+ def parse_response_body(text)
138
+ return nil if text.nil?
139
+
140
+ body = text.to_s
141
+ return nil if body.empty?
142
+
143
+ JSON.parse(body)
144
+ rescue
145
+ body
146
+ end
147
+
137
148
  def request(method, url, body, cache: nil, &block)
138
149
  if method == "GET" && cache.is_a?(Integer) && cache > 0
139
150
  entry = cache_lookup(url)
@@ -163,7 +174,7 @@ module Funicular
163
174
  JS.global.fetch(url, options) do |response|
164
175
  status = response.status.to_i
165
176
  json_text = response.to_binary
166
- data = JSON.parse(json_text)
177
+ data = parse_response_body(json_text)
167
178
  # @type var status: Integer
168
179
  http_response = Response.new(status, data)
169
180
 
data/mrblib/patcher.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  module Funicular
2
2
  module VDOM
3
3
  class Patcher
4
- def initialize(doc = nil)
4
+ def initialize(doc = nil, runtime = nil)
5
5
  @doc = doc || JS.document
6
+ @runtime = runtime
6
7
  end
7
8
 
8
9
  def apply(element, patches)
@@ -28,7 +29,7 @@ module Funicular
28
29
  old_dom_element = instance.dom_element
29
30
 
30
31
  # Apply internal patches and get the potentially new root element
31
- new_dom_element = Patcher.new(@doc).apply(old_dom_element, internal_patches)
32
+ new_dom_element = Patcher.new(@doc, instance.runtime).apply(old_dom_element, internal_patches)
32
33
 
33
34
  # Update the instance's reference to its root DOM element if it changed
34
35
  if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
@@ -159,9 +160,10 @@ module Funicular
159
160
 
160
161
  props_patch.each do |key, value|
161
162
  key_str = key.to_s
163
+ normalized_key = key_str.downcase
162
164
 
163
165
  # Skip event handlers (handled by bind_events)
164
- next if key_str.start_with?('on')
166
+ next if VDOM.event_attribute?(normalized_key)
165
167
 
166
168
  # Skip updating value for focused input/textarea elements
167
169
  if key_str == "value"
@@ -178,17 +180,19 @@ module Funicular
178
180
  end
179
181
 
180
182
  # Block javascript: URIs in URL attributes
181
- if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
183
+ if VDOM.blocked_attribute?(normalized_key, value)
182
184
  puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
183
185
  next
184
186
  end
185
187
 
186
188
  # Handle boolean attributes
187
- if BOOLEAN_ATTRIBUTES.include?(key_str)
189
+ if BOOLEAN_ATTRIBUTES.include?(normalized_key)
188
190
  if value.nil? || value.to_s == "false"
189
191
  element.removeAttribute(key_str)
192
+ element[key_str] = false
190
193
  else
191
194
  element.setAttribute(key_str, key_str)
195
+ element[key_str] = true
192
196
  end
193
197
  elsif value.nil?
194
198
  element.removeAttribute(key_str)
@@ -223,17 +227,20 @@ module Funicular
223
227
  element = @doc.createElement(vnode.tag)
224
228
  vnode.props.each do |key, value|
225
229
  key_str = key.to_s
226
- next if key_str.start_with?('on')
227
- if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
230
+ normalized_key = key_str.downcase
231
+ next if VDOM.event_attribute?(normalized_key)
232
+ if VDOM.blocked_attribute?(normalized_key, value)
228
233
  puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
229
234
  next
230
235
  end
231
236
  if key_str == "value" && (vnode.tag == "input" || vnode.tag == "textarea")
232
237
  element[:value] = value.to_s
233
- elsif BOOLEAN_ATTRIBUTES.include?(key_str)
238
+ elsif BOOLEAN_ATTRIBUTES.include?(normalized_key)
234
239
  if value.nil? || value.to_s == "false"
240
+ element[key_str] = false
235
241
  else
236
242
  element.setAttribute(key_str, key_str)
243
+ element[key_str] = true
237
244
  end
238
245
  else
239
246
  element.setAttribute(key_str, value.to_s)
@@ -259,7 +266,7 @@ module Funicular
259
266
  element
260
267
  when :component
261
268
  raise "Expected Component vnode" unless vnode.is_a?(Component)
262
- Renderer.new(@doc).render(vnode, nil)
269
+ Renderer.new(@doc, vnode.runtime || @runtime).render(vnode, nil)
263
270
  else
264
271
  raise "Unknown vnode type: #{vnode.type}"
265
272
  end
data/mrblib/router.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Funicular
2
2
  class Router
3
- attr_reader :routes, :current_component, :current_path
3
+ attr_reader :routes, :current_component, :current_path, :url_helpers, :route_helpers
4
4
 
5
5
  def initialize(container)
6
6
  @container = container
@@ -10,7 +10,9 @@ module Funicular
10
10
  @current_path = nil
11
11
  @popstate_callback_id = nil
12
12
  @url_helpers = Module.new
13
- Funicular.const_set(:RouteHelpers, @url_helpers) unless Funicular.const_defined?(:RouteHelpers)
13
+ @route_helpers = Object.new
14
+ @route_helpers.extend(@url_helpers)
15
+ @runtime = Funicular::Runtime.new(self)
14
16
  end
15
17
 
16
18
  # Rails-style DSL methods
@@ -133,6 +135,7 @@ module Funicular
133
135
  # Mount new component
134
136
  @current_path = path
135
137
  @current_component = component_class.new(params)
138
+ @current_component.runtime = @runtime
136
139
  # @type ivar @current_component: Funicular::Component
137
140
 
138
141
  server_root = hydrate_now ? Funicular.first_element_child(@container) : nil
@@ -147,6 +150,7 @@ module Funicular
147
150
  puts "[Funicular] Hydration failed, falling back to full render: #{e.message}"
148
151
  @container[:innerHTML] = ''
149
152
  @current_component = component_class.new(params)
153
+ @current_component.runtime = @runtime
150
154
  end
151
155
  end
152
156
 
@@ -194,19 +198,19 @@ module Funicular
194
198
  if param_names.empty?
195
199
  # No parameters - return static path
196
200
  @url_helpers.module_eval do
197
- define_method(helper_method_name) do # steep:ignore
201
+ define_method(helper_method_name) do
198
202
  path_pattern
199
203
  end
200
204
  end
201
205
  else
202
206
  # With parameters
203
207
  @url_helpers.module_eval do
204
- define_method(helper_method_name) do |*args| # steep:ignore
208
+ define_method(helper_method_name) do |*args|
205
209
  # Handle model objects with id method
206
210
  if args.length == 1 && args[0].respond_to?(:id) && param_names.length == 1
207
211
  args = [args[0].id]
208
212
  elsif args.length != param_names.length
209
- raise ArgumentError, "#{helper_method_name} expects #{param_names.length} argument(s), got #{args.length}"
213
+ Kernel.raise ArgumentError, "#{helper_method_name} expects #{param_names.length} argument(s), got #{args.length}"
210
214
  end
211
215
 
212
216
  result = path_pattern.dup
data/mrblib/runtime.rb ADDED
@@ -0,0 +1,28 @@
1
+ module Funicular
2
+ class Runtime
3
+ attr_accessor :router
4
+
5
+ def initialize(router = nil)
6
+ @router = router
7
+ end
8
+
9
+ def routes
10
+ router = @router
11
+ if router
12
+ router.route_helpers
13
+ else
14
+ EmptyRoutes
15
+ end
16
+ end
17
+ end
18
+
19
+ module EmptyRoutes
20
+ def self.method_missing(name, *args)
21
+ raise NoMethodError, "undefined route helper #{name}"
22
+ end
23
+
24
+ def self.respond_to_missing?(name, include_private = false)
25
+ false
26
+ end
27
+ end
28
+ end
data/mrblib/styles.rb CHANGED
@@ -30,33 +30,29 @@ module Funicular
30
30
  @definitions = definitions
31
31
  end
32
32
 
33
- def method_missing(name, *args)
33
+ def [](name, variant = nil)
34
34
  style = @definitions[name]
35
35
  return StyleValue.new("") unless style
36
36
 
37
- if args.empty?
37
+ if variant.nil?
38
38
  # No arguments: return base or value
39
39
  StyleValue.new(style[:base] || style[:value] || "")
40
- elsif args[0] == true || args[0] == false
40
+ elsif variant == true || variant == false
41
41
  # Boolean argument: base + active (if true)
42
42
  # JS::Object#== now supports direct comparison with Ruby true/false
43
43
  base = style[:base] || ""
44
- active_class = (args[0] == true) ? (style[:active] || "") : ""
44
+ active_class = (variant == true) ? (style[:active] || "") : ""
45
45
  StyleValue.new("#{base} #{active_class}".strip)
46
- elsif args[0].is_a?(Symbol)
46
+ elsif variant.is_a?(Symbol)
47
47
  # Symbol argument: base + variants[symbol]
48
48
  base = style[:base] || ""
49
- variant_class = style[:variants] ? (style[:variants][args[0]] || "") : ""
49
+ variant_class = style[:variants] ? (style[:variants][variant] || "") : ""
50
50
  StyleValue.new("#{base} #{variant_class}".strip)
51
51
  else
52
52
  # Other types: just return base
53
53
  StyleValue.new(style[:base] || style[:value] || "")
54
54
  end
55
55
  end
56
-
57
- def respond_to_missing?(name, include_private = false)
58
- @definitions.key?(name) || super
59
- end
60
56
  end
61
57
 
62
58
  class StyleBuilder
@@ -64,13 +60,13 @@ module Funicular
64
60
  @definitions = {}
65
61
  end
66
62
 
67
- def method_missing(name, *args)
68
- if args.size == 1 && args[0].is_a?(String)
69
- # Simple style: name "class-string"
70
- @definitions[name] = { value: args[0] }
71
- elsif args.size == 1 && args[0].is_a?(Hash)
72
- # Complex style with base/active/variants
73
- @definitions[name] = args[0]
63
+ def define(name, value = nil, **options)
64
+ if value.is_a?(String)
65
+ @definitions[name] = { value: value }
66
+ elsif value.is_a?(Hash)
67
+ @definitions[name] = value
68
+ elsif !options.empty?
69
+ @definitions[name] = options
74
70
  else
75
71
  raise ArgumentError, "Invalid style definition for #{name}"
76
72
  end