dommy 0.7.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
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
@@ -65,10 +66,10 @@ module Dommy
65
66
  # WHATWG URL Standard — `URL.parse(input, base)` is the
66
67
  # non-throwing static factory. Returns a URL on success, `nil`
67
68
  # on parse failure. The constructor (`new URL(...)`) raises
68
- # `SyntaxError` for the same failure case.
69
+ # `TypeError` for the same failure case.
69
70
  def parse(input, base = nil)
70
71
  new(input, base)
71
- rescue DOMException::SyntaxError
72
+ rescue Bridge::TypeError
72
73
  nil
73
74
  end
74
75
 
@@ -80,503 +81,290 @@ module Dommy
80
81
  end
81
82
  end
82
83
 
83
- attr_reader :search_params
84
+ attr_reader :search_params
84
85
 
85
- def initialize(input, base = nil)
86
- raw = parse_with_base(input, base)
87
- @uri = raw
88
- @search_params = URLSearchParams.new(raw.query.to_s, owner: self)
89
- end
90
-
91
- def href
92
- build_href
93
- end
94
-
95
- def href=(value)
96
- raw = parse_with_base(value.to_s, nil)
97
- @uri = raw
98
- @search_params.__internal_replace__(raw.query.to_s)
99
- build_href
100
- end
101
-
102
- def protocol
103
- @uri.scheme ? "#{@uri.scheme}:" : ""
104
- end
105
-
106
- def protocol=(value)
107
- s = value.to_s.sub(/:$/, "")
108
- @uri.scheme = s
109
- end
110
-
111
- def host
112
- port = @uri.port
113
- default = default_port_for(@uri.scheme.to_s.downcase)
114
- hostpart = @uri.host.to_s
115
- return hostpart if port.nil? || port == default
116
-
117
- "#{hostpart}:#{port}"
118
- end
119
-
120
- def host=(value)
121
- h, p = value.to_s.split(":", 2)
122
- @uri.host = h
123
- @uri.port = p.to_i if p
124
- end
125
-
126
- def hostname
127
- @uri.host.to_s
128
- end
129
-
130
- def hostname=(value)
131
- # WHATWG: a non-ASCII hostname assigned through the setter is
132
- # Punycode-encoded before storage (matches `new URL("...")`).
133
- @uri.host = Internal::IDNA.to_ascii(value.to_s)
134
- end
135
-
136
- def port
137
- default = default_port_for(@uri.scheme.to_s.downcase)
138
- return "" if @uri.port.nil? || @uri.port == default
139
-
140
- @uri.port.to_s
141
- end
142
-
143
- def port=(value)
144
- @uri.port = value.to_s.empty? ? nil : value.to_i
145
- end
146
-
147
- # WHATWG: for opaque-body schemes (javascript:, mailto:, data:,
148
- # tel:, blob:) the body sits in `URI`'s `opaque` slot, not `path`.
149
- # For special schemes (http/https/ws/wss/ftp), an empty path is
150
- # canonicalized to `"/"`.
151
- def pathname
152
- opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
153
- return opaque.to_s if opaque
154
-
155
- path = @uri.path.to_s
156
- return "/" if path.empty? && special_scheme?
157
-
158
- path
159
- end
160
-
161
- def pathname=(value)
162
- v = value.to_s
163
- v = "/#{v}" if !v.start_with?("/") && !v.empty?
164
- @uri.path = v
165
- end
166
-
167
- # WHATWG: `url.search` is the raw query string (with `?` prefix),
168
- # preserving percent-encoding and stray `?` characters as parsed.
169
- # `url.searchParams.toString()` re-serializes via the form-encoded
170
- # contract (`+` for space, etc.) — distinct from `url.search`.
171
- def search
172
- q = @uri.query
173
- q.nil? || q.empty? ? "" : "?#{q}"
174
- end
175
-
176
- def search=(value)
177
- q = value.to_s.sub(/^\?/, "")
178
- @uri.query = q.empty? ? nil : q
179
- @search_params.__internal_replace__(q)
180
- end
181
-
182
- def hash
183
- f = @uri.fragment.to_s
184
- f.empty? ? "" : "##{f}"
185
- 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
186
97
 
187
- def hash=(value)
188
- f = value.to_s.sub(/^#/, "")
189
- @uri.fragment = f.empty? ? nil : f
190
- end
98
+ def href
99
+ Internal::UrlParser.serialize(@record)
100
+ end
191
101
 
192
- # WHATWG URL §origin. Tuple origins for http(s) / ws(s) / ftp;
193
- # `"null"` for file/data/javascript/etc. Blob URLs unwrap their
194
- # inner URL recursively.
195
- def origin
196
- scheme = @uri.scheme.to_s.downcase
197
- return blob_inner_origin if scheme == "blob"
198
- return "null" unless TUPLE_ORIGIN_SCHEMES.include?(scheme)
199
- 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
200
110
 
201
- default = default_port_for(scheme)
202
- port_part = (@uri.port && @uri.port != default) ? ":#{@uri.port}" : ""
203
- "#{scheme}://#{@uri.host}#{port_part}"
204
- end
111
+ def protocol
112
+ "#{@record.scheme}:"
113
+ end
205
114
 
206
- def blob_inner_origin
207
- # `blob:<inner-url>` the body after `blob:` is itself a URL
208
- # whose origin we adopt. Anything that fails to parse falls
209
- # back to "null".
210
- opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
211
- 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
212
119
 
213
- URL.new(opaque).origin
214
- rescue DOMException::SyntaxError
215
- "null"
216
- end
120
+ def host
121
+ return "" if @record.host.nil?
217
122
 
218
- def username
219
- @uri.user.to_s
220
- end
123
+ @record.port ? "#{@record.host}:#{@record.port}" : @record.host
124
+ end
221
125
 
222
- def username=(value)
223
- @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
224
132
  end
133
+ self.port = p unless sep.empty?
134
+ end
225
135
 
226
- def password
227
- @uri.password.to_s
228
- end
136
+ def hostname
137
+ @record.host.to_s
138
+ end
229
139
 
230
- def password=(value)
231
- @uri.password = value.to_s.empty? ? nil : value.to_s
232
- 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
233
145
 
234
- def to_s
235
- href
236
- end
146
+ def port
147
+ @record.port.nil? ? "" : @record.port.to_s
148
+ end
237
149
 
238
- def to_json(*_args)
239
- # match JSON.stringify(url) -> "\"<href>\""
240
- 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
241
157
  end
158
+ end
242
159
 
243
- def __js_get__(key)
244
- case key
245
- when "href"
246
- href
247
- when "protocol"
248
- protocol
249
- when "host"
250
- host
251
- when "hostname"
252
- hostname
253
- when "port"
254
- port
255
- when "pathname"
256
- pathname
257
- when "search"
258
- search
259
- when "hash"
260
- hash
261
- when "origin"
262
- origin
263
- when "username"
264
- username
265
- when "password"
266
- password
267
- when "searchParams"
268
- @search_params
269
- end
270
- end
160
+ def pathname
161
+ Internal::UrlParser.serialize_path(@record)
162
+ end
271
163
 
272
- def __js_set__(key, value)
273
- case key
274
- when "href"
275
- self.href = value
276
- when "protocol"
277
- self.protocol = value
278
- when "host"
279
- self.host = value
280
- when "hostname"
281
- self.hostname = value
282
- when "port"
283
- self.port = value
284
- when "pathname"
285
- self.pathname = value
286
- when "search"
287
- self.search = value
288
- when "hash"
289
- self.hash = value
290
- when "username"
291
- self.username = value
292
- when "password"
293
- self.password = value
294
- end
164
+ def pathname=(value)
165
+ return if @record.opaque_path?
295
166
 
296
- nil
297
- 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
298
175
 
299
- def __js_call__(method, _args)
300
- case method
301
- when "toString", "toJSON"
302
- href
303
- end
304
- end
176
+ def search
177
+ q = @record.query
178
+ q.nil? || q.empty? ? "" : "?#{q}"
179
+ end
305
180
 
306
- # Called by URLSearchParams when it mutates; we need to keep the
307
- # underlying URI's query string in sync so subsequent `href` is
308
- # accurate.
309
- def __internal_notify_params_changed__
310
- 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
311
188
  end
189
+ @search_params.__internal_replace__(@record.query.to_s)
190
+ end
312
191
 
313
- SPECIAL_SCHEMES = %w[http https ws wss ftp file].freeze
314
-
315
- # WHATWG: only http(s) / ws(s) / ftp produce a tuple origin. file
316
- # / data / javascript / etc. resolve to `"null"`. `blob:` is
317
- # handled specially (inner-URL origin).
318
- TUPLE_ORIGIN_SCHEMES = %w[http https ws wss ftp].freeze
319
-
320
- # Default ports per scheme (Ruby URI knows http/https/ftp; we add
321
- # ws/wss).
322
- DEFAULT_PORTS = {
323
- "http" => 80,
324
- "https" => 443,
325
- "ws" => 80,
326
- "wss" => 443,
327
- "ftp" => 21
328
- }.freeze
329
-
330
- # Chars that Ruby URI rejects in the path/query/fragment portion
331
- # but WHATWG silently percent-encodes.
332
- UNSAFE_PATH_CHARS = /[ "<>`{}|\\\^\[\]]/
333
-
334
- private
335
-
336
- def special_scheme?
337
- SPECIAL_SCHEMES.include?(@uri.scheme.to_s.downcase)
338
- end
192
+ def hash
193
+ f = @record.fragment
194
+ f.nil? || f.empty? ? "" : "##{f}"
195
+ end
339
196
 
340
- def default_port_for(scheme)
341
- 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
342
204
  end
205
+ end
343
206
 
344
- def parse_with_base(input, base)
345
- str = preprocess(input.to_s)
346
- uri = nil
347
- if base
348
- base_str = preprocess(base.is_a?(URL) ? base.href : base.to_s)
349
- base_uri = URI.parse(base_str)
350
- uri = URI.join(base_uri, str)
351
- else
352
- uri = URI.parse(str)
353
- raise DOMException::SyntaxError, "Invalid URL: #{str}" unless uri.scheme
354
- end
355
-
356
- normalize_path_segments(uri) if special_scheme_for?(uri)
357
- uri
358
- rescue URI::InvalidURIError => e
359
- raise DOMException::SyntaxError, "Invalid URL: #{e.message}"
360
- rescue Internal::Punycode::Error, Internal::IDNA::Error => e
361
- raise DOMException::SyntaxError, "Invalid URL host: #{e.message}"
362
- end
363
-
364
- # WHATWG URL preprocessing — turn a raw input string into a form
365
- # Ruby URI accepts. Order matters: each step can depend on
366
- # earlier normalizations.
367
- def preprocess(str)
368
- str = strip_c0_and_space(str)
369
- str = strip_tab_and_newline(str)
370
- str = replace_backslashes_for_special_scheme(str)
371
- str = normalize_idn_host(str)
372
- str = normalize_ipv4_host(str)
373
- percent_encode_unsafe(str)
374
- end
375
-
376
- # WHATWG §basic-url-parser step 1: strip leading and trailing
377
- # C0 controls and ASCII space.
378
- def strip_c0_and_space(str)
379
- str.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "")
380
- end
381
-
382
- # WHATWG: remove ASCII tab and newline anywhere in the URL.
383
- def strip_tab_and_newline(str)
384
- str.delete("\t\n\r")
385
- end
386
-
387
- # WHATWG: for special-scheme URLs, treat `\` as `/` in the
388
- # authority and path portions.
389
- def replace_backslashes_for_special_scheme(str)
390
- m = str.match(/\A([a-zA-Z][a-zA-Z0-9+.\-]*):/)
391
- return str unless m
392
- return str unless SPECIAL_SCHEMES.include?(m[1].downcase)
393
-
394
- scheme_end = m.end(0)
395
- str[0...scheme_end] + str[scheme_end..].tr("\\", "/")
396
- end
397
-
398
- # Percent-encode chars after the authority section that Ruby URI
399
- # would reject (space, `<`, `>`, `{`, `}`, `|`, etc.) and any
400
- # non-ASCII byte. Preserves already-encoded `%XX` sequences.
401
- def percent_encode_unsafe(str)
402
- m = str.match(%r{\A([a-zA-Z][a-zA-Z0-9+.\-]*:(?://[^/?#]*)?)(.*)\z}m)
403
- return str unless m
404
-
405
- prefix = m[1]
406
- tail = m[2]
407
- out = +""
408
- i = 0
409
- while i < tail.length
410
- c = tail[i]
411
- if c == "%" && tail[i + 1, 2].to_s.match?(/\A[0-9A-Fa-f]{2}\z/)
412
- out << tail[i, 3]
413
- i += 3
414
- next
415
- end
416
-
417
- needs_encoding = c.bytesize > 1 ||
418
- c.ord < 0x20 ||
419
- c.ord == 0x7F ||
420
- UNSAFE_PATH_CHARS.match?(c)
421
-
422
- if needs_encoding
423
- c.bytes.each { |b| out << format("%%%02X", b) }
424
- else
425
- out << c
426
- 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?
427
214
 
428
- i += 1
429
- end
215
+ port_part = @record.port ? ":#{@record.port}" : ""
216
+ "#{scheme}://#{@record.host}#{port_part}"
217
+ end
430
218
 
431
- prefix + out
432
- end
219
+ def username
220
+ @record.username
221
+ end
433
222
 
434
- # Detect dotted-quad / hex / octal / short-form IPv4 hosts and
435
- # canonicalize to dotted-decimal. Touches the authority section
436
- # only; non-special schemes are skipped.
437
- def normalize_ipv4_host(str)
438
- m = str.match(%r{\A([a-zA-Z][a-zA-Z0-9+.\-]*://(?:[^@/?#]*@)?)([^/:?#]+)(.*)\z}m)
439
- return str unless m
223
+ def username=(value)
224
+ return if cannot_have_credentials?
440
225
 
441
- scheme = str.match(/\A([a-zA-Z][a-zA-Z0-9+.\-]*):/)[1].downcase
442
- 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
443
229
 
444
- ip = Internal::Ipv4Parser.parse(m[2])
445
- return str unless ip
230
+ def password
231
+ @record.password
232
+ end
446
233
 
447
- "#{m[1]}#{ip}#{m[3]}"
448
- end
234
+ def password=(value)
235
+ return if cannot_have_credentials?
449
236
 
450
- def special_scheme_for?(uri)
451
- SPECIAL_SCHEMES.include?(uri.scheme.to_s.downcase)
452
- 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
453
240
 
454
- # WHATWG: resolve `.` / `..` path segments. Applied only to
455
- # special-scheme URIs (opaque schemes' path is verbatim).
456
- def normalize_path_segments(uri)
457
- path = uri.path
458
- return if path.nil? || path.empty?
241
+ def to_s
242
+ href
243
+ end
459
244
 
460
- segments = path.split("/", -1)
461
- result = []
462
- segments.each do |seg|
463
- case seg
464
- when ".."
465
- # Pop unless we'd remove the leading-empty marker.
466
- result.pop if result.length > 1
467
- when "."
468
- # Skip.
469
- else
470
- result << seg
471
- end
472
- end
245
+ def to_json(*_args)
246
+ href.inspect
247
+ end
473
248
 
474
- # Preserve the trailing slash if the input had one.
475
- result << "" if path.end_with?("/", ".") && result.last != ""
476
- 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
477
263
  end
264
+ end
478
265
 
479
- # WHATWG: non-ASCII host labels must be Punycode-encoded
480
- # (`日本.test` → `xn--wgv71a.test`) before storage. Ruby's URI
481
- # parser rejects non-ASCII hosts outright, so we rewrite the host
482
- # portion of the authority section here. Userinfo / port / path /
483
- # query / fragment are left untouched.
484
- def normalize_idn_host(str)
485
- return str unless str.is_a?(String)
486
- 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
487
284
 
488
- str.sub(%r{(://)([^/?#]*)}) do
489
- sep = Regexp.last_match(1)
490
- authority = Regexp.last_match(2)
491
- sep + rewrite_authority(authority)
492
- 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
493
291
  end
292
+ end
494
293
 
495
- def rewrite_authority(authority)
496
- userinfo, hostport = authority.include?("@") ? authority.split("@", 2) : [nil, authority]
497
- host, port = hostport.rpartition(":").then { |h, sep, p|
498
- sep.empty? || h.empty? ? [hostport, nil] : [h, p]
499
- }
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
500
299
 
501
- ascii_host = Internal::IDNA.to_ascii(host)
502
- out = +""
503
- out << "#{userinfo}@" if userinfo
504
- out << ascii_host
505
- out << ":#{port}" if port
506
- out
507
- 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
508
303
 
509
- def build_href
510
- out = +""
511
- out << "#{@uri.scheme}:" if @uri.scheme
304
+ private
512
305
 
513
- opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
514
- if opaque
515
- # Opaque-body scheme (javascript:, mailto:, data:, tel:, blob:)
516
- # — emit the body verbatim, no authority section.
517
- out << opaque
518
- else
519
- if @uri.host
520
- out << "//"
521
- if @uri.user
522
- out << @uri.user
523
- out << ":#{@uri.password}" if @uri.password
524
- out << "@"
525
- end
306
+ def cannot_have_credentials?
307
+ @record.host.nil? || @record.host == "" || @record.scheme == "file"
308
+ end
526
309
 
527
- out << @uri.host
528
- default = default_port_for(@uri.scheme.to_s.downcase)
529
- out << ":#{@uri.port}" if @uri.port && @uri.port != default
530
- 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?
531
315
 
532
- path = @uri.path.to_s
533
- # WHATWG: for special schemes the path is normalized to `/`
534
- # when empty (matches `pathname` accessor).
535
- path = "/" if path.empty? && special_scheme?
536
- out << path
537
- end
316
+ body = @record.path
317
+ return "null" if body.nil? || body.empty?
538
318
 
539
- out << search
540
- out << hash
541
- out
542
- end
319
+ inner = URL.new(body)
320
+ return "null" unless %w[http https file].include?(inner.protocol.delete_suffix(":"))
543
321
 
544
- def sync_uri_query
545
- q = @search_params.to_s
546
- @uri.query = q.empty? ? nil : q
547
- end
322
+ inner.origin
323
+ rescue Bridge::TypeError
324
+ "null"
325
+ end
548
326
  end
549
327
 
550
- # `URLSearchParams` — query-string manipulation. Constructed from a
551
- # raw string (`"a=1&b=2"`), an array of `[k, v]` pairs, or a Hash.
552
- # Order is preserved. Values are stringified per spec.
553
328
  class URLSearchParams
554
329
  include Enumerable
555
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
+
556
336
  def initialize(input = "", owner: nil)
557
337
  @owner = owner
558
338
  @pairs = parse(input)
559
339
  end
560
340
 
561
341
  def get(name)
562
- pair = @pairs.find { |k, _| k == name.to_s }
342
+ key = stringify(name)
343
+ pair = @pairs.find { |k, _| k == key }
563
344
  pair && pair[1]
564
345
  end
565
346
 
566
347
  def get_all(name)
567
- @pairs.select { |k, _| k == name.to_s }.map { |_, v| v }
348
+ key = stringify(name)
349
+ @pairs.select { |k, _| k == key }.map { |_, v| v }
568
350
  end
569
351
 
570
352
  alias getAll get_all
571
353
 
572
- def has(name)
573
- @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 }
574
362
  end
575
363
 
576
364
  alias has? has
577
365
 
578
366
  def set(name, value)
579
- key = name.to_s
367
+ key = stringify(name)
580
368
  first_done = false
581
369
  @pairs = @pairs.reject do |k, _|
582
370
  next false unless k == key
@@ -589,33 +377,44 @@ module Dommy
589
377
  end
590
378
  end
591
379
 
592
- @pairs.map! { |pair| pair[0] == key ? [key, value.to_s] : pair }
593
- @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
594
383
  notify
595
384
  nil
596
385
  end
597
386
 
598
387
  def append(name, value)
599
- @pairs << [name.to_s, value.to_s]
388
+ @pairs << [stringify(name), stringify(value)]
600
389
  notify
601
390
  nil
602
391
  end
603
392
 
604
- def delete(name, value = nil)
605
- key = name.to_s
606
- 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)
607
398
  @pairs.reject! { |k, _| k == key }
608
399
  else
609
- v = value.to_s
610
- @pairs.reject! { |k, vv| k == key && vv == v }
400
+ val = stringify(value)
401
+ @pairs.reject! { |k, vv| k == key && vv == val }
611
402
  end
612
403
 
613
404
  notify
614
405
  nil
615
406
  end
616
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).
617
413
  def sort
618
- @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)
619
418
  notify
620
419
  nil
621
420
  end
@@ -665,6 +464,8 @@ module Dommy
665
464
  end
666
465
  end
667
466
 
467
+ include Bridge::Methods
468
+ js_methods %w[get getAll has set append delete sort toString forEach keys values entries]
668
469
  def __js_call__(method, args)
669
470
  case method
670
471
  when "get"
@@ -672,19 +473,30 @@ module Dommy
672
473
  when "getAll"
673
474
  get_all(args[0])
674
475
  when "has"
675
- has(args[0])
476
+ value_given?(args) ? has(args[0], args[1]) : has(args[0])
676
477
  when "set"
677
478
  set(args[0], args[1])
678
479
  when "append"
679
480
  append(args[0], args[1])
680
481
  when "delete"
681
- delete(args[0], args[1])
482
+ value_given?(args) ? delete(args[0], args[1]) : delete(args[0])
682
483
  when "sort"
683
484
  sort
684
485
  when "toString"
685
486
  to_s
686
487
  when "forEach"
687
- 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
688
500
  when "keys"
689
501
  keys
690
502
  when "values"
@@ -696,6 +508,12 @@ module Dommy
696
508
 
697
509
  private
698
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
+
699
517
  def parse(input)
700
518
  case input
701
519
  when Array
@@ -703,18 +521,38 @@ module Dommy
703
521
  when Hash
704
522
  input.map { |k, v| [k.to_s, v.to_s] }
705
523
  else
706
- 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?
707
530
  return [] if s.empty?
708
531
 
709
- 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|
710
535
  k, v = pair.split("=", 2)
711
- [CGI.unescape(k.to_s), CGI.unescape(v.to_s)]
536
+ [decode(k.to_s), decode(v.to_s)]
712
537
  end
713
538
  end
714
539
  end
715
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 "*".)
716
548
  def encode(str)
717
- 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
718
556
  end
719
557
 
720
558
  def notify