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
@@ -0,0 +1,184 @@
1
+ require 'rng'
2
+
3
+ module Funicular
4
+ module FileUpload
5
+ JS_HELPER_CODE = <<~JAVASCRIPT
6
+ (function() {
7
+ 'use strict';
8
+ // Avoid duplicate mounting
9
+ if (window.funicularFormDataUpload) {
10
+ return;
11
+ }
12
+ // FormData upload helper
13
+ window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId) {
14
+ const formData = new FormData();
15
+ for (const [key, value] of Object.entries(fieldsObj)) {
16
+ formData.append(key, String(value));
17
+ }
18
+ if (fileFieldName && fileRefId !== null && fileRefId !== undefined) {
19
+ const file = window.picorubyRefs[fileRefId];
20
+ if (file) {
21
+ formData.append(fileFieldName, file);
22
+ }
23
+ }
24
+ return fetch(url, {
25
+ method: 'PATCH',
26
+ body: formData
27
+ }).then(response => response.json());
28
+ };
29
+ console.log('Funicular helpers mounted');
30
+ })();
31
+ JAVASCRIPT
32
+
33
+ def self.mount
34
+ script = JS.document.createElement("script")
35
+ script[:textContent] = JS_HELPER_CODE
36
+ JS.document.body.appendChild(script)
37
+ JS.document.body.removeChild(script)
38
+ end
39
+
40
+ # Select a file from input element and generate preview
41
+ # @param input_id [String] DOM element ID of the file input
42
+ # @param block [Proc] Callback with preview data URL (or nil if no file)
43
+ def self.select_file_with_preview(input_id, &block)
44
+ input = JS.document.getElementById(input_id)
45
+ return block.call(nil, nil) unless input
46
+
47
+ # @type var files: Array[JS::Object]
48
+ files = input[:files]
49
+ if !files || files.length.to_i == 0
50
+ return block.call(nil, nil)
51
+ end
52
+
53
+ file = files[0]
54
+
55
+ # Use URL.createObjectURL instead of FileReader for preview
56
+ # This is simpler and doesn't require async callbacks
57
+ if file.nil?
58
+ raise "Selected file is nil"
59
+ else
60
+ url = JS.global[:URL]
61
+ if url.is_a?(JS::Object)
62
+ preview_url = url.createObjectURL(file)
63
+ else
64
+ preview_url = nil
65
+ end
66
+ end
67
+
68
+ # Call the block with file and preview URL
69
+ # @type var preview_url: String
70
+ block.call(file, preview_url) if block
71
+ end
72
+
73
+ # Upload data with FormData (supports file upload)
74
+ # @param url [String] Upload URL
75
+ # @param fields [Hash] Form fields to include
76
+ # @param file_field [String] Name of the file field
77
+ # @param file [JS::Object] File object from input element
78
+ # @param block [Proc] Callback with response data
79
+ def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, &block)
80
+ # Store file and callback ID
81
+ @callback_counters ||= [] # steep:ignore UnannotatedEmptyCollection
82
+ callback_id = _ = nil
83
+ while true
84
+ callback_id = RNG.random_int
85
+ break unless @callback_counters.include?(callback_id)
86
+ end
87
+ @callback_counters << callback_id
88
+
89
+ if file
90
+ JS.global[:_tempFileForUpload] = file
91
+ end
92
+
93
+ # Build fields object as JSON string
94
+ fields_json = JSON.generate(fields)
95
+
96
+ # Call JavaScript helper
97
+ # Note: The helper returns a Promise that resolves to JSON data (not Response)
98
+ script = JS.document.createElement("script")
99
+ script_code = <<~JS
100
+ (function() {
101
+ var fieldsObj = JSON.parse('#{fields_json.gsub("'", "\\\\'")}');
102
+ var fileRefId = null;
103
+ if (window._tempFileForUpload) {
104
+ fileRefId = window.picorubyRefs.indexOf(window._tempFileForUpload);
105
+ }
106
+ // Call helper and store result as JSON string when Promise resolves
107
+ window.funicularFormDataUpload(
108
+ '#{url}',
109
+ fieldsObj,
110
+ #{file_field ? "'#{file_field}'" : 'null'},
111
+ fileRefId
112
+ ).then(function(data) {
113
+ // Store as JSON string for easy Ruby parsing
114
+ window._funicularUploadResult_#{callback_id} = JSON.stringify(data);
115
+ }).catch(function(error) {
116
+ window._funicularUploadResult_#{callback_id} = JSON.stringify({error: error.toString()});
117
+ });
118
+ })();
119
+ JS
120
+ script[:textContent] = script_code
121
+ JS.document.body.appendChild(script)
122
+ JS.document.body.removeChild(script)
123
+
124
+ # Poll for result (simple polling approach)
125
+ counter = 0
126
+ json_str = nil
127
+ while true
128
+ counter += 1
129
+ sleep 0.1
130
+ json_str = JS.global[:"_funicularUploadResult_#{callback_id}"]
131
+ break if json_str && !json_str.to_s.empty?
132
+ if 100 < counter
133
+ puts "[WARN] Timeout waiting for upload result. Return without calling block."
134
+ return
135
+ end
136
+ end
137
+
138
+ # Get JSON string from global variable (already retrieved above)
139
+ result = JSON.parse(json_str.to_s)
140
+
141
+ block.call(result) if block
142
+ puts "[DEBUG] Block called"
143
+ ensure
144
+ # Always cleanup global references, even on timeout or error
145
+ if callback_id
146
+ @callback_counters.delete(callback_id) if @callback_counters
147
+ if JS.global[:"_funicularUploadResult_#{callback_id}"]
148
+ JS.global[:"_funicularUploadResult_#{callback_id}"] = nil
149
+ end
150
+ end
151
+ if JS.global[:_tempFileForUpload]
152
+ JS.global[:_tempFileForUpload] = nil
153
+ end
154
+ end
155
+
156
+ # Helper: Store file in global variable for later use
157
+ def self.store_file(input_id, storage_key = '_selectedFile')
158
+ input = JS.document.getElementById(input_id)
159
+ return nil unless input
160
+
161
+ # @type var files: Array[JS::Object]
162
+ files = input[:files]
163
+ if !files || files.length.to_i == 0
164
+ JS.global[storage_key] = nil
165
+ return nil
166
+ end
167
+
168
+ file = files[0]
169
+ JS.global[storage_key] = file
170
+ file
171
+ end
172
+
173
+ # Helper: Retrieve stored file
174
+ def self.retrieve_file(storage_key = '_selectedFile')
175
+ file = JS.global[storage_key]
176
+ JS.global[storage_key] = nil if file
177
+ if file.is_a?(JS::Object)
178
+ file
179
+ else
180
+ nil
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,284 @@
1
+ module Funicular
2
+ class FormBuilder
3
+ attr_reader :component, :model_key, :options
4
+
5
+ def initialize(component, model_key, options = {})
6
+ @component = component
7
+ @model_key = model_key
8
+ @options = options
9
+ @error_class = options[:error_class] || "text-red-600 text-sm mt-1"
10
+ @field_error_class = options[:field_error_class] || "border-red-500"
11
+ end
12
+
13
+ # Generic field builder for input elements
14
+ def build_field(field_name, field_type, field_options = {})
15
+ field_key = field_name.to_s
16
+ full_key = "#{@model_key}.#{field_key}"
17
+
18
+ # Get current value from state
19
+ value = get_nested_value(@component.state, full_key) || ""
20
+
21
+ # Build input change handler
22
+ on_input = ->(event) do
23
+ new_value = event.target[:value]
24
+ set_nested_value(@model_key, field_key, new_value)
25
+ end
26
+
27
+ # Check for errors
28
+ error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
29
+ has_error = !error_message.nil?
30
+
31
+ # Merge CSS classes (add error class if error exists)
32
+ css_class = field_options[:class]
33
+ css_class = css_class.to_s if css_class
34
+ css_class ||= ""
35
+
36
+ if has_error
37
+ if css_class.empty?
38
+ css_class = @field_error_class
39
+ else
40
+ css_class = "#{css_class} #{@field_error_class}"
41
+ end
42
+ end
43
+
44
+ # Build field attributes
45
+ attrs = {
46
+ type: field_type,
47
+ value: value,
48
+ oninput: on_input
49
+ }.merge(field_options.reject { |k, _| k == :class })
50
+
51
+ # Add class attribute if not empty
52
+ attrs[:class] = css_class unless css_class.empty?
53
+
54
+ # Render field + error message
55
+ @component.div do
56
+ @component.input(attrs)
57
+ if has_error
58
+ @component.div(class: @error_class) { error_message }
59
+ end
60
+ end
61
+ end
62
+
63
+ # Public field methods
64
+ def text_field(field_name, options = {})
65
+ build_field(field_name, "text", options)
66
+ end
67
+
68
+ def password_field(field_name, options = {})
69
+ build_field(field_name, "password", options)
70
+ end
71
+
72
+ def email_field(field_name, options = {})
73
+ build_field(field_name, "email", options)
74
+ end
75
+
76
+ def number_field(field_name, options = {})
77
+ build_field(field_name, "number", options)
78
+ end
79
+
80
+ def textarea(field_name, options = {})
81
+ field_key = field_name.to_s
82
+ full_key = "#{@model_key}.#{field_key}"
83
+
84
+ value = get_nested_value(@component.state, full_key) || ""
85
+
86
+ on_input = ->(event) do
87
+ new_value = event.target[:value]
88
+ set_nested_value(@model_key, field_key, new_value)
89
+ end
90
+
91
+ error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
92
+ has_error = !error_message.nil?
93
+
94
+ css_class = options[:class]
95
+ css_class = css_class.to_s if css_class
96
+ css_class ||= ""
97
+
98
+ if has_error
99
+ if css_class.empty?
100
+ css_class = @field_error_class
101
+ else
102
+ css_class = "#{css_class} #{@field_error_class}"
103
+ end
104
+ end
105
+
106
+ attrs = {
107
+ oninput: on_input
108
+ }.merge(options.reject { |k, _| k == :class })
109
+
110
+ attrs[:class] = css_class unless css_class.empty?
111
+
112
+ @component.div do
113
+ @component.textarea(attrs) { value }
114
+ if has_error
115
+ @component.div(class: @error_class) { error_message }
116
+ end
117
+ end
118
+ end
119
+
120
+ def checkbox(field_name, options = {})
121
+ field_key = field_name.to_s
122
+ full_key = "#{@model_key}.#{field_key}"
123
+
124
+ value = get_nested_value(@component.state, full_key) || false
125
+
126
+ on_change = ->(event) do
127
+ new_value = event.target[:checked]
128
+ set_nested_value(@model_key, field_key, new_value)
129
+ end
130
+
131
+ attrs = {
132
+ type: "checkbox",
133
+ checked: value,
134
+ onchange: on_change
135
+ }.merge(options)
136
+
137
+ @component.input(attrs)
138
+ end
139
+
140
+ def select(field_name, choices, options = {})
141
+ field_key = field_name.to_s
142
+ full_key = "#{@model_key}.#{field_key}"
143
+
144
+ value = get_nested_value(@component.state, full_key) || ""
145
+
146
+ on_change = ->(event) do
147
+ new_value = event.target[:value]
148
+ set_nested_value(@model_key, field_key, new_value)
149
+ end
150
+
151
+ error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
152
+ has_error = !error_message.nil?
153
+
154
+ css_class = options[:class]
155
+ css_class = css_class.to_s if css_class
156
+ css_class ||= ""
157
+
158
+ if has_error
159
+ if css_class.empty?
160
+ css_class = @field_error_class
161
+ else
162
+ css_class = "#{css_class} #{@field_error_class}"
163
+ end
164
+ end
165
+
166
+ attrs = {
167
+ onchange: on_change
168
+ }.merge(options.reject { |k, _| k == :class })
169
+
170
+ attrs[:class] = css_class unless css_class.empty?
171
+
172
+ @component.div do
173
+ @component.select(attrs) do
174
+ choices.each do |choice|
175
+ option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
176
+ selected = value.to_s == option_value.to_s
177
+ @component.option(value: option_value, selected: selected) do
178
+ option_text
179
+ end
180
+ end
181
+ end
182
+ if has_error
183
+ @component.div(class: @error_class) { error_message }
184
+ end
185
+ end
186
+ end
187
+
188
+ def file_field(field_name, options = {})
189
+ field_key = field_name.to_s
190
+
191
+ # Check if custom onchange handler is provided
192
+ custom_handler = options.delete(:onchange)
193
+
194
+ # Default handler: store file name in state
195
+ default_handler = ->(event) do
196
+ file = event.target[:files] ? event.target[:files][0] : nil
197
+ file_name = if file.nil?
198
+ nil
199
+ else
200
+ # @type var file: Hash[Symbol, String]
201
+ file[:name]
202
+ end
203
+ set_nested_value(@model_key, field_key, file_name)
204
+ end
205
+
206
+ on_change = custom_handler || default_handler
207
+
208
+ attrs = {
209
+ type: "file",
210
+ onchange: on_change
211
+ }.merge(options)
212
+
213
+ @component.input(attrs)
214
+ end
215
+
216
+ def submit(label = "Submit", options = {})
217
+ attrs = { type: "submit" }.merge(options)
218
+ @component.button(attrs) { label }
219
+ end
220
+
221
+ def label(field_name, text = nil, options = {})
222
+ text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
223
+ @component.label(options) { text }
224
+ end
225
+
226
+ private
227
+
228
+ # Get nested value from state (e.g., "user.username")
229
+ def get_nested_value(state, key_path)
230
+ keys = key_path.split('.')
231
+ value = state
232
+ keys.each do |key|
233
+ if value.respond_to?(key.to_sym)
234
+ value = value.send(key.to_sym)
235
+ elsif value.is_a?(Hash)
236
+ value = value[key.to_sym] || value[key]
237
+ else
238
+ value = nil
239
+ end
240
+ break if value.nil?
241
+ end
242
+ value
243
+ end
244
+
245
+ # Set nested value in state via patch
246
+ def set_nested_value(model_key, field_key, new_value)
247
+ # Handle nested keys like "address.city"
248
+ if field_key.include?('.')
249
+ # Complex nested update
250
+ keys = field_key.split('.')
251
+ current_model = @component.state.send(model_key.to_sym)
252
+ updated_model = deep_merge_value(current_model, keys, new_value)
253
+ @component.patch(model_key.to_sym => updated_model)
254
+ else
255
+ # Simple update
256
+ current_model = @component.state.send(model_key.to_sym)
257
+ if current_model.nil?
258
+ @component.patch(model_key.to_sym => { field_key.to_sym => new_value })
259
+ elsif current_model.is_a?(Hash)
260
+ updated_model = current_model.merge(field_key.to_sym => new_value)
261
+ @component.patch(model_key.to_sym => updated_model)
262
+ else
263
+ # Assume it's an object with instance variables
264
+ current_model.instance_variable_set("@#{field_key}", new_value)
265
+ @component.patch(model_key.to_sym => current_model)
266
+ end
267
+ end
268
+ end
269
+
270
+ # Deep merge helper for nested keys
271
+ def deep_merge_value(hash, keys, value)
272
+ return hash unless hash.is_a?(Hash)
273
+
274
+ hash = hash.dup
275
+ if keys.length == 1
276
+ hash[keys[0].to_sym] = value
277
+ else
278
+ key = keys[0].to_sym
279
+ hash[key] = deep_merge_value(hash[key] || {}, (keys[1..-1] || []), value)
280
+ end
281
+ hash
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,156 @@
1
+ # The 'js' gem (picoruby-wasm) provides JavaScript interop and is only
2
+ # available in wasm builds. During test builds, picoruby-wasm is excluded
3
+ # from dependencies (see mrbgem.rake), so `require 'js'` raises LoadError.
4
+ #
5
+ # Additionally, gem init order is not guaranteed to be stable. A dummy
6
+ # `require` in picoruby-mruby/mrblib/require.rb exists to suppress
7
+ # LoadError during picogem_init, but if picoruby-require initializes
8
+ # before this gem, the real `require` (which raises LoadError) will
9
+ # already be active. Rescuing LoadError here makes the code robust
10
+ # regardless of init order.
11
+ begin
12
+ require 'js'
13
+ rescue LoadError
14
+ # not available outside wasm environment
15
+ end
16
+
17
+ module Funicular
18
+ VERSION = '0.1.0'
19
+
20
+ def self.version
21
+ VERSION
22
+ end
23
+
24
+ def self.env
25
+ @env ||= EnvironmentInquirer.new(ENV['FUNICULAR_ENV'] || ENV['RAILS_ENV'] || 'development')
26
+ end
27
+
28
+ def self.env=(environment)
29
+ case environment
30
+ when EnvironmentInquirer
31
+ @env = environment
32
+ when nil
33
+ @env = nil
34
+ else
35
+ @env = EnvironmentInquirer.new(environment)
36
+ end
37
+ # @type ivar @env: EnvironmentInquirer?
38
+ @env
39
+ end
40
+
41
+ @router = nil
42
+
43
+ def self.router
44
+ @router
45
+ end
46
+
47
+ # Load schemas for models
48
+ # Usage:
49
+ # Funicular.load_schemas({ User => "user", Session => "session" }) do
50
+ # Funicular.start(container: 'app') { |router| ... }
51
+ # end
52
+ def self.load_schemas(models, &block)
53
+ schemas_loaded = 0
54
+ total_schemas = models.size
55
+
56
+ check_completion = -> {
57
+ if schemas_loaded >= total_schemas
58
+ puts "[Funicular] All schemas loaded (#{schemas_loaded}/#{total_schemas})"
59
+ block.call if block
60
+ end
61
+ }
62
+
63
+ models.each do |model_class, schema_name|
64
+ HTTP.get("/api/schema/#{schema_name}") do |response|
65
+ if response.error?
66
+ puts "[Schema] Failed to load #{schema_name} schema: #{response.error_message}"
67
+ else
68
+ model_class.load_schema(response.data)
69
+ puts "[Schema] #{schema_name} model initialized"
70
+ schemas_loaded += 1
71
+ check_completion.call
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ # Start Funicular application
78
+ # Usage:
79
+ # Funicular.start(MyComponent, container: 'app')
80
+ # Funicular.start(MyComponent, container: 'app', props: { name: 'John' })
81
+ def self.start(component_class = nil, container: 'app', props: {}, &block)
82
+ # Export debug configuration to JavaScript
83
+ export_debug_config
84
+
85
+ # Initialize debug module in development mode
86
+ Funicular::Debug.expose_to_global if Funicular.env.development?
87
+
88
+ container_element = if container.is_a?(String)
89
+ JS.document.getElementById(container)
90
+ else
91
+ container
92
+ end
93
+
94
+ unless container_element
95
+ raise "Container element not found: #{container}"
96
+ end
97
+
98
+ # If block is given, use router mode
99
+ if block
100
+ router = Router.new(container_element)
101
+ @router = router
102
+ block.call(router)
103
+ router.start
104
+ return router
105
+ end
106
+
107
+ # Otherwise, mount single component (backward compatible)
108
+ if component_class
109
+ instance = component_class.new(props)
110
+ instance.mount(container_element)
111
+ return instance
112
+ end
113
+
114
+ raise "Either component_class or block must be provided"
115
+ rescue => e
116
+ puts "Exception in Funicular.start: #{e.message}"
117
+ puts e.backtrace
118
+ raise e
119
+ end
120
+
121
+ # Form builder configuration
122
+ class << self
123
+ attr_accessor :form_builder_config
124
+
125
+ def configure_forms
126
+ @form_builder_config ||= {
127
+ error_class: "text-red-600 text-sm mt-1",
128
+ field_error_class: "border-red-500"
129
+ }
130
+ yield @form_builder_config if block_given?
131
+ end
132
+ end
133
+
134
+ # Initialize default form configuration
135
+ configure_forms
136
+
137
+ # Debug highlighter configuration
138
+ class << self
139
+ attr_accessor :debug_color
140
+
141
+ def configure_debug
142
+ @debug_color = 'green'
143
+ yield self if block_given?
144
+ end
145
+ end
146
+
147
+ # Initialize default debug configuration
148
+ configure_debug
149
+
150
+ # Export debug_color to JavaScript global variable
151
+ def self.export_debug_config
152
+ if JS.global[:window]
153
+ JS.global[:window][:FUNICULAR_DEBUG_COLOR] = @debug_color # steep:ignore
154
+ end
155
+ end
156
+ end
data/mrblib/http.rb ADDED
@@ -0,0 +1,89 @@
1
+ module Funicular
2
+ module HTTP
3
+ class Response
4
+ attr_reader :data, :status, :ok
5
+
6
+ def initialize(status, data)
7
+ @status = status
8
+ @ok = @status >= 200 && @status < 300
9
+ @data = data
10
+ end
11
+
12
+ def error?
13
+ return true unless @ok
14
+ return false unless @data.is_a?(Hash)
15
+ @data["error"] || @data["errors"]
16
+ end
17
+
18
+ def error_message
19
+ return nil unless @data.is_a?(Hash)
20
+ @data["error"] || (@data["errors"].is_a?(Array) ? @data["errors"].join(", ") : @data["errors"])
21
+ end
22
+ end
23
+
24
+ def self.get(url, &block)
25
+ request("GET", url, nil, &block)
26
+ end
27
+
28
+ def self.post(url, body = nil, &block)
29
+ request("POST", url, body, &block)
30
+ end
31
+
32
+ def self.patch(url, body = nil, &block)
33
+ request("PATCH", url, body, &block)
34
+ end
35
+
36
+ def self.delete(url, &block)
37
+ request("DELETE", url, nil, &block)
38
+ end
39
+
40
+ def self.put(url, body = nil, &block)
41
+ request("PUT", url, body, &block)
42
+ end
43
+
44
+ # Get CSRF token from meta tag
45
+ # Note: Don't cache the token - Rails may rotate it after each request
46
+ def self.csrf_token
47
+ meta = JS.document.querySelector('meta[name="csrf-token"]')
48
+ if meta
49
+ # Use getAttribute method (direct method call on JS::Object)
50
+ token_obj = meta.getAttribute('content')
51
+ # Convert JS::Object to Ruby string
52
+ token_obj ? token_obj.to_s : nil
53
+ else
54
+ nil
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def self.request(method, url, body, &block)
61
+ # @type var options: Hash[Symbol, String | Hash[String, String]]
62
+ options = { method: method, credentials: "include" }
63
+
64
+ headers = {} #: Hash[String, String]
65
+
66
+ if body
67
+ headers["Content-Type"] = "application/json"
68
+ options[:body] = JSON.generate(body)
69
+ end
70
+
71
+ # Add CSRF token for non-GET requests
72
+ if method != "GET"
73
+ token = csrf_token
74
+ headers["X-CSRF-Token"] = token if token
75
+ end
76
+
77
+ options[:headers] = headers unless headers.empty?
78
+
79
+ JS.global.fetch(url, options) do |response|
80
+ status = response.status.to_i
81
+ json_text = response.to_binary
82
+ data = JSON.parse(json_text)
83
+ # @type var status: Integer
84
+ http_response = Response.new(status, data)
85
+ block.call(http_response) if block
86
+ end
87
+ end
88
+ end
89
+ end