funicular 0.0.1 → 0.1.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. metadata +119 -8
data/mrblib/model.rb ADDED
@@ -0,0 +1,146 @@
1
+ module Funicular
2
+ class Model
3
+ attr_reader :id
4
+
5
+ class << self
6
+ attr_accessor :schema, :endpoints
7
+ end
8
+
9
+ def self.load_schema(schema_data)
10
+ @schema = schema_data["attributes"]
11
+ @endpoints = schema_data["endpoints"]
12
+
13
+ # Generate attr_accessor dynamically based on schema
14
+ @schema.each do |name, config|
15
+ attr_reader name.to_sym
16
+
17
+ unless config["readonly"]
18
+ define_method("#{name}=") do |value|
19
+ # @type self: Model
20
+ instance_variable_set("@#{name}", value)
21
+ @changed_attributes ||= {} # steep:ignore UnannotatedEmptyCollection
22
+ @changed_attributes[name] = value
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def initialize(attributes = {})
29
+ @changed_attributes = {}
30
+ # Set attributes based on schema
31
+ self.class.schema.each do |name, config|
32
+ value = attributes[name] || attributes[name.to_sym]
33
+ instance_variable_set("@#{name}", value)
34
+ end
35
+ end
36
+
37
+ def self.all(params = {}, &block)
38
+ endpoint = @endpoints["all"]
39
+ return unless endpoint
40
+
41
+ HTTP.get(endpoint["path"]) do |response|
42
+ if response.error?
43
+ block.call(nil, response.error_message) if block
44
+ else
45
+ instances = response.data.map { |attrs| new(attrs) }
46
+ block.call(instances, nil) if block
47
+ end
48
+ end
49
+ end
50
+
51
+ def self.find(id = nil, endpoint_name: "find", model_class: nil, &block)
52
+ endpoint = @endpoints[endpoint_name]
53
+ return unless endpoint
54
+
55
+ path = endpoint["path"]
56
+ path = path.gsub(":id", id.to_s) if id
57
+
58
+ HTTP.get(path) do |response|
59
+ if response.error?
60
+ block.call(nil, response.error_message) if block
61
+ else
62
+ klass = model_class || self
63
+ instance = klass.new(response.data)
64
+ block.call(instance, nil) if block
65
+ end
66
+ end
67
+ end
68
+
69
+ def self.create(attrs, model_class: nil, &block)
70
+ endpoint = @endpoints["create"]
71
+ return unless endpoint
72
+
73
+ HTTP.post(endpoint["path"], attrs) do |response|
74
+ if response.error?
75
+ block.call(nil, response.error_message) if block
76
+ else
77
+ klass = model_class || self
78
+ instance = klass.new(response.data)
79
+ block.call(instance, nil) if block
80
+ end
81
+ end
82
+ end
83
+
84
+ def self.destroy(id = nil, &block)
85
+ endpoint = @endpoints["destroy"]
86
+ return unless endpoint
87
+
88
+ path = id ? endpoint["path"].gsub(":id", id.to_s) : endpoint["path"]
89
+
90
+ HTTP.delete(path) do |response|
91
+ if response.error?
92
+ block.call(false, response.error_message) if block
93
+ else
94
+ block.call(true, response.data) if block
95
+ end
96
+ end
97
+ end
98
+
99
+ def update(attrs = nil, &block)
100
+ if attrs
101
+ attrs.each { |k, v| send("#{k}=", v) }
102
+ end
103
+
104
+ return if @changed_attributes.empty?
105
+
106
+ json_attrs = @changed_attributes.reject do |name, value|
107
+ schema = self.class.schema[name]
108
+ schema && schema["type"] == "binary"
109
+ end
110
+
111
+ return if json_attrs.empty?
112
+
113
+ endpoint = self.class.endpoints["update"]
114
+ path = endpoint["path"].gsub(":id", @id.to_s)
115
+
116
+ HTTP.patch(path, json_attrs) do |response|
117
+ if response.error?
118
+ block.call(false, response.error_message) if block
119
+ else
120
+ # Update attributes with response data
121
+ response.data.each do |key, value|
122
+ instance_variable_set("@#{key}", value)
123
+ end
124
+ @changed_attributes = {}
125
+ block.call(true, response.data) if block
126
+ end
127
+ end
128
+ end
129
+
130
+ def destroy(&block)
131
+ self.class.destroy(@id, &block)
132
+ end
133
+
134
+ def reload(&block)
135
+ self.class.find(@id) do |instance, error|
136
+ if instance
137
+ instance.instance_variables.each do |var|
138
+ instance_variable_set(var, instance.instance_variable_get(var))
139
+ end
140
+ @changed_attributes = {}
141
+ end
142
+ block.call(instance, error) if block
143
+ end
144
+ end
145
+ end
146
+ end
data/mrblib/patcher.rb ADDED
@@ -0,0 +1,203 @@
1
+ module Funicular
2
+ module VDOM
3
+ class Patcher
4
+ def initialize(doc = nil)
5
+ @doc = doc || JS.document
6
+ end
7
+
8
+ def apply(element, patches)
9
+ return element if patches.empty?
10
+ result = element
11
+ patches.each do |patch|
12
+ case patch[0]
13
+ when :replace
14
+ new_vnode = patch[1]
15
+ old_vnode = patch[2]
16
+ unmount_component(old_vnode)
17
+ new_element = create_element(new_vnode)
18
+ if parent = element.parentElement
19
+ parent.replaceChild(new_element, element)
20
+ result = new_element
21
+ end
22
+ when :props
23
+ update_props(element, patch[1])
24
+ when :update_and_rebind
25
+ instance, internal_patches, new_vdom = patch[1], patch[2], patch[3]
26
+ old_dom_element = instance.dom_element
27
+
28
+ # Apply internal patches and get the potentially new root element
29
+ new_dom_element = Patcher.new(@doc).apply(old_dom_element, internal_patches)
30
+
31
+ # Update the instance's reference to its root DOM element if it changed
32
+ if new_dom_element != old_dom_element
33
+ instance.dom_element = new_dom_element
34
+ end
35
+
36
+ # Update the instance's VDOM to the new one AFTER applying patches
37
+ instance.vdom = new_vdom
38
+
39
+ # Re-collect refs using the new DOM element
40
+ instance.collect_refs(new_dom_element, new_vdom)
41
+
42
+ # Re-bind events using the new DOM element
43
+ instance.cleanup_events
44
+ instance.bind_events(new_dom_element, new_vdom)
45
+
46
+ # Call component_updated on the child instance
47
+ instance.component_updated if instance.respond_to?(:component_updated)
48
+ when :update_props
49
+ # Update component props - element is the component's DOM element
50
+ # The component instance needs to be retrieved and updated
51
+ # This is handled at a higher level (in Component#re_render)
52
+ # For now, we just return the element as-is
53
+ when :remove
54
+ old_vnode = patch[1]
55
+ unmount_component(old_vnode)
56
+ element.parentElement&.removeChild(element)
57
+ when Integer
58
+ child_index = patch[0]
59
+ child_patches = patch[1]
60
+ # Use childNodes instead of children to include text nodes
61
+ child_nodes = element[:childNodes]
62
+ children = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[JS::Object]
63
+ child_element = children[child_index]
64
+ if child_element.nil?
65
+ # No existing child at this index - we need to create new elements
66
+ child_patches.each do |child_patch|
67
+ case child_patch[0]
68
+ when :replace
69
+ new_child_element = create_element(child_patch[1])
70
+ element.appendChild(new_child_element)
71
+ when :remove
72
+ # Nothing to remove if child doesn't exist
73
+ when Integer
74
+ # This shouldn't happen at the top level of child_patches
75
+ # But if it does, recursively process it
76
+ end
77
+ end
78
+ else
79
+ apply(child_element, child_patches)
80
+ end
81
+ end
82
+ end
83
+ result
84
+ end
85
+
86
+ private
87
+
88
+ def unmount_component(vnode)
89
+ return unless vnode.is_a?(VDOM::Component) && vnode.instance
90
+ vnode.instance.unmount
91
+ end
92
+
93
+ def update_props(element, props_patch)
94
+ props_patch.each do |key, value|
95
+ key_str = key.to_s
96
+
97
+ # Skip event handlers (handled by bind_events)
98
+ next if key_str.start_with?('on')
99
+
100
+ # Skip updating value for focused input/textarea elements
101
+ if key_str == "value"
102
+ tag_name = element[:tagName].to_s.downcase
103
+ if (tag_name == "input" || tag_name == "textarea")
104
+ active_element = @doc[:activeElement]
105
+ if active_element && element == active_element
106
+ next
107
+ end
108
+ # Use property instead of attribute for value
109
+ element[:value] = value.to_s
110
+ next
111
+ end
112
+ end
113
+
114
+ # Block javascript: URIs in URL attributes
115
+ if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
116
+ puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
117
+ next
118
+ end
119
+
120
+ # Handle boolean attributes
121
+ if BOOLEAN_ATTRIBUTES.include?(key_str)
122
+ if value.nil? || value.to_s == "false"
123
+ element.removeAttribute(key_str)
124
+ else
125
+ element.setAttribute(key_str, key_str)
126
+ end
127
+ elsif value.nil?
128
+ element.removeAttribute(key_str)
129
+ else
130
+ element.setAttribute(key_str, value.to_s)
131
+ end
132
+ end
133
+ end
134
+
135
+ def create_element(vnode)
136
+ return @doc.createTextNode(vnode) if vnode.is_a?(String)
137
+ if vnode.is_a?(Array)
138
+ # Arrays should have been flattened in VDOM::Element.normalize_children
139
+ # Create a wrapper div as fallback
140
+ wrapper = @doc.createElement("div")
141
+ vnode.each do |child|
142
+ if child.is_a?(VNode)
143
+ wrapper.appendChild(create_element(child))
144
+ elsif child.is_a?(String)
145
+ wrapper.appendChild(@doc.createTextNode(child))
146
+ end
147
+ end
148
+ return wrapper
149
+ end
150
+ raise "Invalid vnode: #{vnode.class}" unless vnode.is_a?(VNode)
151
+ case vnode.type
152
+ when :text
153
+ raise "Expected Text vnode" unless vnode.is_a?(Text)
154
+ @doc.createTextNode(vnode.content)
155
+ when :element
156
+ raise "Expected Element vnode" unless vnode.is_a?(Element)
157
+ element = @doc.createElement(vnode.tag)
158
+ vnode.props.each do |key, value|
159
+ key_str = key.to_s
160
+ next if key_str.start_with?('on')
161
+ if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
162
+ puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
163
+ next
164
+ end
165
+ if key_str == "value" && (vnode.tag == "input" || vnode.tag == "textarea")
166
+ element[:value] = value.to_s
167
+ elsif BOOLEAN_ATTRIBUTES.include?(key_str)
168
+ if value.nil? || value.to_s == "false"
169
+ else
170
+ element.setAttribute(key_str, key_str)
171
+ end
172
+ else
173
+ element.setAttribute(key_str, value.to_s)
174
+ end
175
+ end
176
+ vnode.children.each do |child|
177
+ if child.is_a?(VNode)
178
+ element.appendChild(create_element(child))
179
+ elsif child.is_a?(String)
180
+ text_node = @doc.createTextNode(child)
181
+ element.appendChild(text_node)
182
+ elsif child.is_a?(Array)
183
+ child.each do |c|
184
+ if c.is_a?(VNode)
185
+ element.appendChild(create_element(c))
186
+ elsif c.is_a?(String)
187
+ text_node = @doc.createTextNode(c)
188
+ element.appendChild(text_node)
189
+ end
190
+ end
191
+ end
192
+ end
193
+ element
194
+ when :component
195
+ raise "Expected Component vnode" unless vnode.is_a?(Component)
196
+ Renderer.new(@doc).render(vnode, nil)
197
+ else
198
+ raise "Unknown vnode type: #{vnode.type}"
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
data/mrblib/router.rb ADDED
@@ -0,0 +1,229 @@
1
+ module Funicular
2
+ class Router
3
+ attr_reader :routes, :current_component, :current_path
4
+
5
+ def initialize(container)
6
+ @container = container
7
+ @routes = []
8
+ @default_route = nil
9
+ @current_component = nil
10
+ @current_path = nil
11
+ @popstate_callback_id = nil
12
+ @url_helpers = Module.new
13
+ Funicular.const_set(:RouteHelpers, @url_helpers) unless Funicular.const_defined?(:RouteHelpers)
14
+ end
15
+
16
+ # Rails-style DSL methods
17
+ def get(path, to:, as: nil, constraints: nil)
18
+ add_route_with_method(:get, path, to, as, constraints)
19
+ end
20
+
21
+ def post(path, to:, as: nil, constraints: nil)
22
+ add_route_with_method(:post, path, to, as, constraints)
23
+ end
24
+
25
+ def put(path, to:, as: nil, constraints: nil)
26
+ add_route_with_method(:put, path, to, as, constraints)
27
+ end
28
+
29
+ def patch(path, to:, as: nil, constraints: nil)
30
+ add_route_with_method(:patch, path, to, as, constraints)
31
+ end
32
+
33
+ def delete(path, to:, as: nil, constraints: nil)
34
+ add_route_with_method(:delete, path, to, as, constraints)
35
+ end
36
+
37
+ # Add a route (backward compatibility)
38
+ def add_route(path, component_class, as: nil, constraints: nil)
39
+ add_route_with_method(:get, path, component_class, as, constraints)
40
+ end
41
+
42
+ # Set default route (used when path is empty)
43
+ def set_default(path)
44
+ @default_route = path
45
+ end
46
+
47
+ # Start listening to popstate
48
+ def start
49
+ # Clean up existing listener if any (prevents duplicate registration)
50
+ if @popstate_callback_id
51
+ JS::Object.removeEventListener(@popstate_callback_id)
52
+ @popstate_callback_id = nil
53
+ end
54
+
55
+ # Set up popstate listener
56
+ @popstate_callback_id = JS.global.addEventListener('popstate') do |event|
57
+ handle_route_change
58
+ end
59
+
60
+ # Handle initial route
61
+ if current_location_path == '/' && @default_route
62
+ # Use replaceState to not add a new entry to the history
63
+ JS.global.history.replaceState(JS::Bridge.to_js({}), '', @default_route)
64
+ end
65
+ handle_route_change
66
+ end
67
+
68
+ # Stop listening to popstate
69
+ def stop
70
+ if @popstate_callback_id
71
+ JS::Object.removeEventListener(@popstate_callback_id)
72
+ @popstate_callback_id = nil
73
+ end
74
+
75
+ unmount_current_component
76
+ end
77
+
78
+ # Navigate to a path programmatically using History API
79
+ def navigate(path)
80
+ JS.global.history.pushState(JS::Bridge.to_js({}), '', path)
81
+ # Manually trigger route change because pushState doesn't fire popstate
82
+ handle_route_change
83
+ end
84
+
85
+ # Get current path from location
86
+ def current_location_path
87
+ js_path_obj = JS.global.location.pathname
88
+ path = js_path_obj.to_s
89
+ path.empty? ? '/' : path
90
+ end
91
+
92
+ private
93
+
94
+ # Handle route change
95
+ def handle_route_change
96
+ path = current_location_path
97
+
98
+ # Find matching route
99
+ component_class, params = find_route(path)
100
+
101
+ unless component_class
102
+ # Maybe render a 404 component?
103
+ return
104
+ end
105
+
106
+ # Don't remount if already on this path
107
+ return if @current_path == path
108
+
109
+ # Unmount current component
110
+ unmount_current_component
111
+
112
+ # Mount new component
113
+ @current_path = path
114
+ @current_component = component_class.new(params)
115
+ # @type ivar @current_component: Funicular::Component
116
+ @current_component.mount(@container)
117
+ end
118
+
119
+ # Unmount current component
120
+ def unmount_current_component
121
+ @current_component&.unmount
122
+ @current_component = nil
123
+ @current_path = nil
124
+ end
125
+
126
+ private
127
+
128
+ def add_route_with_method(method, path, component_class, name = '', constraints = nil)
129
+ pattern_segments = path.split('/').reject { |s| s.empty? }
130
+ route = {
131
+ method: method,
132
+ path: path,
133
+ component: component_class,
134
+ name: name,
135
+ pattern_segments: pattern_segments,
136
+ constraints: constraints || {}
137
+ }
138
+ # @type var route: Funicular::route_definition_t
139
+ @routes << route
140
+
141
+ # Generate URL helper if name is provided
142
+ generate_url_helper(name, path) if name
143
+ end
144
+
145
+ def generate_url_helper(name, path_pattern)
146
+ helper_method_name = "#{name}_path".to_sym
147
+
148
+ # Check for duplicate helper names
149
+ if @url_helpers.instance_methods.include?(helper_method_name)
150
+ raise "URL helper '#{helper_method_name}' is already defined"
151
+ end
152
+
153
+ # Extract parameter names from path pattern (without regex)
154
+ param_names = extract_param_names(path_pattern)
155
+
156
+ # Define the helper method
157
+ if param_names.empty?
158
+ # No parameters - return static path
159
+ @url_helpers.module_eval do
160
+ define_method(helper_method_name) do # steep:ignore
161
+ path_pattern
162
+ end
163
+ end
164
+ else
165
+ # With parameters
166
+ @url_helpers.module_eval do
167
+ define_method(helper_method_name) do |*args| # steep:ignore
168
+ # Handle model objects with id method
169
+ if args.length == 1 && args[0].respond_to?(:id) && param_names.length == 1
170
+ args = [args[0].id]
171
+ elsif args.length != param_names.length
172
+ raise ArgumentError, "#{helper_method_name} expects #{param_names.length} argument(s), got #{args.length}"
173
+ end
174
+
175
+ result = path_pattern.dup
176
+ param_names.each_with_index do |param, idx|
177
+ result = result.sub(":#{param}", args[idx].to_s)
178
+ end
179
+ result
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ def extract_param_names(path_pattern)
186
+ path_pattern.split('/').select { |s|
187
+ s.start_with?(':')
188
+ }.map {
189
+ |s| s[1..-1]&.to_sym
190
+ }.compact
191
+ end
192
+
193
+ def find_route(path)
194
+ path_segments = path.split('/').reject { |s| s.empty? }
195
+ params = {} #: Hash[Symbol, untyped]
196
+
197
+ @routes.each do |route|
198
+ pattern_segments = route[:pattern_segments]
199
+ next if pattern_segments.length != path_segments.length
200
+
201
+ match = true
202
+
203
+ pattern_segments.each_with_index do |pattern_segment, index|
204
+ path_segment = path_segments[index]
205
+
206
+ if pattern_segment.start_with?(':')
207
+ param_name = pattern_segment[1..-1]&.to_sym
208
+ if param_name.nil?
209
+ raise "Invalid parameter name in route pattern: #{route[:path]}"
210
+ end
211
+ constraint = route[:constraints][param_name]
212
+ if constraint && !constraint.match?(path_segment)
213
+ match = false
214
+ break
215
+ end
216
+ params[param_name] = path_segment
217
+ elsif pattern_segment != path_segment
218
+ match = false
219
+ break
220
+ end
221
+ end
222
+
223
+ return [route[:component], params] if match
224
+ end
225
+
226
+ [nil, params] # No route found
227
+ end
228
+ end
229
+ end
data/mrblib/styles.rb ADDED
@@ -0,0 +1,83 @@
1
+ module Funicular
2
+ class StyleValue
3
+ attr_reader :value
4
+
5
+ def initialize(value)
6
+ @value = value.to_s
7
+ end
8
+
9
+ def |(other)
10
+ case other
11
+ when StyleValue
12
+ StyleValue.new("#{@value} #{other.value}".strip)
13
+ when String
14
+ StyleValue.new("#{@value} #{other}".strip)
15
+ when nil
16
+ self
17
+ else
18
+ other = other #: untyped
19
+ StyleValue.new("#{@value} #{other.to_s}".strip)
20
+ end
21
+ end
22
+
23
+ def to_s
24
+ @value
25
+ end
26
+ end
27
+
28
+ class StyleAccessor
29
+ def initialize(definitions)
30
+ @definitions = definitions
31
+ end
32
+
33
+ def method_missing(name, *args)
34
+ style = @definitions[name]
35
+ return StyleValue.new("") unless style
36
+
37
+ if args.empty?
38
+ # No arguments: return base or value
39
+ StyleValue.new(style[:base] || style[:value] || "")
40
+ elsif args[0] == true || args[0] == false
41
+ # Boolean argument: base + active (if true)
42
+ # JS::Object#== now supports direct comparison with Ruby true/false
43
+ base = style[:base] || ""
44
+ active_class = (args[0] == true) ? (style[:active] || "") : ""
45
+ StyleValue.new("#{base} #{active_class}".strip)
46
+ elsif args[0].is_a?(Symbol)
47
+ # Symbol argument: base + variants[symbol]
48
+ base = style[:base] || ""
49
+ variant_class = style[:variants] ? (style[:variants][args[0]] || "") : ""
50
+ StyleValue.new("#{base} #{variant_class}".strip)
51
+ else
52
+ # Other types: just return base
53
+ StyleValue.new(style[:base] || style[:value] || "")
54
+ end
55
+ end
56
+
57
+ def respond_to_missing?(name, include_private = false)
58
+ @definitions.key?(name) || super
59
+ end
60
+ end
61
+
62
+ class StyleBuilder
63
+ def initialize
64
+ @definitions = {}
65
+ end
66
+
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]
74
+ else
75
+ raise ArgumentError, "Invalid style definition for #{name}"
76
+ end
77
+ end
78
+
79
+ def to_definitions
80
+ @definitions
81
+ end
82
+ end
83
+ end