funicular 0.1.0 → 0.2.1

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +12 -4
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +25 -18
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6908 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/form_for_test.rb +106 -0
  45. data/minitest/hydration_test.rb +87 -0
  46. data/minitest/plugin_test.rb +51 -0
  47. data/minitest/schema_test.rb +106 -0
  48. data/minitest/ssr_test.rb +94 -0
  49. data/minitest/validations_test.rb +183 -0
  50. data/mrbgem.rake +1 -0
  51. data/mrblib/0_validations.rb +206 -0
  52. data/mrblib/1_validators.rb +180 -0
  53. data/mrblib/cable.rb +24 -9
  54. data/mrblib/component.rb +246 -47
  55. data/mrblib/debug.rb +3 -0
  56. data/mrblib/differ.rb +47 -37
  57. data/mrblib/file_upload.rb +9 -1
  58. data/mrblib/form_builder.rb +28 -6
  59. data/mrblib/funicular.rb +97 -8
  60. data/mrblib/html_serializer.rb +121 -0
  61. data/mrblib/http.rb +123 -29
  62. data/mrblib/model.rb +50 -0
  63. data/mrblib/patcher.rb +78 -8
  64. data/mrblib/router.rb +40 -3
  65. data/mrblib/store.rb +304 -0
  66. data/mrblib/store_collection.rb +171 -0
  67. data/mrblib/store_singleton.rb +79 -0
  68. data/mrblib/vdom.rb +2 -0
  69. data/sig/cable.rbs +1 -0
  70. data/sig/component.rbs +13 -5
  71. data/sig/funicular.rbs +14 -1
  72. data/sig/html_serializer.rbs +20 -0
  73. data/sig/http.rbs +21 -6
  74. data/sig/model.rbs +6 -1
  75. data/sig/patcher.rbs +4 -1
  76. data/sig/router.rbs +3 -2
  77. data/sig/store.rbs +89 -0
  78. data/sig/store_collection.rbs +43 -0
  79. data/sig/store_singleton.rbs +19 -0
  80. data/sig/validations.rbs +103 -0
  81. data/sig/vdom.rbs +6 -6
  82. metadata +48 -12
  83. data/docs/README.md +0 -419
  84. data/docs/advanced-features.md +0 -632
  85. data/docs/components-and-state.md +0 -539
  86. data/docs/data-fetching.md +0 -528
  87. data/docs/forms.md +0 -446
  88. data/docs/rails-integration.md +0 -426
  89. data/docs/realtime.md +0 -543
  90. data/docs/routing-and-navigation.md +0 -427
  91. data/docs/styling.md +0 -285
data/mrblib/funicular.rb CHANGED
@@ -15,12 +15,28 @@ rescue LoadError
15
15
  end
16
16
 
17
17
  module Funicular
18
- VERSION = '0.1.0'
18
+ # Guard against redefinition: when the mrblib runtime is loaded into a
19
+ # CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
20
+ # VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
21
+ VERSION = '0.1.0' unless Funicular.const_defined?(:VERSION)
19
22
 
20
23
  def self.version
21
24
  VERSION
22
25
  end
23
26
 
27
+ # True when the runtime is loaded under CRuby on the server (SSR) rather
28
+ # than running as PicoRuby.wasm in the browser. JS-dependent entry points
29
+ # become no-ops in this mode. Defaults to false (browser).
30
+ @server = false
31
+
32
+ def self.server?
33
+ @server
34
+ end
35
+
36
+ def self.server=(value)
37
+ @server = value ? true : false
38
+ end
39
+
24
40
  def self.env
25
41
  @env ||= EnvironmentInquirer.new(ENV['FUNICULAR_ENV'] || ENV['RAILS_ENV'] || 'development')
26
42
  end
@@ -44,12 +60,57 @@ module Funicular
44
60
  @router
45
61
  end
46
62
 
63
+ # Read the SSR state embedded by the server (funicular_state_tag) as a
64
+ # Ruby Hash with string keys. Returns {} when absent or on the server.
65
+ # Goes through JSON.stringify/parse for a reliable JS->Ruby conversion.
66
+ def self.window_state
67
+ return {} if server?
68
+ win = JS.global[:window]
69
+ # @type var win: JS::Object?
70
+ return {} unless win
71
+ raw = win[:__FUNICULAR_STATE__]
72
+ return {} if raw.nil?
73
+ json = JS.global[:JSON]
74
+ # @type var json: untyped
75
+ json_str = json.stringify(raw)
76
+ JSON.parse(json_str.to_s)
77
+ rescue => e
78
+ puts "[Funicular] Failed to read window state: #{e.message}"
79
+ {}
80
+ end
81
+
82
+ # True when the server embedded hydration state on the page.
83
+ def self.has_ssr_state?
84
+ return false if server?
85
+ win = JS.global[:window]
86
+ # @type var win: JS::Object?
87
+ return false unless win
88
+ !win[:__FUNICULAR_STATE__].nil?
89
+ rescue
90
+ false
91
+ end
92
+
93
+ # The first element child of a container, or nil. Used to find the
94
+ # server-rendered root for hydration.
95
+ def self.first_element_child(container_element)
96
+ child = container_element[:firstElementChild]
97
+ child.is_a?(JS::Element) ? child : nil
98
+ end
99
+
47
100
  # Load schemas for models
48
101
  # Usage:
49
102
  # Funicular.load_schemas({ User => "user", Session => "session" }) do
50
103
  # Funicular.start(container: 'app') { |router| ... }
51
104
  # end
52
105
  def self.load_schemas(models, &block)
106
+ # On the server there is no fetch and no need for client-side schemas:
107
+ # SSR injects plain data into component state directly. Just run the
108
+ # block so route registration (Funicular.start) still happens.
109
+ if server?
110
+ block.call if block
111
+ return
112
+ end
113
+
53
114
  schemas_loaded = 0
54
115
  total_schemas = models.size
55
116
 
@@ -78,7 +139,20 @@ module Funicular
78
139
  # Usage:
79
140
  # Funicular.start(MyComponent, container: 'app')
80
141
  # Funicular.start(MyComponent, container: 'app', props: { name: 'John' })
81
- def self.start(component_class = nil, container: 'app', props: {}, &block)
142
+ def self.start(component_class = nil, container: 'app', props: {}, hydrate: false, &block)
143
+ # On the server we only need route registration so SSR can resolve a
144
+ # path to a component. Skip all DOM/JS work (container lookup, popstate
145
+ # listener, debug export).
146
+ if server?
147
+ if block
148
+ router = Router.new(nil)
149
+ @router = router
150
+ block.call(router)
151
+ return router
152
+ end
153
+ return nil
154
+ end
155
+
82
156
  # Export debug configuration to JavaScript
83
157
  export_debug_config
84
158
 
@@ -91,23 +165,33 @@ module Funicular
91
165
  container
92
166
  end
93
167
 
94
- unless container_element
168
+ unless container_element.is_a?(JS::Element)
95
169
  raise "Container element not found: #{container}"
96
170
  end
97
171
 
172
+ # Hydrate automatically when the server embedded state, unless the caller
173
+ # explicitly opted out.
174
+ hydrate = true if hydrate == false && has_ssr_state?
175
+
98
176
  # If block is given, use router mode
99
177
  if block
100
178
  router = Router.new(container_element)
101
179
  @router = router
102
180
  block.call(router)
103
- router.start
181
+ router.start(hydrate: hydrate)
104
182
  return router
105
183
  end
106
184
 
107
185
  # Otherwise, mount single component (backward compatible)
108
186
  if component_class
109
187
  instance = component_class.new(props)
110
- instance.mount(container_element)
188
+ server_root = hydrate ? first_element_child(container_element) : nil
189
+ if server_root
190
+ instance.seed_state(window_state)
191
+ instance.hydrate(server_root)
192
+ else
193
+ instance.mount(container_element)
194
+ end
111
195
  return instance
112
196
  end
113
197
 
@@ -123,11 +207,15 @@ module Funicular
123
207
  attr_accessor :form_builder_config
124
208
 
125
209
  def configure_forms
210
+ # Defaults are semantic class names whose CSS the gem ships and injects
211
+ # via picoruby_include_tag (see assets/funicular.css).
126
212
  @form_builder_config ||= {
127
- error_class: "text-red-600 text-sm mt-1",
128
- field_error_class: "border-red-500"
213
+ error_class: "funicular-error",
214
+ field_error_class: "funicular-field-error"
129
215
  }
130
- yield @form_builder_config if block_given?
216
+ config = @form_builder_config
217
+ # @type var config: Hash[Symbol, String]
218
+ yield config if block_given?
131
219
  end
132
220
  end
133
221
 
@@ -149,6 +237,7 @@ module Funicular
149
237
 
150
238
  # Export debug_color to JavaScript global variable
151
239
  def self.export_debug_config
240
+ return if server?
152
241
  if JS.global[:window]
153
242
  JS.global[:window][:FUNICULAR_DEBUG_COLOR] = @debug_color # steep:ignore
154
243
  end
@@ -0,0 +1,121 @@
1
+ module Funicular
2
+ module VDOM
3
+ # HTMLSerializer is a string-emitting counterpart of VDOM::Renderer.
4
+ # While Renderer builds a live DOM tree via JS (createElement/appendChild),
5
+ # HTMLSerializer walks the same VNode tree and produces an HTML string.
6
+ #
7
+ # It is pure Ruby with no JS dependency, so it runs under both mruby
8
+ # (in the browser, if ever needed) and CRuby (on the Rails server for SSR).
9
+ #
10
+ # Event handler props (on*) are intentionally skipped: they are Procs that
11
+ # cannot be serialized. They are re-bound on the client during hydration.
12
+ class HTMLSerializer
13
+ # Elements that have no closing tag and no children.
14
+ VOID_ELEMENTS = %w[
15
+ area base br col embed hr img input link meta param source track wbr
16
+ ]
17
+
18
+ # Props that must never be emitted as HTML attributes.
19
+ SKIP_PROPS = %i[ref key children_block]
20
+
21
+ def self.serialize(vnode)
22
+ new.render(vnode)
23
+ end
24
+
25
+ def render(vnode)
26
+ case vnode&.type
27
+ when :element
28
+ # @type var vnode: Funicular::VDOM::Element
29
+ render_element(vnode)
30
+ when :text
31
+ # @type var vnode: Funicular::VDOM::Text
32
+ render_text(vnode)
33
+ when :component
34
+ # @type var vnode: Funicular::VDOM::Component
35
+ render_component(vnode)
36
+ when nil
37
+ ""
38
+ else
39
+ raise "Unknown vnode type: #{vnode&.type}"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def render_element(element)
46
+ tag = element.tag
47
+ attrs = serialize_props(element.props)
48
+
49
+ if VOID_ELEMENTS.include?(tag)
50
+ "<#{tag}#{attrs}>"
51
+ else
52
+ "<#{tag}#{attrs}>#{render_children(element.children)}</#{tag}>"
53
+ end
54
+ end
55
+
56
+ def render_children(children)
57
+ parts = [] #: Array[String]
58
+ children.each do |child|
59
+ if child.is_a?(VNode)
60
+ parts << render(child)
61
+ elsif child.is_a?(String)
62
+ parts << escape_html(child)
63
+ elsif child.is_a?(Array)
64
+ parts << render_children(child)
65
+ elsif !child.nil?
66
+ parts << escape_html(child.to_s)
67
+ end
68
+ end
69
+ parts.join
70
+ end
71
+
72
+ def render_text(text)
73
+ escape_html(text.content)
74
+ end
75
+
76
+ def render_component(component_vnode)
77
+ instance = component_vnode.component_class.new(component_vnode.props)
78
+ component_vnode.instance = instance
79
+ render(instance.build_vdom)
80
+ end
81
+
82
+ def serialize_props(props)
83
+ parts = [] #: Array[String]
84
+ props.each do |key, value|
85
+ key_str = key.to_s
86
+ next if SKIP_PROPS.include?(key)
87
+ # Event handlers are bound on the client, never serialized.
88
+ next if key_str.start_with?("on")
89
+
90
+ if URL_ATTRIBUTES.include?(key_str) &&
91
+ value.to_s.strip.downcase.start_with?("javascript:")
92
+ # Prevent XSS via javascript: URIs (mirrors Renderer#render_element).
93
+ next
94
+ end
95
+
96
+ if BOOLEAN_ATTRIBUTES.include?(key_str)
97
+ # Boolean attributes are absent when false/nil, otherwise present.
98
+ next if value.nil? || value.to_s == "false"
99
+ parts << " #{key_str}=\"#{key_str}\""
100
+ else
101
+ parts << " #{key_str}=\"#{escape_attr(value.to_s)}\""
102
+ end
103
+ end
104
+ parts.join
105
+ end
106
+
107
+ # Minimal HTML escaping that works the same under mruby and CRuby.
108
+ # Avoids any dependency on CGI/ERB::Util.
109
+ def escape_html(str)
110
+ str.to_s
111
+ .gsub("&", "&amp;")
112
+ .gsub("<", "&lt;")
113
+ .gsub(">", "&gt;")
114
+ end
115
+
116
+ def escape_attr(str)
117
+ escape_html(str).gsub('"', "&quot;")
118
+ end
119
+ end
120
+ end
121
+ end
data/mrblib/http.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  module Funicular
2
2
  module HTTP
3
+ CACHE_DB_NAME = 'funicular_http_cache'.freeze
4
+ CACHE_STORE = 'responses'.freeze
5
+
6
+ @cache = nil
7
+
3
8
  class Response
4
9
  attr_reader :data, :status, :ok
5
10
 
@@ -21,23 +26,73 @@ module Funicular
21
26
  end
22
27
  end
23
28
 
24
- def self.get(url, &block)
25
- request("GET", url, nil, &block)
29
+ # Open (or reuse) the response cache store. Idempotent and safe to call
30
+ # multiple times. Falls back to the in-memory backing if browser
31
+ # IndexedDB is unavailable.
32
+ def self.cache_init!
33
+ cache = @cache
34
+ return cache if cache
35
+ @cache = IndexedDB::KVS.open(CACHE_DB_NAME, store: CACHE_STORE)
36
+ end
37
+
38
+ # Drop a single cached entry by URL key. No-op if the cache is not
39
+ # initialized.
40
+ def self.cache_purge(url)
41
+ cache = @cache
42
+ return nil unless cache
43
+ cache.delete(url)
44
+ nil
45
+ end
46
+
47
+ # Drop every cached entry. No-op if the cache is not initialized.
48
+ def self.cache_clear
49
+ cache = @cache
50
+ return nil unless cache
51
+ cache.clear
52
+ nil
53
+ end
54
+
55
+ # Internal: read the cache for *url*. Returns the parsed entry hash or
56
+ # nil. Lazily initializes the cache on first use so callers can pass
57
+ # `cache:` without booting the SPA shell first.
58
+ def self.cache_lookup(url)
59
+ cache_init! unless @cache
60
+ cache = @cache
61
+ return nil unless cache
62
+ cache[url]
26
63
  end
27
64
 
28
- def self.post(url, body = nil, &block)
65
+ # Internal: write *entry* (a Hash with status/data/cached_at) to the
66
+ # cache. Awaits one extra Promise so the next request reliably hits.
67
+ def self.cache_write(url, entry)
68
+ cache_init! unless @cache
69
+ cache = @cache
70
+ return nil unless cache
71
+ cache[url] = entry
72
+ nil
73
+ end
74
+
75
+ def self.get(url, cache: nil, &block)
76
+ request("GET", url, nil, cache: cache, &block)
77
+ end
78
+
79
+ def self.post(url, body = nil, cache: nil, &block)
80
+ warn_unsupported_cache("post") if cache
29
81
  request("POST", url, body, &block)
30
82
  end
31
83
 
32
- def self.patch(url, body = nil, &block)
84
+ def self.patch(url, body = nil, cache: nil, &block)
85
+ warn_unsupported_cache("patch") if cache
33
86
  request("PATCH", url, body, &block)
34
87
  end
35
88
 
36
- def self.delete(url, &block)
89
+ def self.delete(url, cache: nil, &block)
90
+ warn_unsupported_cache("delete") if cache
37
91
  request("DELETE", url, nil, &block)
38
92
  end
39
93
 
40
- def self.put(url, body = nil, &block)
94
+ def self.put(url, body = nil, cache: nil, &block)
95
+ warn_unsupported_cache("put") if cache
41
96
  request("PUT", url, body, &block)
42
97
  end
43
98
 
@@ -46,43 +101,82 @@ module Funicular
46
101
  def self.csrf_token
47
102
  meta = JS.document.querySelector('meta[name="csrf-token"]')
48
103
  if meta
49
- # Use getAttribute method (direct method call on JS::Object)
50
104
  token_obj = meta.getAttribute('content')
51
- # Convert JS::Object to Ruby string
52
105
  token_obj ? token_obj.to_s : nil
53
106
  else
54
107
  nil
55
108
  end
56
109
  end
57
110
 
58
- private
111
+ class << self
112
+ private
59
113
 
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]
114
+ def warn_unsupported_cache(verb)
115
+ puts "[Funicular::HTTP] cache: option is GET-only; ignoring on #{verb.upcase}"
116
+ end
65
117
 
66
- if body
67
- headers["Content-Type"] = "application/json"
68
- options[:body] = JSON.generate(body)
118
+ def now_seconds
119
+ # JavaScript Date.now() returns ms since epoch
120
+ ms = JS.global[:Date].now # steep:ignore
121
+ (ms.to_i / 1000)
69
122
  end
70
123
 
71
- # Add CSRF token for non-GET requests
72
- if method != "GET"
73
- token = csrf_token
74
- headers["X-CSRF-Token"] = token if token
124
+ def cache_hit?(entry, ttl)
125
+ return false unless entry.is_a?(Hash)
126
+ cached_at = entry["cached_at"]
127
+ return false unless cached_at.is_a?(Integer)
128
+ (now_seconds - cached_at) <= ttl
75
129
  end
76
130
 
77
- options[:headers] = headers unless headers.empty?
131
+ def serve_from_cache(entry, &block)
132
+ status = entry["status"].to_i
133
+ data = entry["data"]
134
+ block.call(Response.new(status, data)) if block
135
+ end
78
136
 
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
137
+ def request(method, url, body, cache: nil, &block)
138
+ if method == "GET" && cache.is_a?(Integer) && cache > 0
139
+ entry = cache_lookup(url)
140
+ if cache_hit?(entry, cache)
141
+ serve_from_cache(entry, &block)
142
+ return
143
+ end
144
+ end
145
+
146
+ # @type var options: Hash[Symbol, String | Hash[String, String]]
147
+ options = { method: method, credentials: "include" }
148
+
149
+ headers = {} #: Hash[String, String]
150
+
151
+ if body
152
+ headers["Content-Type"] = "application/json"
153
+ options[:body] = JSON.generate(body)
154
+ end
155
+
156
+ if method != "GET"
157
+ token = csrf_token
158
+ headers["X-CSRF-Token"] = token if token
159
+ end
160
+
161
+ options[:headers] = headers unless headers.empty?
162
+
163
+ JS.global.fetch(url, options) do |response|
164
+ status = response.status.to_i
165
+ json_text = response.to_binary
166
+ data = JSON.parse(json_text)
167
+ # @type var status: Integer
168
+ http_response = Response.new(status, data)
169
+
170
+ if method == "GET" && cache.is_a?(Integer) && cache > 0 && http_response.ok
171
+ cache_write(url, {
172
+ "status" => status,
173
+ "data" => data,
174
+ "cached_at" => now_seconds
175
+ })
176
+ end
177
+
178
+ block.call(http_response) if block
179
+ end
86
180
  end
87
181
  end
88
182
  end
data/mrblib/model.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  module Funicular
2
2
  class Model
3
+ include Validations
4
+
3
5
  attr_reader :id
4
6
 
5
7
  class << self
@@ -22,6 +24,41 @@ module Funicular
22
24
  @changed_attributes[name] = value
23
25
  end
24
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
25
62
  end
26
63
  end
27
64
 
@@ -70,6 +107,13 @@ module Funicular
70
107
  endpoint = @endpoints["create"]
71
108
  return unless endpoint
72
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
+
73
117
  HTTP.post(endpoint["path"], attrs) do |response|
74
118
  if response.error?
75
119
  block.call(nil, response.error_message) if block
@@ -101,6 +145,12 @@ module Funicular
101
145
  attrs.each { |k, v| send("#{k}=", v) }
102
146
  end
103
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
+
104
154
  return if @changed_attributes.empty?
105
155
 
106
156
  json_attrs = @changed_attributes.reject do |name, value|