funicular 0.0.1 → 0.2.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -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/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. metadata +154 -8
data/mrblib/model.rb ADDED
@@ -0,0 +1,196 @@
1
+ module Funicular
2
+ class Model
3
+ include Validations
4
+
5
+ attr_reader :id
6
+
7
+ class << self
8
+ attr_accessor :schema, :endpoints
9
+ end
10
+
11
+ def self.load_schema(schema_data)
12
+ @schema = schema_data["attributes"]
13
+ @endpoints = schema_data["endpoints"]
14
+
15
+ # Generate attr_accessor dynamically based on schema
16
+ @schema.each do |name, config|
17
+ attr_reader name.to_sym
18
+
19
+ unless config["readonly"]
20
+ define_method("#{name}=") do |value|
21
+ # @type self: Model
22
+ instance_variable_set("@#{name}", value)
23
+ @changed_attributes ||= {} # steep:ignore UnannotatedEmptyCollection
24
+ @changed_attributes[name] = value
25
+ end
26
+ end
27
+
28
+ # Validations are inlined per attribute by Funicular::Schema.build.
29
+ register_schema_validations(name => config["validations"]) if config["validations"]
30
+ end
31
+
32
+ # Backward-compatible: a top-level { attr => rules } block also works.
33
+ register_schema_validations(schema_data["validations"])
34
+ end
35
+
36
+ # validations: { "attr" => { "presence" => true, "length" => { "maximum" => 30 } } }
37
+ def self.register_schema_validations(validations)
38
+ return unless validations.is_a?(Hash)
39
+ validations.each do |attribute, rules|
40
+ next unless rules.is_a?(Hash)
41
+ rules.each do |kind, opts|
42
+ options = normalize_validation_options(kind, opts)
43
+ add_schema_validator(attribute, kind, options)
44
+ end
45
+ end
46
+ end
47
+
48
+ # Turn JSON-shaped validator options into the Ruby options the client
49
+ # validators expect (notably rebuilding a Regexp for `format`). Integer
50
+ # Regexp flags are used so this works the same under CRuby and the client
51
+ # JS RegExp wrapper.
52
+ def self.normalize_validation_options(kind, opts)
53
+ return opts unless opts.is_a?(Hash)
54
+ if kind.to_s == "format" && opts["with"]
55
+ flags = opts["flags"].to_s
56
+ bits = 0
57
+ bits |= Regexp::IGNORECASE if flags.include?("i")
58
+ bits |= Regexp::MULTILINE if flags.include?("m")
59
+ { with: Regexp.new(opts["with"], bits) }
60
+ else
61
+ opts
62
+ end
63
+ end
64
+
65
+ def initialize(attributes = {})
66
+ @changed_attributes = {}
67
+ # Set attributes based on schema
68
+ self.class.schema.each do |name, config|
69
+ value = attributes[name] || attributes[name.to_sym]
70
+ instance_variable_set("@#{name}", value)
71
+ end
72
+ end
73
+
74
+ def self.all(params = {}, &block)
75
+ endpoint = @endpoints["all"]
76
+ return unless endpoint
77
+
78
+ HTTP.get(endpoint["path"]) do |response|
79
+ if response.error?
80
+ block.call(nil, response.error_message) if block
81
+ else
82
+ instances = response.data.map { |attrs| new(attrs) }
83
+ block.call(instances, nil) if block
84
+ end
85
+ end
86
+ end
87
+
88
+ def self.find(id = nil, endpoint_name: "find", model_class: nil, &block)
89
+ endpoint = @endpoints[endpoint_name]
90
+ return unless endpoint
91
+
92
+ path = endpoint["path"]
93
+ path = path.gsub(":id", id.to_s) if id
94
+
95
+ HTTP.get(path) do |response|
96
+ if response.error?
97
+ block.call(nil, response.error_message) if block
98
+ else
99
+ klass = model_class || self
100
+ instance = klass.new(response.data)
101
+ block.call(instance, nil) if block
102
+ end
103
+ end
104
+ end
105
+
106
+ def self.create(attrs, model_class: nil, &block)
107
+ endpoint = @endpoints["create"]
108
+ return unless endpoint
109
+
110
+ # Validate on the client before the request (mirrors ActiveRecord#save).
111
+ candidate = new(attrs)
112
+ unless candidate.valid?
113
+ block.call(nil, candidate.errors) if block
114
+ return
115
+ end
116
+
117
+ HTTP.post(endpoint["path"], attrs) do |response|
118
+ if response.error?
119
+ block.call(nil, response.error_message) if block
120
+ else
121
+ klass = model_class || self
122
+ instance = klass.new(response.data)
123
+ block.call(instance, nil) if block
124
+ end
125
+ end
126
+ end
127
+
128
+ def self.destroy(id = nil, &block)
129
+ endpoint = @endpoints["destroy"]
130
+ return unless endpoint
131
+
132
+ path = id ? endpoint["path"].gsub(":id", id.to_s) : endpoint["path"]
133
+
134
+ HTTP.delete(path) do |response|
135
+ if response.error?
136
+ block.call(false, response.error_message) if block
137
+ else
138
+ block.call(true, response.data) if block
139
+ end
140
+ end
141
+ end
142
+
143
+ def update(attrs = nil, &block)
144
+ if attrs
145
+ attrs.each { |k, v| send("#{k}=", v) }
146
+ end
147
+
148
+ # Validate on the client before the request (mirrors ActiveRecord#save).
149
+ unless valid?
150
+ block.call(false, errors) if block
151
+ return
152
+ end
153
+
154
+ return if @changed_attributes.empty?
155
+
156
+ json_attrs = @changed_attributes.reject do |name, value|
157
+ schema = self.class.schema[name]
158
+ schema && schema["type"] == "binary"
159
+ end
160
+
161
+ return if json_attrs.empty?
162
+
163
+ endpoint = self.class.endpoints["update"]
164
+ path = endpoint["path"].gsub(":id", @id.to_s)
165
+
166
+ HTTP.patch(path, json_attrs) do |response|
167
+ if response.error?
168
+ block.call(false, response.error_message) if block
169
+ else
170
+ # Update attributes with response data
171
+ response.data.each do |key, value|
172
+ instance_variable_set("@#{key}", value)
173
+ end
174
+ @changed_attributes = {}
175
+ block.call(true, response.data) if block
176
+ end
177
+ end
178
+ end
179
+
180
+ def destroy(&block)
181
+ self.class.destroy(@id, &block)
182
+ end
183
+
184
+ def reload(&block)
185
+ self.class.find(@id) do |instance, error|
186
+ if instance
187
+ instance.instance_variables.each do |var|
188
+ instance_variable_set(var, instance.instance_variable_get(var))
189
+ end
190
+ @changed_attributes = {}
191
+ end
192
+ block.call(instance, error) if block
193
+ end
194
+ end
195
+ end
196
+ end
data/mrblib/patcher.rb ADDED
@@ -0,0 +1,269 @@
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
+ # Props patches only make sense for Element nodes (text nodes have
24
+ # no attributes); narrow before delegating.
25
+ update_props(element, patch[1]) if element.is_a?(JS::Element)
26
+ when :update_and_rebind
27
+ instance, internal_patches, new_vdom = patch[1], patch[2], patch[3]
28
+ old_dom_element = instance.dom_element
29
+
30
+ # Apply internal patches and get the potentially new root element
31
+ new_dom_element = Patcher.new(@doc).apply(old_dom_element, internal_patches)
32
+
33
+ # Update the instance's reference to its root DOM element if it changed
34
+ if new_dom_element != old_dom_element && new_dom_element.is_a?(JS::Element)
35
+ instance.dom_element = new_dom_element
36
+ end
37
+
38
+ # Update the instance's VDOM to the new one AFTER applying patches
39
+ instance.vdom = new_vdom
40
+
41
+ # Re-collect refs using the new DOM element
42
+ if new_dom_element.is_a?(JS::Element)
43
+ instance.collect_refs(new_dom_element, new_vdom)
44
+
45
+ # Re-bind events using the new DOM element
46
+ instance.cleanup_events
47
+ instance.bind_events(new_dom_element, new_vdom)
48
+ end
49
+
50
+ # Call component_updated on the child instance
51
+ instance.component_updated if instance.respond_to?(:component_updated)
52
+ when :update_props
53
+ # Update component props - element is the component's DOM element
54
+ # The component instance needs to be retrieved and updated
55
+ # This is handled at a higher level (in Component#re_render)
56
+ # For now, we just return the element as-is
57
+ when :remove
58
+ old_vnode = patch[1]
59
+ unmount_component(old_vnode)
60
+ element.parentElement&.removeChild(element)
61
+ when :keyed_children
62
+ # Composite patch produced by Differ.diff_children_with_keys.
63
+ # Applied in three phases against `element`:
64
+ # 1. snapshot DOM children, then remove unmatched keyed old
65
+ # children (descending old_index so the snapshot indices
66
+ # remain valid as removes happen).
67
+ # 2. apply content updates to kept children in place. The
68
+ # lookup is by snapshot[old_index], so updates are stable
69
+ # regardless of subsequent insertions.
70
+ # 3. insert new children at their new_index using
71
+ # insertBefore on the live DOM. Processed in ascending
72
+ # new_index order so each insertion fixes its own
73
+ # position before later inserts run.
74
+ ops = patch[1]
75
+ removes = patch[2]
76
+
77
+ child_nodes = element[:childNodes]
78
+ snapshot = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[untyped]
79
+
80
+ # Phase 1: removes (descending old_index)
81
+ sorted_removes = removes.sort { |a, b| b[0] <=> a[0] }
82
+ sorted_removes.each do |entry|
83
+ old_index = entry[0]
84
+ old_vnode = entry[1]
85
+ target = snapshot[old_index]
86
+ next if target.nil?
87
+ unmount_component(old_vnode)
88
+ parent_el = target.parentElement
89
+ parent_el.removeChild(target) if parent_el
90
+ end
91
+
92
+ # Phase 2: updates against the snapshot (no movement)
93
+ ops.each do |op|
94
+ next unless op[0] == :keep
95
+ old_index = op[1]
96
+ child_patches = op[3]
97
+ next if child_patches.empty?
98
+ target = snapshot[old_index]
99
+ next if target.nil?
100
+ apply(target, child_patches)
101
+ end
102
+
103
+ # Phase 3: inserts in ascending new_index order
104
+ ops.each do |op|
105
+ next unless op[0] == :insert
106
+ new_index = op[1]
107
+ new_vnode = op[2]
108
+ new_node = create_element(new_vnode)
109
+ next if new_node.nil?
110
+ live_nodes = element[:childNodes]
111
+ live_arr = live_nodes.is_a?(JS::Object) ? live_nodes.to_a : [] #: Array[untyped]
112
+ ref = live_arr[new_index]
113
+ if ref.nil?
114
+ element.appendChild(new_node) if element.is_a?(JS::Element)
115
+ else
116
+ element.insertBefore(new_node, ref) if element.is_a?(JS::Element)
117
+ end
118
+ end
119
+ when Integer
120
+ child_index = patch[0]
121
+ child_patches = patch[1]
122
+ # Use childNodes instead of children to include text nodes
123
+ child_nodes = element[:childNodes]
124
+ children = child_nodes.is_a?(JS::Object) ? child_nodes.to_a : [] #: Array[JS::Object]
125
+ child_element = children[child_index]
126
+ if child_element.nil?
127
+ # No existing child at this index - we need to create new elements
128
+ child_patches.each do |child_patch|
129
+ case child_patch[0]
130
+ when :replace
131
+ new_child_element = create_element(child_patch[1])
132
+ element.appendChild(new_child_element) if element.is_a?(JS::Element)
133
+ when :remove
134
+ # Nothing to remove if child doesn't exist
135
+ when Integer
136
+ # This shouldn't happen at the top level of child_patches
137
+ # But if it does, recursively process it
138
+ end
139
+ end
140
+ elsif child_element.is_a?(JS::Object)
141
+ # Recurse into the child node. apply handles both Element and
142
+ # text Node cases (the latter only meaningfully via :replace).
143
+ apply(child_element, child_patches)
144
+ end
145
+ end
146
+ end
147
+ result
148
+ end
149
+
150
+ private
151
+
152
+ def unmount_component(vnode)
153
+ return unless vnode.is_a?(VDOM::Component) && vnode.instance
154
+ vnode.instance.unmount
155
+ end
156
+
157
+ def update_props(element, props_patch)
158
+ return unless element.is_a?(JS::Element)
159
+
160
+ props_patch.each do |key, value|
161
+ key_str = key.to_s
162
+
163
+ # Skip event handlers (handled by bind_events)
164
+ next if key_str.start_with?('on')
165
+
166
+ # Skip updating value for focused input/textarea elements
167
+ if key_str == "value"
168
+ tag_name = element[:tagName].to_s.downcase
169
+ if (tag_name == "input" || tag_name == "textarea")
170
+ active_element = @doc[:activeElement]
171
+ if active_element && element == active_element
172
+ next
173
+ end
174
+ # Use property instead of attribute for value
175
+ element[:value] = value.to_s
176
+ next
177
+ end
178
+ end
179
+
180
+ # Block javascript: URIs in URL attributes
181
+ if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
182
+ puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
183
+ next
184
+ end
185
+
186
+ # Handle boolean attributes
187
+ if BOOLEAN_ATTRIBUTES.include?(key_str)
188
+ if value.nil? || value.to_s == "false"
189
+ element.removeAttribute(key_str)
190
+ else
191
+ element.setAttribute(key_str, key_str)
192
+ end
193
+ elsif value.nil?
194
+ element.removeAttribute(key_str)
195
+ else
196
+ element.setAttribute(key_str, value.to_s)
197
+ end
198
+ end
199
+ end
200
+
201
+ def create_element(vnode)
202
+ return @doc.createTextNode(vnode) if vnode.is_a?(String)
203
+ if vnode.is_a?(Array)
204
+ # Arrays should have been flattened in VDOM::Element.normalize_children
205
+ # Create a wrapper div as fallback
206
+ wrapper = @doc.createElement("div")
207
+ vnode.each do |child|
208
+ if child.is_a?(VNode)
209
+ wrapper.appendChild(create_element(child))
210
+ elsif child.is_a?(String)
211
+ wrapper.appendChild(@doc.createTextNode(child))
212
+ end
213
+ end
214
+ return wrapper
215
+ end
216
+ raise "Invalid vnode: #{vnode.class}" unless vnode.is_a?(VNode)
217
+ case vnode.type
218
+ when :text
219
+ raise "Expected Text vnode" unless vnode.is_a?(Text)
220
+ @doc.createTextNode(vnode.content)
221
+ when :element
222
+ raise "Expected Element vnode" unless vnode.is_a?(Element)
223
+ element = @doc.createElement(vnode.tag)
224
+ vnode.props.each do |key, value|
225
+ key_str = key.to_s
226
+ next if key_str.start_with?('on')
227
+ if URL_ATTRIBUTES.include?(key_str) && value.to_s.strip.downcase.start_with?('javascript:')
228
+ puts "[WARN] Funicular: Blocked potentially malicious value for attribute '#{key_str}'."
229
+ next
230
+ end
231
+ if key_str == "value" && (vnode.tag == "input" || vnode.tag == "textarea")
232
+ element[:value] = value.to_s
233
+ elsif BOOLEAN_ATTRIBUTES.include?(key_str)
234
+ if value.nil? || value.to_s == "false"
235
+ else
236
+ element.setAttribute(key_str, key_str)
237
+ end
238
+ else
239
+ element.setAttribute(key_str, value.to_s)
240
+ end
241
+ end
242
+ vnode.children.each do |child|
243
+ if child.is_a?(VNode)
244
+ element.appendChild(create_element(child))
245
+ elsif child.is_a?(String)
246
+ text_node = @doc.createTextNode(child)
247
+ element.appendChild(text_node)
248
+ elsif child.is_a?(Array)
249
+ child.each do |c|
250
+ if c.is_a?(VNode)
251
+ element.appendChild(create_element(c))
252
+ elsif c.is_a?(String)
253
+ text_node = @doc.createTextNode(c)
254
+ element.appendChild(text_node)
255
+ end
256
+ end
257
+ end
258
+ end
259
+ element
260
+ when :component
261
+ raise "Expected Component vnode" unless vnode.is_a?(Component)
262
+ Renderer.new(@doc).render(vnode, nil)
263
+ else
264
+ raise "Unknown vnode type: #{vnode.type}"
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end