funicular 0.2.1 → 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 +2 -2
  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 +13 -13
  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 +16 -10
  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 +85 -84
  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 +2 -2
  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 +158 -237
  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 +28 -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 +12 -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 +90 -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 +18 -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)
@@ -63,10 +65,10 @@ module Funicular
63
65
  attrs[:class] = css_class unless css_class.empty?
64
66
 
65
67
  # Render field + error message
66
- @component.div do
67
- @component.input(attrs)
68
+ @view_context.div do |h|
69
+ h.input(attrs)
68
70
  if has_error
69
- @component.div(class: @error_class) { error_message }
71
+ h.div(class: @error_class) { error_message }
70
72
  end
71
73
  end
72
74
  end
@@ -99,7 +101,8 @@ module Funicular
99
101
  set_nested_value(@model_key, field_key, new_value)
100
102
  end
101
103
 
102
- 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
103
106
  # errors may be a single message (legacy) or an array of messages
104
107
  # (Funicular::Model::Errors#messages). Show the first.
105
108
  error_message = error_message.first if error_message.is_a?(Array)
@@ -125,10 +128,10 @@ module Funicular
125
128
 
126
129
  attrs[:class] = css_class unless css_class.empty?
127
130
 
128
- @component.div do
129
- @component.textarea(attrs)
131
+ @view_context.div do |h|
132
+ h.textarea(attrs)
130
133
  if has_error
131
- @component.div(class: @error_class) { error_message }
134
+ h.div(class: @error_class) { error_message }
132
135
  end
133
136
  end
134
137
  end
@@ -151,7 +154,7 @@ module Funicular
151
154
  onchange: on_change
152
155
  }.merge(options)
153
156
 
154
- @component.input(attrs)
157
+ @view_context.input(attrs)
155
158
  end
156
159
 
157
160
  def select(field_name, choices, options = {})
@@ -165,7 +168,8 @@ module Funicular
165
168
  set_nested_value(@model_key, field_key, new_value)
166
169
  end
167
170
 
168
- 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
169
173
  # errors may be a single message (legacy) or an array of messages
170
174
  # (Funicular::Model::Errors#messages). Show the first.
171
175
  error_message = error_message.first if error_message.is_a?(Array)
@@ -190,18 +194,18 @@ module Funicular
190
194
 
191
195
  attrs[:class] = css_class unless css_class.empty?
192
196
 
193
- @component.div do
194
- @component.select(attrs) do
197
+ @view_context.div do |h|
198
+ h.select(attrs) do |hh|
195
199
  choices.each do |choice|
196
200
  option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
197
201
  selected = value.to_s == option_value.to_s
198
- @component.option(value: option_value, selected: selected) do
202
+ hh.option(value: option_value, selected: selected) do
199
203
  option_text
200
204
  end
201
205
  end
202
206
  end
203
207
  if has_error
204
- @component.div(class: @error_class) { error_message }
208
+ h.div(class: @error_class) { error_message }
205
209
  end
206
210
  end
207
211
  end
@@ -232,17 +236,17 @@ module Funicular
232
236
  onchange: on_change
233
237
  }.merge(options)
234
238
 
235
- @component.input(attrs)
239
+ @view_context.input(attrs)
236
240
  end
237
241
 
238
242
  def submit(label = "Submit", options = {})
239
243
  attrs = { type: "submit" }.merge(options)
240
- @component.button(attrs) { label }
244
+ @view_context.button(attrs) { label }
241
245
  end
242
246
 
243
247
  def label(field_name, text = nil, options = {})
244
248
  text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
245
- @component.label(options) { text }
249
+ @view_context.label(options) { text }
246
250
  end
247
251
 
248
252
  private
@@ -252,10 +256,10 @@ module Funicular
252
256
  keys = key_path.split('.')
253
257
  value = state
254
258
  keys.each do |key|
255
- if value.respond_to?(key.to_sym)
256
- value = value.send(key.to_sym)
257
- elsif value.is_a?(Hash)
259
+ if value.is_a?(Hash)
258
260
  value = value[key.to_sym] || value[key]
261
+ elsif value.respond_to?(:[])
262
+ value = value[key.to_sym]
259
263
  else
260
264
  value = nil
261
265
  end
@@ -270,12 +274,12 @@ module Funicular
270
274
  if field_key.include?('.')
271
275
  # Complex nested update
272
276
  keys = field_key.split('.')
273
- current_model = @component.state.send(model_key.to_sym)
277
+ current_model = @component.state[model_key.to_sym]
274
278
  updated_model = deep_merge_value(current_model, keys, new_value)
275
279
  @component.patch(model_key.to_sym => updated_model)
276
280
  else
277
281
  # Simple update
278
- current_model = @component.state.send(model_key.to_sym)
282
+ current_model = @component.state[model_key.to_sym]
279
283
  if current_model.nil?
280
284
  @component.patch(model_key.to_sym => { field_key.to_sym => new_value })
281
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,13 +180,13 @@ 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)
190
192
  element[key_str] = false
@@ -225,14 +227,15 @@ module Funicular
225
227
  element = @doc.createElement(vnode.tag)
226
228
  vnode.props.each do |key, value|
227
229
  key_str = key.to_s
228
- next if key_str.start_with?('on')
229
- 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)
230
233
  puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
231
234
  next
232
235
  end
233
236
  if key_str == "value" && (vnode.tag == "input" || vnode.tag == "textarea")
234
237
  element[:value] = value.to_s
235
- elsif BOOLEAN_ATTRIBUTES.include?(key_str)
238
+ elsif BOOLEAN_ATTRIBUTES.include?(normalized_key)
236
239
  if value.nil? || value.to_s == "false"
237
240
  element[key_str] = false
238
241
  else
@@ -263,7 +266,7 @@ module Funicular
263
266
  element
264
267
  when :component
265
268
  raise "Expected Component vnode" unless vnode.is_a?(Component)
266
- Renderer.new(@doc).render(vnode, nil)
269
+ Renderer.new(@doc, vnode.runtime || @runtime).render(vnode, nil)
267
270
  else
268
271
  raise "Unknown vnode type: #{vnode.type}"
269
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