dommy 0.6.0 → 0.8.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +10 -2
  4. data/lib/dommy/attr.rb +197 -32
  5. data/lib/dommy/backend/nokogiri_adapter.rb +127 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +117 -0
  7. data/lib/dommy/backend.rb +175 -0
  8. data/lib/dommy/blob.rb +30 -11
  9. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  10. data/lib/dommy/bridge/methods.rb +57 -0
  11. data/lib/dommy/bridge.rb +97 -0
  12. data/lib/dommy/callable_invoker.rb +36 -0
  13. data/lib/dommy/compression_streams.rb +4 -4
  14. data/lib/dommy/cookie_store.rb +4 -2
  15. data/lib/dommy/crypto.rb +16 -9
  16. data/lib/dommy/css.rb +53 -7
  17. data/lib/dommy/custom_elements.rb +33 -9
  18. data/lib/dommy/data_transfer.rb +4 -0
  19. data/lib/dommy/document.rb +693 -60
  20. data/lib/dommy/dom_parser.rb +29 -15
  21. data/lib/dommy/element.rb +1147 -438
  22. data/lib/dommy/event.rb +279 -79
  23. data/lib/dommy/event_source.rb +14 -10
  24. data/lib/dommy/fetch.rb +509 -39
  25. data/lib/dommy/file_reader.rb +14 -6
  26. data/lib/dommy/form_data.rb +3 -3
  27. data/lib/dommy/history.rb +46 -8
  28. data/lib/dommy/html_collection.rb +59 -6
  29. data/lib/dommy/html_elements.rb +153 -1502
  30. data/lib/dommy/internal/css_pseudo_handlers.rb +137 -0
  31. data/lib/dommy/internal/dom_matching.rb +3 -3
  32. data/lib/dommy/internal/global_functions.rb +26 -0
  33. data/lib/dommy/internal/idna.rb +16 -7
  34. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  35. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  36. data/lib/dommy/internal/namespaces.rb +70 -0
  37. data/lib/dommy/internal/node_equality.rb +86 -0
  38. data/lib/dommy/internal/node_traversal.rb +1 -1
  39. data/lib/dommy/internal/node_wrapper_cache.rb +77 -31
  40. data/lib/dommy/internal/observable_callback.rb +1 -5
  41. data/lib/dommy/internal/parent_node.rb +126 -0
  42. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  43. data/lib/dommy/internal/selector_parser.rb +664 -0
  44. data/lib/dommy/internal/template_content_registry.rb +6 -6
  45. data/lib/dommy/internal/url_parser.rb +677 -0
  46. data/lib/dommy/intersection_observer.rb +4 -2
  47. data/lib/dommy/location.rb +10 -4
  48. data/lib/dommy/media_query_list.rb +10 -4
  49. data/lib/dommy/message_channel.rb +41 -11
  50. data/lib/dommy/mutation_observer.rb +76 -23
  51. data/lib/dommy/navigator.rb +38 -24
  52. data/lib/dommy/node.rb +158 -16
  53. data/lib/dommy/notification.rb +6 -4
  54. data/lib/dommy/parser.rb +13 -13
  55. data/lib/dommy/performance.rb +4 -0
  56. data/lib/dommy/performance_observer.rb +4 -2
  57. data/lib/dommy/promise.rb +14 -14
  58. data/lib/dommy/range.rb +74 -5
  59. data/lib/dommy/resize_observer.rb +4 -2
  60. data/lib/dommy/scheduler.rb +34 -13
  61. data/lib/dommy/shadow_root.rb +31 -60
  62. data/lib/dommy/storage.rb +2 -0
  63. data/lib/dommy/streams.rb +40 -49
  64. data/lib/dommy/svg_elements.rb +204 -3606
  65. data/lib/dommy/text_codec.rb +178 -25
  66. data/lib/dommy/tree_walker.rb +270 -81
  67. data/lib/dommy/url.rb +305 -450
  68. data/lib/dommy/url_pattern.rb +2 -0
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/web_socket.rb +49 -19
  71. data/lib/dommy/window.rb +205 -203
  72. data/lib/dommy/worker.rb +12 -12
  73. data/lib/dommy/xml_http_request.rb +32 -7
  74. data/lib/dommy.rb +19 -2
  75. metadata +22 -27
data/lib/dommy/url.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "uri"
4
4
  require "cgi"
5
+ require_relative "internal/url_parser"
5
6
 
6
7
  module Dommy
7
8
  # `URL` — WHATWG-style URL parsing. Public API mirrors the JS class:
@@ -18,10 +19,10 @@ module Dommy
18
19
  # Dommy::URL.new("/a", "https://x.test").href
19
20
  # # => "https://x.test/a"
20
21
  #
21
- # Internally backed by Ruby's URI library good enough for the
22
- # common test cases. Edge cases that URI rejects raise
23
- # `DOMException::SyntaxError` (called `TypeError` in JS but Dommy
24
- # uses the closest WHATWG name).
22
+ # Internally backed by a WHATWG basic URL parser
23
+ # (`Internal::UrlParser`). A parse failure in the constructor or the
24
+ # `href` setter raises `Bridge::TypeError` matching the URL
25
+ # Standard, which throws a JS `TypeError` (not a DOMException) there.
25
26
  class URL
26
27
  # Registry of Blob URLs created via `URL.createObjectURL(blob)`.
27
28
  # Process-wide because the spec scopes them to the document/window
@@ -30,7 +31,7 @@ module Dommy
30
31
 
31
32
  class << self
32
33
  # Create a unique blob: URL that resolves back to `blob` via
33
- # `URL.__resolve_blob_url__(url)`. Returns nil for non-Blob input.
34
+ # `URL.__test_resolve_blob_url__(url)`. Returns nil for non-Blob input.
34
35
  def create_object_url(blob)
35
36
  return nil unless blob.is_a?(Blob)
36
37
 
@@ -53,513 +54,317 @@ module Dommy
53
54
 
54
55
  # Resolve a blob: URL back to its Blob, or nil if revoked / unknown.
55
56
  # Internal — used by fetch / XHR implementations that load blob URLs.
56
- def __resolve_blob_url__(url)
57
+ def __test_resolve_blob_url__(url)
57
58
  @blob_urls[url.to_s]
58
59
  end
59
60
 
60
61
  # Test seam: drop all registered blob URLs.
61
- def __reset_blob_urls__
62
+ def __test_reset_blob_urls__
62
63
  @blob_urls.clear
63
64
  end
64
- end
65
-
66
- attr_reader :search_params
67
-
68
- def initialize(input, base = nil)
69
- raw = parse_with_base(input, base)
70
- @uri = raw
71
- @search_params = URLSearchParams.new(raw.query.to_s, owner: self)
72
- end
73
-
74
- def href
75
- build_href
76
- end
77
-
78
- def href=(value)
79
- raw = parse_with_base(value.to_s, nil)
80
- @uri = raw
81
- @search_params.__replace__(raw.query.to_s)
82
- build_href
83
- end
84
-
85
- def protocol
86
- @uri.scheme ? "#{@uri.scheme}:" : ""
87
- end
88
-
89
- def protocol=(value)
90
- s = value.to_s.sub(/:$/, "")
91
- @uri.scheme = s
92
- end
93
-
94
- def host
95
- port = @uri.port
96
- default = default_port_for(@uri.scheme.to_s.downcase)
97
- hostpart = @uri.host.to_s
98
- return hostpart if port.nil? || port == default
99
-
100
- "#{hostpart}:#{port}"
101
- end
102
-
103
- def host=(value)
104
- h, p = value.to_s.split(":", 2)
105
- @uri.host = h
106
- @uri.port = p.to_i if p
107
- end
108
-
109
- def hostname
110
- @uri.host.to_s
111
- end
112
-
113
- def hostname=(value)
114
- # WHATWG: a non-ASCII hostname assigned through the setter is
115
- # Punycode-encoded before storage (matches `new URL("...")`).
116
- @uri.host = Internal::IDNA.to_ascii(value.to_s)
117
- end
118
-
119
- def port
120
- default = default_port_for(@uri.scheme.to_s.downcase)
121
- return "" if @uri.port.nil? || @uri.port == default
122
-
123
- @uri.port.to_s
124
- end
125
65
 
126
- def port=(value)
127
- @uri.port = value.to_s.empty? ? nil : value.to_i
128
- end
129
-
130
- # WHATWG: for opaque-body schemes (javascript:, mailto:, data:,
131
- # tel:, blob:) the body sits in `URI`'s `opaque` slot, not `path`.
132
- # For special schemes (http/https/ws/wss/ftp), an empty path is
133
- # canonicalized to `"/"`.
134
- def pathname
135
- opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
136
- return opaque.to_s if opaque
137
-
138
- path = @uri.path.to_s
139
- return "/" if path.empty? && special_scheme?
140
-
141
- path
142
- end
143
-
144
- def pathname=(value)
145
- v = value.to_s
146
- v = "/#{v}" if !v.start_with?("/") && !v.empty?
147
- @uri.path = v
148
- end
66
+ # WHATWG URL Standard — `URL.parse(input, base)` is the
67
+ # non-throwing static factory. Returns a URL on success, `nil`
68
+ # on parse failure. The constructor (`new URL(...)`) raises
69
+ # `TypeError` for the same failure case.
70
+ def parse(input, base = nil)
71
+ new(input, base)
72
+ rescue Bridge::TypeError
73
+ nil
74
+ end
149
75
 
150
- # WHATWG: `url.search` is the raw query string (with `?` prefix),
151
- # preserving percent-encoding and stray `?` characters as parsed.
152
- # `url.searchParams.toString()` re-serializes via the form-encoded
153
- # contract (`+` for space, etc.) — distinct from `url.search`.
154
- def search
155
- q = @uri.query
156
- q.nil? || q.empty? ? "" : "?#{q}"
76
+ # WHATWG URL Standard `URL.canParse(input, base)`. Boolean
77
+ # counterpart to `parse`: lets callers peek at validity
78
+ # without rescuing an exception or holding a URL reference.
79
+ def can_parse(input, base = nil)
80
+ !parse(input, base).nil?
81
+ end
157
82
  end
158
83
 
159
- def search=(value)
160
- q = value.to_s.sub(/^\?/, "")
161
- @uri.query = q.empty? ? nil : q
162
- @search_params.__replace__(q)
163
- end
84
+ attr_reader :search_params
164
85
 
165
- def hash
166
- f = @uri.fragment.to_s
167
- f.empty? ? "" : "##{f}"
168
- end
86
+ def initialize(input, base = nil)
87
+ # An explicit JS `undefined` base means "no base" (WebIDL optional arg),
88
+ # distinct from a string base. (JS null already arrives as nil.)
89
+ base = nil if base.equal?(Bridge::UNDEFINED)
90
+ base_str = base.is_a?(URL) ? base.href : base
91
+ @record = Internal::UrlParser.parse(input.to_s, base_str)
92
+ @search_params = URLSearchParams.new(@record.query.to_s, owner: self)
93
+ rescue Internal::UrlParser::Failure => e
94
+ # WHATWG: the URL constructor throws TypeError on a parse failure.
95
+ raise Bridge::TypeError, "Invalid URL: #{e.message}"
96
+ end
169
97
 
170
- def hash=(value)
171
- f = value.to_s.sub(/^#/, "")
172
- @uri.fragment = f.empty? ? nil : f
173
- end
98
+ def href
99
+ Internal::UrlParser.serialize(@record)
100
+ end
174
101
 
175
- # WHATWG URL §origin. Tuple origins for http(s) / ws(s) / ftp;
176
- # `"null"` for file/data/javascript/etc. Blob URLs unwrap their
177
- # inner URL recursively.
178
- def origin
179
- scheme = @uri.scheme.to_s.downcase
180
- return blob_inner_origin if scheme == "blob"
181
- return "null" unless TUPLE_ORIGIN_SCHEMES.include?(scheme)
182
- return "null" unless @uri.host
102
+ def href=(value)
103
+ @record = Internal::UrlParser.parse(value.to_s, nil)
104
+ @search_params.__internal_replace__(@record.query.to_s)
105
+ href
106
+ rescue Internal::UrlParser::Failure => e
107
+ # WHATWG: the href setter throws TypeError on a parse failure.
108
+ raise Bridge::TypeError, "Invalid URL: #{e.message}"
109
+ end
183
110
 
184
- default = default_port_for(scheme)
185
- port_part = (@uri.port && @uri.port != default) ? ":#{@uri.port}" : ""
186
- "#{scheme}://#{@uri.host}#{port_part}"
187
- end
111
+ def protocol
112
+ "#{@record.scheme}:"
113
+ end
188
114
 
189
- def blob_inner_origin
190
- # `blob:<inner-url>` the body after `blob:` is itself a URL
191
- # whose origin we adopt. Anything that fails to parse falls
192
- # back to "null".
193
- opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
194
- return "null" if opaque.nil? || opaque.empty?
115
+ def protocol=(value)
116
+ s = value.to_s.sub(/:\z/, "").downcase
117
+ @record.scheme = s if s.match?(/\A[a-z][a-z0-9+\-.]*\z/)
118
+ end
195
119
 
196
- URL.new(opaque).origin
197
- rescue DOMException::SyntaxError
198
- "null"
199
- end
120
+ def host
121
+ return "" if @record.host.nil?
200
122
 
201
- def username
202
- @uri.user.to_s
203
- end
123
+ @record.port ? "#{@record.host}:#{@record.port}" : @record.host
124
+ end
204
125
 
205
- def username=(value)
206
- @uri.user = value.to_s.empty? ? nil : value.to_s
126
+ def host=(value)
127
+ h, sep, p = value.to_s.partition(":")
128
+ begin
129
+ @record.host = Internal::UrlParser.parse_host(h, @record.special?)
130
+ rescue Internal::UrlParser::Failure
131
+ return
207
132
  end
133
+ self.port = p unless sep.empty?
134
+ end
208
135
 
209
- def password
210
- @uri.password.to_s
211
- end
136
+ def hostname
137
+ @record.host.to_s
138
+ end
212
139
 
213
- def password=(value)
214
- @uri.password = value.to_s.empty? ? nil : value.to_s
215
- end
140
+ def hostname=(value)
141
+ @record.host = Internal::UrlParser.parse_host(value.to_s, @record.special?)
142
+ rescue Internal::UrlParser::Failure
143
+ nil
144
+ end
216
145
 
217
- def to_s
218
- href
219
- end
146
+ def port
147
+ @record.port.nil? ? "" : @record.port.to_s
148
+ end
220
149
 
221
- def to_json(*_args)
222
- # match JSON.stringify(url) -> "\"<href>\""
223
- href.inspect
150
+ def port=(value)
151
+ v = value.to_s
152
+ if v.empty?
153
+ @record.port = nil
154
+ elsif v.match?(/\A[0-9]+\z/)
155
+ n = v.to_i
156
+ @record.port = (n == @record.default_port ? nil : n) if n <= 65_535
224
157
  end
158
+ end
225
159
 
226
- def __js_get__(key)
227
- case key
228
- when "href"
229
- href
230
- when "protocol"
231
- protocol
232
- when "host"
233
- host
234
- when "hostname"
235
- hostname
236
- when "port"
237
- port
238
- when "pathname"
239
- pathname
240
- when "search"
241
- search
242
- when "hash"
243
- hash
244
- when "origin"
245
- origin
246
- when "username"
247
- username
248
- when "password"
249
- password
250
- when "searchParams"
251
- @search_params
252
- end
253
- end
160
+ def pathname
161
+ Internal::UrlParser.serialize_path(@record)
162
+ end
254
163
 
255
- def __js_set__(key, value)
256
- case key
257
- when "href"
258
- self.href = value
259
- when "protocol"
260
- self.protocol = value
261
- when "host"
262
- self.host = value
263
- when "hostname"
264
- self.hostname = value
265
- when "port"
266
- self.port = value
267
- when "pathname"
268
- self.pathname = value
269
- when "search"
270
- self.search = value
271
- when "hash"
272
- self.hash = value
273
- when "username"
274
- self.username = value
275
- when "password"
276
- self.password = value
277
- end
164
+ def pathname=(value)
165
+ return if @record.opaque_path?
278
166
 
279
- nil
280
- end
167
+ v = value.to_s
168
+ v = v.tr("\\", "/") if @record.special?
169
+ segs = v.split("/", -1)
170
+ segs.shift if segs.first == ""
171
+ set = Internal::UrlParser.method(:path_set?)
172
+ @record.path = segs.map { |s| s.each_char.map { |ch| Internal::UrlParser.pe(ch, set) }.join }
173
+ @record.path = [""] if @record.path.empty? && @record.special?
174
+ end
281
175
 
282
- def __js_call__(method, _args)
283
- case method
284
- when "toString", "toJSON"
285
- href
286
- end
287
- end
176
+ def search
177
+ q = @record.query
178
+ q.nil? || q.empty? ? "" : "?#{q}"
179
+ end
288
180
 
289
- # Called by URLSearchParams when it mutates; we need to keep the
290
- # underlying URI's query string in sync so subsequent `href` is
291
- # accurate.
292
- def __notify_params_changed__
293
- sync_uri_query
181
+ def search=(value)
182
+ v = value.to_s.sub(/\A\?/, "")
183
+ if v.empty?
184
+ @record.query = nil
185
+ else
186
+ set = @record.special? ? Internal::UrlParser.method(:special_query_set?) : Internal::UrlParser.method(:query_set?)
187
+ @record.query = v.each_char.map { |ch| Internal::UrlParser.pe(ch, set) }.join
294
188
  end
189
+ @search_params.__internal_replace__(@record.query.to_s)
190
+ end
295
191
 
296
- SPECIAL_SCHEMES = %w[http https ws wss ftp file].freeze
297
-
298
- # WHATWG: only http(s) / ws(s) / ftp produce a tuple origin. file
299
- # / data / javascript / etc. resolve to `"null"`. `blob:` is
300
- # handled specially (inner-URL origin).
301
- TUPLE_ORIGIN_SCHEMES = %w[http https ws wss ftp].freeze
302
-
303
- # Default ports per scheme (Ruby URI knows http/https/ftp; we add
304
- # ws/wss).
305
- DEFAULT_PORTS = {
306
- "http" => 80,
307
- "https" => 443,
308
- "ws" => 80,
309
- "wss" => 443,
310
- "ftp" => 21
311
- }.freeze
312
-
313
- # Chars that Ruby URI rejects in the path/query/fragment portion
314
- # but WHATWG silently percent-encodes.
315
- UNSAFE_PATH_CHARS = /[ "<>`{}|\\\^\[\]]/
316
-
317
- private
318
-
319
- def special_scheme?
320
- SPECIAL_SCHEMES.include?(@uri.scheme.to_s.downcase)
321
- end
192
+ def hash
193
+ f = @record.fragment
194
+ f.nil? || f.empty? ? "" : "##{f}"
195
+ end
322
196
 
323
- def default_port_for(scheme)
324
- DEFAULT_PORTS[scheme]
197
+ def hash=(value)
198
+ v = value.to_s.sub(/\A#/, "")
199
+ if v.empty?
200
+ @record.fragment = nil
201
+ else
202
+ set = Internal::UrlParser.method(:fragment_set?)
203
+ @record.fragment = v.each_char.map { |ch| Internal::UrlParser.pe(ch, set) }.join
325
204
  end
205
+ end
326
206
 
327
- def parse_with_base(input, base)
328
- str = preprocess(input.to_s)
329
- uri = nil
330
- if base
331
- base_str = preprocess(base.is_a?(URL) ? base.href : base.to_s)
332
- base_uri = URI.parse(base_str)
333
- uri = URI.join(base_uri, str)
334
- else
335
- uri = URI.parse(str)
336
- raise DOMException::SyntaxError, "Invalid URL: #{str}" unless uri.scheme
337
- end
338
-
339
- normalize_path_segments(uri) if special_scheme_for?(uri)
340
- uri
341
- rescue URI::InvalidURIError => e
342
- raise DOMException::SyntaxError, "Invalid URL: #{e.message}"
343
- rescue Internal::Punycode::Error, Internal::IDNA::Error => e
344
- raise DOMException::SyntaxError, "Invalid URL host: #{e.message}"
345
- end
346
-
347
- # WHATWG URL preprocessing — turn a raw input string into a form
348
- # Ruby URI accepts. Order matters: each step can depend on
349
- # earlier normalizations.
350
- def preprocess(str)
351
- str = strip_c0_and_space(str)
352
- str = strip_tab_and_newline(str)
353
- str = replace_backslashes_for_special_scheme(str)
354
- str = normalize_idn_host(str)
355
- str = normalize_ipv4_host(str)
356
- percent_encode_unsafe(str)
357
- end
358
-
359
- # WHATWG §basic-url-parser step 1: strip leading and trailing
360
- # C0 controls and ASCII space.
361
- def strip_c0_and_space(str)
362
- str.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "")
363
- end
364
-
365
- # WHATWG: remove ASCII tab and newline anywhere in the URL.
366
- def strip_tab_and_newline(str)
367
- str.delete("\t\n\r")
368
- end
369
-
370
- # WHATWG: for special-scheme URLs, treat `\` as `/` in the
371
- # authority and path portions.
372
- def replace_backslashes_for_special_scheme(str)
373
- m = str.match(/\A([a-zA-Z][a-zA-Z0-9+.\-]*):/)
374
- return str unless m
375
- return str unless SPECIAL_SCHEMES.include?(m[1].downcase)
376
-
377
- scheme_end = m.end(0)
378
- str[0...scheme_end] + str[scheme_end..].tr("\\", "/")
379
- end
380
-
381
- # Percent-encode chars after the authority section that Ruby URI
382
- # would reject (space, `<`, `>`, `{`, `}`, `|`, etc.) and any
383
- # non-ASCII byte. Preserves already-encoded `%XX` sequences.
384
- def percent_encode_unsafe(str)
385
- m = str.match(%r{\A([a-zA-Z][a-zA-Z0-9+.\-]*:(?://[^/?#]*)?)(.*)\z}m)
386
- return str unless m
387
-
388
- prefix = m[1]
389
- tail = m[2]
390
- out = +""
391
- i = 0
392
- while i < tail.length
393
- c = tail[i]
394
- if c == "%" && tail[i + 1, 2].to_s.match?(/\A[0-9A-Fa-f]{2}\z/)
395
- out << tail[i, 3]
396
- i += 3
397
- next
398
- end
399
-
400
- needs_encoding = c.bytesize > 1 ||
401
- c.ord < 0x20 ||
402
- c.ord == 0x7F ||
403
- UNSAFE_PATH_CHARS.match?(c)
404
-
405
- if needs_encoding
406
- c.bytes.each { |b| out << format("%%%02X", b) }
407
- else
408
- out << c
409
- end
207
+ # WHATWG URL §origin. Tuple origins for http(s) / ws(s) / ftp; `"null"`
208
+ # for file/data/javascript/etc. Blob URLs unwrap their inner URL.
209
+ def origin
210
+ scheme = @record.scheme
211
+ return blob_inner_origin if scheme == "blob"
212
+ return "null" unless TUPLE_ORIGIN_SCHEMES.include?(scheme)
213
+ return "null" if @record.host.nil?
410
214
 
411
- i += 1
412
- end
215
+ port_part = @record.port ? ":#{@record.port}" : ""
216
+ "#{scheme}://#{@record.host}#{port_part}"
217
+ end
413
218
 
414
- prefix + out
415
- end
219
+ def username
220
+ @record.username
221
+ end
416
222
 
417
- # Detect dotted-quad / hex / octal / short-form IPv4 hosts and
418
- # canonicalize to dotted-decimal. Touches the authority section
419
- # only; non-special schemes are skipped.
420
- def normalize_ipv4_host(str)
421
- m = str.match(%r{\A([a-zA-Z][a-zA-Z0-9+.\-]*://(?:[^@/?#]*@)?)([^/:?#]+)(.*)\z}m)
422
- return str unless m
223
+ def username=(value)
224
+ return if cannot_have_credentials?
423
225
 
424
- scheme = str.match(/\A([a-zA-Z][a-zA-Z0-9+.\-]*):/)[1].downcase
425
- return str unless SPECIAL_SCHEMES.include?(scheme)
226
+ set = Internal::UrlParser.method(:userinfo_set?)
227
+ @record.username = value.to_s.each_char.map { |ch| Internal::UrlParser.pe(ch, set) }.join
228
+ end
426
229
 
427
- ip = Internal::Ipv4Parser.parse(m[2])
428
- return str unless ip
230
+ def password
231
+ @record.password
232
+ end
429
233
 
430
- "#{m[1]}#{ip}#{m[3]}"
431
- end
234
+ def password=(value)
235
+ return if cannot_have_credentials?
432
236
 
433
- def special_scheme_for?(uri)
434
- SPECIAL_SCHEMES.include?(uri.scheme.to_s.downcase)
435
- end
237
+ set = Internal::UrlParser.method(:userinfo_set?)
238
+ @record.password = value.to_s.each_char.map { |ch| Internal::UrlParser.pe(ch, set) }.join
239
+ end
436
240
 
437
- # WHATWG: resolve `.` / `..` path segments. Applied only to
438
- # special-scheme URIs (opaque schemes' path is verbatim).
439
- def normalize_path_segments(uri)
440
- path = uri.path
441
- return if path.nil? || path.empty?
241
+ def to_s
242
+ href
243
+ end
442
244
 
443
- segments = path.split("/", -1)
444
- result = []
445
- segments.each do |seg|
446
- case seg
447
- when ".."
448
- # Pop unless we'd remove the leading-empty marker.
449
- result.pop if result.length > 1
450
- when "."
451
- # Skip.
452
- else
453
- result << seg
454
- end
455
- end
245
+ def to_json(*_args)
246
+ href.inspect
247
+ end
456
248
 
457
- # Preserve the trailing slash if the input had one.
458
- result << "" if path.end_with?("/", ".") && result.last != ""
459
- uri.path = result.join("/")
249
+ def __js_get__(key)
250
+ case key
251
+ when "href" then href
252
+ when "protocol" then protocol
253
+ when "host" then host
254
+ when "hostname" then hostname
255
+ when "port" then port
256
+ when "pathname" then pathname
257
+ when "search" then search
258
+ when "hash" then hash
259
+ when "origin" then origin
260
+ when "username" then username
261
+ when "password" then password
262
+ when "searchParams" then @search_params
460
263
  end
264
+ end
461
265
 
462
- # WHATWG: non-ASCII host labels must be Punycode-encoded
463
- # (`日本.test` → `xn--wgv71a.test`) before storage. Ruby's URI
464
- # parser rejects non-ASCII hosts outright, so we rewrite the host
465
- # portion of the authority section here. Userinfo / port / path /
466
- # query / fragment are left untouched.
467
- def normalize_idn_host(str)
468
- return str unless str.is_a?(String)
469
- return str unless str.match?(%r{://})
266
+ def __js_set__(key, value)
267
+ case key
268
+ when "href" then self.href = value
269
+ when "protocol" then self.protocol = value
270
+ when "host" then self.host = value
271
+ when "hostname" then self.hostname = value
272
+ when "port" then self.port = value
273
+ when "pathname" then self.pathname = value
274
+ when "search" then self.search = value
275
+ when "hash" then self.hash = value
276
+ when "username" then self.username = value
277
+ when "password" then self.password = value
278
+ else
279
+ return Bridge::UNHANDLED
280
+ end
281
+
282
+ nil
283
+ end
470
284
 
471
- str.sub(%r{(://)([^/?#]*)}) do
472
- sep = Regexp.last_match(1)
473
- authority = Regexp.last_match(2)
474
- sep + rewrite_authority(authority)
475
- end
285
+ include Bridge::Methods
286
+ js_methods %w[toString toJSON]
287
+ def __js_call__(method, _args)
288
+ case method
289
+ when "toString", "toJSON"
290
+ href
476
291
  end
292
+ end
477
293
 
478
- def rewrite_authority(authority)
479
- userinfo, hostport = authority.include?("@") ? authority.split("@", 2) : [nil, authority]
480
- host, port = hostport.rpartition(":").then { |h, sep, p|
481
- sep.empty? || h.empty? ? [hostport, nil] : [h, p]
482
- }
294
+ # Called by URLSearchParams when it mutates; keep the record's query in sync.
295
+ def __internal_notify_params_changed__
296
+ q = @search_params.to_s
297
+ @record.query = q.empty? ? nil : q
298
+ end
483
299
 
484
- ascii_host = Internal::IDNA.to_ascii(host)
485
- out = +""
486
- out << "#{userinfo}@" if userinfo
487
- out << ascii_host
488
- out << ":#{port}" if port
489
- out
490
- end
300
+ # WHATWG: only http(s) / ws(s) / ftp produce a tuple origin. file / data /
301
+ # javascript / etc. resolve to `"null"`. `blob:` is handled specially.
302
+ TUPLE_ORIGIN_SCHEMES = %w[http https ws wss ftp].freeze
491
303
 
492
- def build_href
493
- out = +""
494
- out << "#{@uri.scheme}:" if @uri.scheme
304
+ private
495
305
 
496
- opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
497
- if opaque
498
- # Opaque-body scheme (javascript:, mailto:, data:, tel:, blob:)
499
- # — emit the body verbatim, no authority section.
500
- out << opaque
501
- else
502
- if @uri.host
503
- out << "//"
504
- if @uri.user
505
- out << @uri.user
506
- out << ":#{@uri.password}" if @uri.password
507
- out << "@"
508
- end
306
+ def cannot_have_credentials?
307
+ @record.host.nil? || @record.host == "" || @record.scheme == "file"
308
+ end
509
309
 
510
- out << @uri.host
511
- default = default_port_for(@uri.scheme.to_s.downcase)
512
- out << ":#{@uri.port}" if @uri.port && @uri.port != default
513
- end
310
+ # HTML Standard "origin" for a blob: URL — parse the opaque path as a URL and
311
+ # return its origin only when that inner URL's scheme is http/https/file;
312
+ # any other scheme (ftp, ws, a nested blob, …) yields an opaque origin.
313
+ def blob_inner_origin
314
+ return "null" unless @record.opaque_path?
514
315
 
515
- path = @uri.path.to_s
516
- # WHATWG: for special schemes the path is normalized to `/`
517
- # when empty (matches `pathname` accessor).
518
- path = "/" if path.empty? && special_scheme?
519
- out << path
520
- end
316
+ body = @record.path
317
+ return "null" if body.nil? || body.empty?
521
318
 
522
- out << search
523
- out << hash
524
- out
525
- end
319
+ inner = URL.new(body)
320
+ return "null" unless %w[http https file].include?(inner.protocol.delete_suffix(":"))
526
321
 
527
- def sync_uri_query
528
- q = @search_params.to_s
529
- @uri.query = q.empty? ? nil : q
530
- end
322
+ inner.origin
323
+ rescue Bridge::TypeError
324
+ "null"
325
+ end
531
326
  end
532
327
 
533
- # `URLSearchParams` — query-string manipulation. Constructed from a
534
- # raw string (`"a=1&b=2"`), an array of `[k, v]` pairs, or a Hash.
535
- # Order is preserved. Values are stringified per spec.
536
328
  class URLSearchParams
537
329
  include Enumerable
538
330
 
331
+ # Sentinel distinguishing "no second argument" from an explicit null/value
332
+ # in the two-argument has()/delete() forms.
333
+ UNSET = Object.new
334
+ private_constant :UNSET
335
+
539
336
  def initialize(input = "", owner: nil)
540
337
  @owner = owner
541
338
  @pairs = parse(input)
542
339
  end
543
340
 
544
341
  def get(name)
545
- pair = @pairs.find { |k, _| k == name.to_s }
342
+ key = stringify(name)
343
+ pair = @pairs.find { |k, _| k == key }
546
344
  pair && pair[1]
547
345
  end
548
346
 
549
347
  def get_all(name)
550
- @pairs.select { |k, _| k == name.to_s }.map { |_, v| v }
348
+ key = stringify(name)
349
+ @pairs.select { |k, _| k == key }.map { |_, v| v }
551
350
  end
552
351
 
553
352
  alias getAll get_all
554
353
 
555
- def has(name)
556
- @pairs.any? { |k, _| k == name.to_s }
354
+ # WHATWG has(name) / has(name, value): with a value, only matches a pair
355
+ # whose value also equals it.
356
+ def has(name, value = UNSET)
357
+ key = stringify(name)
358
+ return @pairs.any? { |k, _| k == key } if UNSET.equal?(value)
359
+
360
+ val = stringify(value)
361
+ @pairs.any? { |k, v| k == key && v == val }
557
362
  end
558
363
 
559
364
  alias has? has
560
365
 
561
366
  def set(name, value)
562
- key = name.to_s
367
+ key = stringify(name)
563
368
  first_done = false
564
369
  @pairs = @pairs.reject do |k, _|
565
370
  next false unless k == key
@@ -572,33 +377,44 @@ module Dommy
572
377
  end
573
378
  end
574
379
 
575
- @pairs.map! { |pair| pair[0] == key ? [key, value.to_s] : pair }
576
- @pairs << [key, value.to_s] unless first_done
380
+ val = stringify(value)
381
+ @pairs.map! { |pair| pair[0] == key ? [key, val] : pair }
382
+ @pairs << [key, val] unless first_done
577
383
  notify
578
384
  nil
579
385
  end
580
386
 
581
387
  def append(name, value)
582
- @pairs << [name.to_s, value.to_s]
388
+ @pairs << [stringify(name), stringify(value)]
583
389
  notify
584
390
  nil
585
391
  end
586
392
 
587
- def delete(name, value = nil)
588
- key = name.to_s
589
- if value.nil?
393
+ # WHATWG delete(name) / delete(name, value): with a value, only removes
394
+ # pairs whose value also matches.
395
+ def delete(name, value = UNSET)
396
+ key = stringify(name)
397
+ if UNSET.equal?(value)
590
398
  @pairs.reject! { |k, _| k == key }
591
399
  else
592
- v = value.to_s
593
- @pairs.reject! { |k, vv| k == key && vv == v }
400
+ val = stringify(value)
401
+ @pairs.reject! { |k, vv| k == key && vv == val }
594
402
  end
595
403
 
596
404
  notify
597
405
  nil
598
406
  end
599
407
 
408
+ # WHATWG: sort by comparison of the names' UTF-16 *code units*
409
+ # (not code points — so a surrogate-pair character sorts by its
410
+ # leading 0xD800–0xDBFF unit), preserving the relative order of
411
+ # pairs with equal names (Ruby's sort_by is not stable, hence the
412
+ # index tiebreak).
600
413
  def sort
601
- @pairs.sort_by! { |k, _| k }
414
+ @pairs = @pairs
415
+ .each_with_index
416
+ .sort_by { |(name, _value), idx| [name.encode(Encoding::UTF_16BE).unpack("n*"), idx] }
417
+ .map(&:first)
602
418
  notify
603
419
  nil
604
420
  end
@@ -636,7 +452,7 @@ module Dommy
636
452
  @pairs.map { |k, v| "#{encode(k)}=#{encode(v)}" }.join("&")
637
453
  end
638
454
 
639
- def __replace__(query_string)
455
+ def __internal_replace__(query_string)
640
456
  @pairs = parse(query_string)
641
457
  nil
642
458
  end
@@ -648,6 +464,8 @@ module Dommy
648
464
  end
649
465
  end
650
466
 
467
+ include Bridge::Methods
468
+ js_methods %w[get getAll has set append delete sort toString forEach keys values entries]
651
469
  def __js_call__(method, args)
652
470
  case method
653
471
  when "get"
@@ -655,19 +473,30 @@ module Dommy
655
473
  when "getAll"
656
474
  get_all(args[0])
657
475
  when "has"
658
- has(args[0])
476
+ value_given?(args) ? has(args[0], args[1]) : has(args[0])
659
477
  when "set"
660
478
  set(args[0], args[1])
661
479
  when "append"
662
480
  append(args[0], args[1])
663
481
  when "delete"
664
- delete(args[0], args[1])
482
+ value_given?(args) ? delete(args[0], args[1]) : delete(args[0])
665
483
  when "sort"
666
484
  sort
667
485
  when "toString"
668
486
  to_s
669
487
  when "forEach"
670
- for_each(&args[0])
488
+ # The callback is a live JS function (HostCallback), not a Ruby Proc, so
489
+ # invoke it through the bridge ABI rather than `&block` (which would try
490
+ # to to_proc it). callback(value, key, this) per WHATWG.
491
+ cb = args[0]
492
+ @pairs.each do |k, v|
493
+ if cb.respond_to?(:__js_call__)
494
+ cb.__js_call__("call", [v, k, self])
495
+ elsif cb.respond_to?(:call)
496
+ cb.call(v, k, self)
497
+ end
498
+ end
499
+ nil
671
500
  when "keys"
672
501
  keys
673
502
  when "values"
@@ -679,6 +508,12 @@ module Dommy
679
508
 
680
509
  private
681
510
 
511
+ # True when a real second argument (value) was passed to has()/delete().
512
+ # An explicit JS `undefined` counts as "not provided" (one-arg form).
513
+ def value_given?(args)
514
+ args.length >= 2 && !args[1].equal?(Bridge::UNDEFINED)
515
+ end
516
+
682
517
  def parse(input)
683
518
  case input
684
519
  when Array
@@ -686,22 +521,42 @@ module Dommy
686
521
  when Hash
687
522
  input.map { |k, v| [k.to_s, v.to_s] }
688
523
  else
689
- s = input.to_s.sub(/^\?/, "")
524
+ # The public `new URLSearchParams(str)` strips a single leading "?".
525
+ # But when we're owner-backed (initialized from a URL's already-extracted
526
+ # query), a leading "?" is literal query data — e.g. `??a=b` stores query
527
+ # "?a=b", whose first name is "?a" — so it must be kept.
528
+ s = input.to_s
529
+ s = s.sub(/^\?/, "") if @owner.nil?
690
530
  return [] if s.empty?
691
531
 
692
- s.split("&").map do |pair|
532
+ # WHATWG urlencoded parser: split on "&" and skip empty sequences (so
533
+ # "a=b&&c" / trailing "&" don't yield phantom empty-name pairs).
534
+ s.split("&").reject(&:empty?).map do |pair|
693
535
  k, v = pair.split("=", 2)
694
- [CGI.unescape(k.to_s), CGI.unescape(v.to_s)]
536
+ [decode(k.to_s), decode(v.to_s)]
695
537
  end
696
538
  end
697
539
  end
698
540
 
541
+ def decode(str)
542
+ CGI.unescape(str)
543
+ end
544
+
545
+ # WHATWG application/x-www-form-urlencoded serializer: byte-encode, keeping
546
+ # alphanumerics and *-._ literal, space as "+", everything else as %XX.
547
+ # (CGI.escape differs — notably it percent-encodes "*".)
699
548
  def encode(str)
700
- CGI.escape(str.to_s)
549
+ str.to_s.b.gsub(/[^*\-._A-Za-z0-9]/n) { |c| c == " " ? "+" : format("%%%02X", c.ord) }
550
+ end
551
+
552
+ # USVString coercion for name/value arguments. JS null arrives as Ruby nil
553
+ # and must stringify to "null" (ToString(null)), not "".
554
+ def stringify(value)
555
+ value.nil? ? "null" : value.to_s
701
556
  end
702
557
 
703
558
  def notify
704
- @owner&.__notify_params_changed__
559
+ @owner&.__internal_notify_params_changed__
705
560
  end
706
561
  end
707
562
  end