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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- 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
|