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/debug.rb CHANGED
@@ -152,16 +152,17 @@ module Funicular
152
152
 
153
153
  result = {} #: Hash[String, String]
154
154
  component.instance_variables.each do |var|
155
- next if var == :@state
156
- next if var.to_s.start_with?('@__debug')
157
- if var == :@vdom || var == :@child_components
158
- result[var.to_s] = "<omitted>"
155
+ name = var.to_s
156
+ next if name == '@state'
157
+ next if name.start_with?('@__debug')
158
+ if name == '@vdom' || name == '@child_components'
159
+ result[name] = "<omitted>"
159
160
  next
160
161
  end
161
162
  begin
162
- result[var.to_s] = component.instance_variable_get(var).inspect
163
+ result[name] = component.instance_variable_get(var).inspect
163
164
  rescue
164
- result[var.to_s] = "<error inspecting value>"
165
+ result[name] = "<error inspecting value>"
165
166
  end
166
167
  end
167
168
  JSON.generate(result)
data/mrblib/differ.rb CHANGED
@@ -213,11 +213,13 @@ module Funicular
213
213
  # If only Proc props changed, update props but skip VDOM rebuild
214
214
  unless data_props_changed
215
215
  instance.props = new_node.props
216
+ instance.children = new_node.children
216
217
  return []
217
218
  end
218
219
 
219
220
  old_internal_vdom = instance.vdom
220
221
  instance.props = new_node.props
222
+ instance.children = new_node.children
221
223
  new_internal_vdom = instance.build_vdom
222
224
  internal_patches = diff(old_internal_vdom, new_internal_vdom)
223
225
 
@@ -227,7 +229,7 @@ module Funicular
227
229
  end
228
230
 
229
231
  # Original logic: If props changed, replace the entire component
230
- if old_node.props != new_node.props
232
+ if old_node.props != new_node.props || old_node.children != new_node.children
231
233
  return [[:replace, new_node, old_node]]
232
234
  end
233
235
 
@@ -8,12 +8,18 @@ module Funicular
8
8
  # end
9
9
  #
10
10
  # With custom fallback:
11
- # component(ErrorBoundary, fallback: ->(error) { div { "Error: #{error.message}" } }) do
11
+ # component(ErrorBoundary, fallback: ->(h, error) { h.div { "Error: #{error.message}" } }) do
12
12
  # component(RiskyComponent)
13
13
  # end
14
14
  #
15
15
  # Props:
16
- # - fallback: Proc or Method that receives the error and returns VDOM
16
+ # - fallback: Proc or Method that receives (view_context, error) and
17
+ # returns VDOM. NOTE: unlike everywhere else, the fallback cannot use
18
+ # bareword tags. The proc is created in the parent component's scope
19
+ # but runs during the boundary's render, where only the boundary's
20
+ # cursor is active; a bareword tag would dispatch against the parent
21
+ # and raise RenderContextError. Build elements through the view
22
+ # context argument (`h.div { ... }`) instead.
17
23
  # - on_error: Optional callback when error is caught (for logging, reporting)
18
24
  #
19
25
  class ErrorBoundary < Component
@@ -72,7 +78,7 @@ module Funicular
72
78
 
73
79
  def render_fallback
74
80
  if props[:fallback]
75
- result = props[:fallback].call(state[:error])
81
+ result = props[:fallback].call(__view__, state[:error])
76
82
  if result.is_a?(VDOM::VNode)
77
83
  result
78
84
  else
@@ -100,16 +106,8 @@ module Funicular
100
106
  end
101
107
 
102
108
  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 { "" }
109
+ div(class: 'error-boundary-content') do
110
+ children
113
111
  end
114
112
  end
115
113
  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
69
+ @view_context.input(attrs)
68
70
  if has_error
69
- @component.div(class: @error_class) { error_message }
71
+ @view_context.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
132
+ @view_context.textarea(attrs)
130
133
  if has_error
131
- @component.div(class: @error_class) { error_message }
134
+ @view_context.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
198
+ @view_context.select(attrs) do
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
+ @view_context.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
+ @view_context.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.4.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
@@ -25,28 +25,45 @@ module Funicular
25
25
  end
26
26
  end
27
27
 
28
- class StyleAccessor
28
+ # Read-side accessor. One subclass is generated per component class with a
29
+ # real method per declared style name (see accessor_for), so lookups never
30
+ # go through method_missing at render time and a typo raises loudly.
31
+ # BasicObject keeps Object/Kernel names (display, hash, ...) usable as
32
+ # style names on both mruby and CRuby.
33
+ class StyleAccessor < BasicObject
34
+ def self.accessor_for(definitions)
35
+ klass = ::Class.new(self) #: singleton(StyleAccessor)
36
+ definitions.each_key do |name|
37
+ klass.__send__(:define_method, name) do |variant = nil|
38
+ self[name, variant] # steep:ignore
39
+ end
40
+ end
41
+ klass
42
+ end
43
+
29
44
  def initialize(definitions)
30
45
  @definitions = definitions
31
46
  end
32
47
 
33
- def method_missing(name, *args)
48
+ def [](name, variant = nil)
34
49
  style = @definitions[name]
35
- return StyleValue.new("") unless style
50
+ unless style
51
+ ::Kernel.raise ::ArgumentError,
52
+ "unknown style :#{name} (declared: #{@definitions.keys.map { |k| ":#{k}" }.join(', ')})"
53
+ end
36
54
 
37
- if args.empty?
55
+ if variant.nil?
38
56
  # No arguments: return base or value
39
57
  StyleValue.new(style[:base] || style[:value] || "")
40
- elsif args[0] == true || args[0] == false
58
+ elsif variant == true || variant == false
41
59
  # Boolean argument: base + active (if true)
42
- # JS::Object#== now supports direct comparison with Ruby true/false
43
60
  base = style[:base] || ""
44
- active_class = (args[0] == true) ? (style[:active] || "") : ""
61
+ active_class = (variant == true) ? (style[:active] || "") : ""
45
62
  StyleValue.new("#{base} #{active_class}".strip)
46
- elsif args[0].is_a?(Symbol)
63
+ elsif ::Symbol === variant
47
64
  # Symbol argument: base + variants[symbol]
48
65
  base = style[:base] || ""
49
- variant_class = style[:variants] ? (style[:variants][args[0]] || "") : ""
66
+ variant_class = style[:variants] ? (style[:variants][variant] || "") : ""
50
67
  StyleValue.new("#{base} #{variant_class}".strip)
51
68
  else
52
69
  # Other types: just return base
@@ -54,30 +71,99 @@ module Funicular
54
71
  end
55
72
  end
56
73
 
57
- def respond_to_missing?(name, include_private = false)
58
- @definitions.key?(name) || super
74
+ def method_missing(name, *_args)
75
+ ::Kernel.raise ::NoMethodError,
76
+ "unknown style '#{name}' (declared: #{@definitions.keys.map { |k| ":#{k}" }.join(', ')})"
77
+ end
78
+
79
+ def respond_to_missing?(name, _include_private = false)
80
+ @definitions.key?(name)
59
81
  end
60
82
  end
61
83
 
62
- class StyleBuilder
84
+ # Definition-side builder. The class-level `styles do ... end` block is
85
+ # instance_exec'd on an instance, so barewords define styles:
86
+ #
87
+ # styles do
88
+ # shell "app-shell"
89
+ # button base: "btn", variants: { primary: "btn--primary" }
90
+ # end
91
+ #
92
+ # BasicObject keeps the bareword namespace clean on both VMs; the block
93
+ # also receives the builder as an argument for the explicit form
94
+ # `styles { |css| css.define(:name, ...) }`, needed when a value is
95
+ # computed (a bareword helper call inside the block would be captured as
96
+ # a style definition, and its nil return raises via validation below).
97
+ class StyleBuilder < BasicObject
98
+ # Style names that would clobber the builder/accessor internals.
99
+ RESERVED_NAMES = %i[
100
+ initialize method_missing respond_to_missing? define to_definitions
101
+ validate_options
102
+ == != ! [] equal? __send__ __id__ instance_eval instance_exec
103
+ ]
104
+
63
105
  def initialize
64
- @definitions = {}
106
+ @definitions = {} #: Hash[Symbol, untyped]
65
107
  end
66
108
 
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]
109
+ def define(name, value = nil, **options)
110
+ if RESERVED_NAMES.include?(name)
111
+ ::Kernel.raise ::ArgumentError, "style name :#{name} is reserved"
112
+ end
113
+ if @definitions.key?(name)
114
+ ::Kernel.raise ::ArgumentError, "style :#{name} is already defined"
115
+ end
116
+
117
+ if ::String === value
118
+ @definitions[name] = { value: value }
119
+ elsif ::Hash === value
120
+ @definitions[name] = validate_options(name, value)
121
+ elsif (::NilClass === value) && !options.empty?
122
+ @definitions[name] = validate_options(name, options)
74
123
  else
75
- raise ArgumentError, "Invalid style definition for #{name}"
124
+ ::Kernel.raise ::ArgumentError,
125
+ "invalid style definition for :#{name}; expected a String, a " \
126
+ "Hash, or keyword options. Note: helper methods cannot be called " \
127
+ "bareword inside a styles block; use " \
128
+ "`styles { |css| css.define(...) }` for computed values."
129
+ end
130
+ self
131
+ end
132
+
133
+ def validate_options(name, options)
134
+ options.each do |key, val|
135
+ case key
136
+ when :value, :base, :active
137
+ unless ::String === val
138
+ ::Kernel.raise ::ArgumentError,
139
+ "style :#{name} option #{key}: must be a String. Note: helper " \
140
+ "methods cannot be called bareword inside a styles block; use " \
141
+ "`styles { |css| css.define(...) }` for computed values."
142
+ end
143
+ when :variants
144
+ unless (::Hash === val) && val.all? { |_k, v| ::String === v }
145
+ ::Kernel.raise ::ArgumentError,
146
+ "style :#{name} option variants: must be a Hash of Symbol => String"
147
+ end
148
+ else
149
+ ::Kernel.raise ::ArgumentError,
150
+ "style :#{name} has unknown option #{key.inspect} " \
151
+ "(allowed: value, base, active, variants)"
152
+ end
76
153
  end
154
+ options
77
155
  end
78
156
 
79
157
  def to_definitions
80
158
  @definitions
81
159
  end
160
+
161
+ def method_missing(name, value = nil, **options)
162
+ define(name, value, **options)
163
+ end
164
+
165
+ def respond_to_missing?(_name, _include_private = false)
166
+ true
167
+ end
82
168
  end
83
169
  end