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
@@ -0,0 +1,677 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Internal
5
+ # A WHATWG URL Standard "basic URL parser" (https://url.spec.whatwg.org/).
6
+ #
7
+ # Replaces the previous Ruby `URI`-based resolution, which diverged from the
8
+ # spec on empty userinfo, relative resolution against opaque/special bases,
9
+ # port-range validation, leading-colon inputs, and percent-encoding. Produces
10
+ # a `Record` (the spec's "URL record"); `Dommy::URL` wraps it.
11
+ module UrlParser
12
+ module_function
13
+
14
+ # scheme => default port (file has none).
15
+ SPECIAL = {"ftp" => 21, "file" => nil, "http" => 80, "https" => 443, "ws" => 80, "wss" => 443}.freeze
16
+
17
+ # Raised on a parse failure; `URL.new` maps it to DOMException::SyntaxError,
18
+ # `URL.parse` rescues it and returns nil.
19
+ class Failure < StandardError; end
20
+
21
+ # The spec URL record. `path` is an Array of segments for a hierarchical
22
+ # URL, or a String for an "opaque path" (cannot-be-a-base) URL. `host` is
23
+ # the already-serialized host string (IPv6 stored with brackets), or nil.
24
+ Record = Struct.new(:scheme, :username, :password, :host, :port, :path, :query, :fragment) do
25
+ def special? = SPECIAL.key?(scheme)
26
+ def opaque_path? = path.is_a?(String)
27
+ def includes_credentials? = !username.to_s.empty? || !password.to_s.empty?
28
+ def default_port = SPECIAL[scheme]
29
+ end
30
+
31
+ def parse(input, base_input = nil)
32
+ base = nil
33
+ if base_input && base_input != ""
34
+ base = base_input.is_a?(Record) ? base_input : run(base_input.to_s, nil)
35
+ end
36
+ run(input.to_s, base)
37
+ end
38
+
39
+ # ===== percent-encode sets =====
40
+
41
+ def c0?(cp) = cp <= 0x1F || cp > 0x7E
42
+ def fragment_set?(cp) = c0?(cp) || [0x20, 0x22, 0x3C, 0x3E, 0x60].include?(cp)
43
+ def query_set?(cp) = c0?(cp) || [0x20, 0x22, 0x23, 0x3C, 0x3E].include?(cp)
44
+ def special_query_set?(cp) = query_set?(cp) || cp == 0x27
45
+ def path_set?(cp) = query_set?(cp) || [0x3F, 0x5E, 0x60, 0x7B, 0x7D].include?(cp)
46
+ def userinfo_set?(cp) = path_set?(cp) || [0x2F, 0x3A, 0x3B, 0x3D, 0x40, 0x5B, 0x5C, 0x5D, 0x7C].include?(cp)
47
+
48
+ # UTF-8 percent-encode a single code point against `set` (a predicate).
49
+ def pe(char, set)
50
+ return char unless set.call(char.ord)
51
+
52
+ char.b.bytes.map { |b| format("%%%02X", b) }.join
53
+ end
54
+
55
+ def percent_decode(str)
56
+ out = +"".b
57
+ bytes = str.b
58
+ i = 0
59
+ while i < bytes.bytesize
60
+ b = bytes.getbyte(i)
61
+ if b == 0x25 && i + 2 < bytes.bytesize &&
62
+ bytes.byteslice(i + 1, 2) =~ /\A[0-9A-Fa-f]{2}\z/
63
+ out << bytes.byteslice(i + 1, 2).to_i(16)
64
+ i += 3
65
+ else
66
+ out << b
67
+ i += 1
68
+ end
69
+ end
70
+ out
71
+ end
72
+
73
+ # ===== host parsing =====
74
+
75
+ FORBIDDEN_HOST = [0x00, 0x09, 0x0A, 0x0D, 0x20, 0x23, 0x2F, 0x3A, 0x3C, 0x3E,
76
+ 0x3F, 0x40, 0x5B, 0x5C, 0x5D, 0x5E, 0x7C].freeze
77
+
78
+ def forbidden_host?(cp) = FORBIDDEN_HOST.include?(cp)
79
+ def forbidden_domain?(cp) = forbidden_host?(cp) || cp <= 0x1F || cp == 0x25 || cp == 0x7F
80
+
81
+ def parse_host(input, special)
82
+ if input.start_with?("[")
83
+ raise Failure, "unclosed IPv6 address" unless input.end_with?("]")
84
+
85
+ return "[#{parse_ipv6(input[1...-1])}]"
86
+ end
87
+ return parse_opaque_host(input) unless special
88
+ raise Failure, "empty host" if input.empty?
89
+
90
+ # UTF-8-decode-without-BOM the percent-decoded bytes: malformed
91
+ # sequences become U+FFFD (which domain-to-ASCII then rejects),
92
+ # matching the spec rather than crashing on invalid encoding.
93
+ domain = percent_decode(input).force_encoding("UTF-8").scrub("�")
94
+ ascii =
95
+ begin
96
+ IDNA.to_ascii(domain, check_hyphens: false, verify_dns_length: false)
97
+ rescue IDNA::Error, Punycode::Error => e
98
+ raise Failure, "domain to ASCII: #{e.message}"
99
+ end
100
+ raise Failure, "empty domain" if ascii.empty?
101
+ raise Failure, "forbidden domain code point" if ascii.each_char.any? { |ch| forbidden_domain?(ch.ord) }
102
+
103
+ if ends_in_number?(ascii)
104
+ ip = Ipv4Parser.parse(ascii)
105
+ raise Failure, "invalid IPv4 address" if ip.nil?
106
+
107
+ return ip
108
+ end
109
+ ascii
110
+ end
111
+
112
+ def parse_opaque_host(input)
113
+ raise Failure, "forbidden host code point" if input.each_char.any? { |ch| forbidden_host?(ch.ord) }
114
+
115
+ input.each_char.map { |ch| pe(ch, method(:c0?)) }.join
116
+ end
117
+
118
+ def ends_in_number?(input)
119
+ parts = input.split(".", -1)
120
+ parts.pop if parts.length > 1 && parts.last == ""
121
+ return false if parts.empty?
122
+
123
+ last = parts.last
124
+ return false if last.empty?
125
+ return true if last.match?(/\A[0-9]+\z/)
126
+
127
+ last.match?(/\A0[xX][0-9A-Fa-f]*\z/)
128
+ end
129
+
130
+ # WHATWG IPv6 parser -> compressed serialized string (no brackets).
131
+ def parse_ipv6(input)
132
+ address = [0, 0, 0, 0, 0, 0, 0, 0]
133
+ piece_index = 0
134
+ compress = nil
135
+ chars = input.chars
136
+ ptr = 0
137
+ c = ->(i) { i < chars.length ? chars[i] : nil }
138
+
139
+ if c.call(ptr) == ":"
140
+ raise Failure, "IPv6 starts with single colon" unless c.call(ptr + 1) == ":"
141
+
142
+ ptr += 2
143
+ piece_index += 1
144
+ compress = piece_index
145
+ end
146
+
147
+ while c.call(ptr)
148
+ raise Failure, "too many IPv6 pieces" if piece_index == 8
149
+
150
+ if c.call(ptr) == ":"
151
+ raise Failure, "multiple IPv6 compressions" unless compress.nil?
152
+
153
+ ptr += 1
154
+ piece_index += 1
155
+ compress = piece_index
156
+ next
157
+ end
158
+
159
+ value = 0
160
+ length = 0
161
+ while length < 4 && c.call(ptr)&.match?(/[0-9A-Fa-f]/)
162
+ value = value * 16 + c.call(ptr).to_i(16)
163
+ ptr += 1
164
+ length += 1
165
+ end
166
+
167
+ if c.call(ptr) == "."
168
+ raise Failure, "IPv4-in-IPv6 with no digits" if length.zero?
169
+
170
+ ptr -= length
171
+ raise Failure, "too few pieces for embedded IPv4" if piece_index > 6
172
+
173
+ numbers_seen = 0
174
+ while c.call(ptr)
175
+ ipv4_piece = nil
176
+ if numbers_seen.positive?
177
+ if c.call(ptr) == "." && numbers_seen < 4
178
+ ptr += 1
179
+ else
180
+ raise Failure, "invalid embedded IPv4"
181
+ end
182
+ end
183
+ raise Failure, "invalid embedded IPv4 digit" unless c.call(ptr)&.match?(/[0-9]/)
184
+
185
+ while c.call(ptr)&.match?(/[0-9]/)
186
+ number = c.call(ptr).to_i
187
+ if ipv4_piece.nil?
188
+ ipv4_piece = number
189
+ elsif ipv4_piece.zero?
190
+ raise Failure, "leading zero in embedded IPv4"
191
+ else
192
+ ipv4_piece = ipv4_piece * 10 + number
193
+ end
194
+ raise Failure, "embedded IPv4 piece > 255" if ipv4_piece > 255
195
+
196
+ ptr += 1
197
+ end
198
+ address[piece_index] = address[piece_index] * 0x100 + ipv4_piece
199
+ numbers_seen += 1
200
+ piece_index += 1 if numbers_seen == 2 || numbers_seen == 4
201
+ end
202
+ raise Failure, "incomplete embedded IPv4" unless numbers_seen == 4
203
+
204
+ break
205
+ elsif c.call(ptr) == ":"
206
+ ptr += 1
207
+ raise Failure, "trailing colon in IPv6" if c.call(ptr).nil?
208
+ elsif c.call(ptr)
209
+ raise Failure, "invalid IPv6 code point"
210
+ end
211
+
212
+ address[piece_index] = value
213
+ piece_index += 1
214
+ end
215
+
216
+ if compress
217
+ swaps = piece_index - compress
218
+ piece_index = 7
219
+ while piece_index != 0 && swaps.positive?
220
+ address[piece_index], address[compress + swaps - 1] = address[compress + swaps - 1], address[piece_index]
221
+ piece_index -= 1
222
+ swaps -= 1
223
+ end
224
+ elsif piece_index != 8
225
+ raise Failure, "too few IPv6 pieces"
226
+ end
227
+
228
+ serialize_ipv6(address)
229
+ end
230
+
231
+ def serialize_ipv6(pieces)
232
+ # Find the longest run (length > 1) of zero pieces to compress.
233
+ best_start = nil
234
+ best_len = 0
235
+ i = 0
236
+ while i < 8
237
+ if pieces[i].zero?
238
+ j = i
239
+ j += 1 while j < 8 && pieces[j].zero?
240
+ if (j - i) > best_len
241
+ best_len = j - i
242
+ best_start = i
243
+ end
244
+ i = j
245
+ else
246
+ i += 1
247
+ end
248
+ end
249
+ best_start = nil if best_len < 2
250
+
251
+ out = +""
252
+ i = 0
253
+ while i < 8
254
+ if best_start == i
255
+ out << (i.zero? ? "::" : ":")
256
+ i += best_len
257
+ next
258
+ end
259
+ out << pieces[i].to_s(16)
260
+ out << ":" if i < 7
261
+ i += 1
262
+ end
263
+ out
264
+ end
265
+
266
+ # ===== serialization =====
267
+
268
+ def serialize_path(record)
269
+ return record.path if record.opaque_path?
270
+
271
+ record.path.map { |seg| "/#{seg}" }.join
272
+ end
273
+
274
+ def serialize(record, exclude_fragment: false)
275
+ out = +"#{record.scheme}:"
276
+ if record.host
277
+ out << "//"
278
+ if record.includes_credentials?
279
+ out << record.username
280
+ out << ":#{record.password}" unless record.password.empty?
281
+ out << "@"
282
+ end
283
+ out << record.host
284
+ out << ":#{record.port}" if record.port
285
+ elsif !record.opaque_path? && record.path.is_a?(Array) &&
286
+ record.path.length > 1 && record.path[0] == ""
287
+ out << "/."
288
+ end
289
+ out << serialize_path(record)
290
+ out << "?#{record.query}" if record.query
291
+ out << "##{record.fragment}" if record.fragment && !exclude_fragment
292
+ out
293
+ end
294
+
295
+ # ===== helpers for path normalization =====
296
+
297
+ def windows_drive_letter?(seg) = seg.length == 2 && seg[0].match?(/[A-Za-z]/) && [":", "|"].include?(seg[1])
298
+ def normalized_windows_drive_letter?(seg) = seg.length == 2 && seg[0].match?(/[A-Za-z]/) && seg[1] == ":"
299
+ def starts_with_windows_drive_letter?(s)
300
+ s.length >= 2 && s[0].match?(/[A-Za-z]/) && [":", "|"].include?(s[1]) &&
301
+ (s.length == 2 || ["/", "\\", "?", "#"].include?(s[2]))
302
+ end
303
+
304
+ def single_dot?(seg) = [".", "%2e"].include?(seg.downcase)
305
+
306
+ def double_dot?(seg)
307
+ ["..", ".%2e", "%2e.", "%2e%2e"].include?(seg.downcase)
308
+ end
309
+
310
+ def shorten_path(record)
311
+ path = record.path
312
+ return if path.empty?
313
+ return if record.scheme == "file" && path.length == 1 && normalized_windows_drive_letter?(path[0])
314
+
315
+ path.pop
316
+ end
317
+
318
+ # ===== the basic URL parser state machine =====
319
+
320
+ def run(input, base)
321
+ input = input.dup
322
+ # Strip leading/trailing C0 controls and spaces, then remove all
323
+ # ASCII tab/newline.
324
+ input = input.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "")
325
+ input = input.gsub(/[\t\n\r]/, "")
326
+
327
+ chars = input.chars
328
+ len = chars.length
329
+ state = :scheme_start
330
+ url = Record.new("", "", "", nil, nil, [], nil, nil)
331
+ buffer = +""
332
+ at_sign_seen = false
333
+ inside_brackets = false
334
+ password_token_seen = false
335
+ ptr = 0
336
+
337
+ cp = lambda { ptr < len ? chars[ptr] : nil }
338
+
339
+ loop do
340
+ c = cp.call
341
+
342
+ case state
343
+ when :scheme_start
344
+ if c&.match?(/[A-Za-z]/)
345
+ buffer << c.downcase
346
+ state = :scheme
347
+ else
348
+ state = :no_scheme
349
+ next # reprocess (do not advance)
350
+ end
351
+
352
+ when :scheme
353
+ if c&.match?(/[A-Za-z0-9+\-.]/)
354
+ buffer << c.downcase
355
+ elsif c == ":"
356
+ url.scheme = buffer
357
+ buffer = +""
358
+ if url.scheme == "file"
359
+ state = :file
360
+ elsif url.special? && base && base.scheme == url.scheme
361
+ state = :special_relative_or_authority
362
+ elsif url.special?
363
+ state = :special_authority_slashes
364
+ elsif input[(ptr + 1)..].to_s.start_with?("/")
365
+ state = :path_or_authority
366
+ ptr += 1
367
+ else
368
+ url.path = ""
369
+ state = :opaque_path
370
+ end
371
+ else
372
+ buffer = +""
373
+ state = :no_scheme
374
+ ptr = -1 # restart from 0 (advance makes it 0)
375
+ end
376
+
377
+ when :no_scheme
378
+ raise Failure, "missing scheme" if base.nil? || (base.opaque_path? && c != "#")
379
+
380
+ if base.opaque_path? && c == "#"
381
+ url.scheme = base.scheme
382
+ url.path = base.path
383
+ url.query = base.query
384
+ url.fragment = +""
385
+ state = :fragment
386
+ elsif base.scheme != "file"
387
+ state = :relative
388
+ next
389
+ else
390
+ state = :file
391
+ next
392
+ end
393
+
394
+ when :special_relative_or_authority
395
+ if c == "/" && input[(ptr + 1)..].to_s.start_with?("/")
396
+ state = :special_authority_ignore_slashes
397
+ ptr += 1
398
+ else
399
+ state = :relative
400
+ next
401
+ end
402
+
403
+ when :path_or_authority
404
+ if c == "/"
405
+ state = :authority
406
+ else
407
+ state = :path
408
+ next
409
+ end
410
+
411
+ when :relative
412
+ url.scheme = base.scheme
413
+ if c == "/"
414
+ state = :relative_slash
415
+ elsif url.special? && c == "\\"
416
+ state = :relative_slash
417
+ else
418
+ url.username = base.username
419
+ url.password = base.password
420
+ url.host = base.host
421
+ url.port = base.port
422
+ url.path = base.path.dup
423
+ url.query = base.query
424
+ if c == "?"
425
+ url.query = +""
426
+ state = :query
427
+ elsif c == "#"
428
+ url.fragment = +""
429
+ state = :fragment
430
+ elsif c
431
+ url.query = nil
432
+ shorten_path(url)
433
+ state = :path
434
+ next
435
+ end
436
+ end
437
+
438
+ when :relative_slash
439
+ if url.special? && (c == "/" || c == "\\")
440
+ state = :special_authority_ignore_slashes
441
+ elsif c == "/"
442
+ state = :authority
443
+ else
444
+ url.username = base.username
445
+ url.password = base.password
446
+ url.host = base.host
447
+ url.port = base.port
448
+ state = :path
449
+ next
450
+ end
451
+
452
+ when :special_authority_slashes
453
+ if c == "/" && input[(ptr + 1)..].to_s.start_with?("/")
454
+ state = :special_authority_ignore_slashes
455
+ ptr += 1
456
+ else
457
+ state = :special_authority_ignore_slashes
458
+ next
459
+ end
460
+
461
+ when :special_authority_ignore_slashes
462
+ if c != "/" && c != "\\"
463
+ state = :authority
464
+ next
465
+ end
466
+
467
+ when :authority
468
+ if c == "@"
469
+ buffer = "%40#{buffer}" if at_sign_seen
470
+ at_sign_seen = true
471
+ buffer.each_char do |ch|
472
+ if ch == ":" && !password_token_seen
473
+ password_token_seen = true
474
+ next
475
+ end
476
+ encoded = pe(ch, method(:userinfo_set?))
477
+ if password_token_seen
478
+ url.password += encoded
479
+ else
480
+ url.username += encoded
481
+ end
482
+ end
483
+ buffer = +""
484
+ elsif c.nil? || ["/", "?", "#"].include?(c) || (url.special? && c == "\\")
485
+ raise Failure, "empty host with credentials" if at_sign_seen && buffer.empty?
486
+
487
+ ptr -= (buffer.length + 1)
488
+ buffer = +""
489
+ state = :host
490
+ else
491
+ buffer << c
492
+ end
493
+
494
+ when :host, :hostname
495
+ if c == ":" && !inside_brackets
496
+ raise Failure, "empty host" if buffer.empty?
497
+
498
+ url.host = parse_host(buffer, url.special?)
499
+ buffer = +""
500
+ state = :port
501
+ elsif c.nil? || ["/", "?", "#"].include?(c) || (url.special? && c == "\\")
502
+ ptr -= 1
503
+ raise Failure, "empty special host" if url.special? && buffer.empty?
504
+
505
+ url.host = parse_host(buffer, url.special?)
506
+ buffer = +""
507
+ state = :path_start
508
+ else
509
+ inside_brackets = true if c == "["
510
+ inside_brackets = false if c == "]"
511
+ buffer << c
512
+ end
513
+
514
+ when :port
515
+ if c&.match?(/[0-9]/)
516
+ buffer << c
517
+ elsif c.nil? || ["/", "?", "#"].include?(c) || (url.special? && c == "\\")
518
+ unless buffer.empty?
519
+ port = buffer.to_i
520
+ raise Failure, "port out of range" if port > 65_535
521
+
522
+ url.port = (port == url.default_port ? nil : port)
523
+ buffer = +""
524
+ end
525
+ state = :path_start
526
+ next
527
+ else
528
+ raise Failure, "invalid port"
529
+ end
530
+
531
+ when :file
532
+ url.scheme = "file"
533
+ url.host = ""
534
+ if c == "/" || c == "\\"
535
+ state = :file_slash
536
+ elsif base && base.scheme == "file"
537
+ url.host = base.host
538
+ url.path = base.path.dup
539
+ url.query = base.query
540
+ if c == "?"
541
+ url.query = +""
542
+ state = :query
543
+ elsif c == "#"
544
+ url.fragment = +""
545
+ state = :fragment
546
+ elsif c
547
+ url.query = nil
548
+ shorten_path(url) unless starts_with_windows_drive_letter?(input[ptr..].to_s)
549
+ url.path = [] if starts_with_windows_drive_letter?(input[ptr..].to_s)
550
+ state = :path
551
+ next
552
+ end
553
+ else
554
+ state = :path
555
+ next
556
+ end
557
+
558
+ when :file_slash
559
+ if c == "/" || c == "\\"
560
+ state = :file_host
561
+ else
562
+ if base && base.scheme == "file"
563
+ url.host = base.host
564
+ if !starts_with_windows_drive_letter?(input[ptr..].to_s) &&
565
+ base.path[0] && normalized_windows_drive_letter?(base.path[0])
566
+ url.path << base.path[0]
567
+ end
568
+ end
569
+ state = :path
570
+ next
571
+ end
572
+
573
+ when :file_host
574
+ if c.nil? || ["/", "\\", "?", "#"].include?(c)
575
+ ptr -= 1
576
+ if buffer.match?(/\A[A-Za-z][:|]\z/)
577
+ state = :path
578
+ elsif buffer.empty?
579
+ url.host = ""
580
+ state = :path_start
581
+ else
582
+ host = parse_host(buffer, true)
583
+ host = "" if host == "localhost"
584
+ url.host = host
585
+ buffer = +""
586
+ state = :path_start
587
+ end
588
+ else
589
+ buffer << c
590
+ end
591
+
592
+ when :path_start
593
+ if url.special?
594
+ state = :path
595
+ next unless c == "/" || c == "\\"
596
+ elsif c == "?"
597
+ url.query = +""
598
+ state = :query
599
+ elsif c == "#"
600
+ url.fragment = +""
601
+ state = :fragment
602
+ elsif c
603
+ state = :path
604
+ next unless c == "/"
605
+ end
606
+
607
+ when :path
608
+ if c.nil? || c == "/" || (url.special? && c == "\\") ||
609
+ c == "?" || c == "#"
610
+ if double_dot?(buffer)
611
+ shorten_path(url)
612
+ url.path << "" unless c == "/" || (url.special? && c == "\\")
613
+ elsif single_dot?(buffer)
614
+ url.path << "" unless c == "/" || (url.special? && c == "\\")
615
+ else
616
+ if url.scheme == "file" && url.path.empty? && windows_drive_letter?(buffer)
617
+ buffer[1] = ":"
618
+ end
619
+ url.path << buffer
620
+ end
621
+ buffer = +""
622
+ if c == "?"
623
+ url.query = +""
624
+ state = :query
625
+ elsif c == "#"
626
+ url.fragment = +""
627
+ state = :fragment
628
+ end
629
+ else
630
+ buffer << pe(c, method(:path_set?))
631
+ end
632
+
633
+ when :opaque_path
634
+ if c == "?"
635
+ url.query = +""
636
+ state = :query
637
+ elsif c == "#"
638
+ url.fragment = +""
639
+ state = :fragment
640
+ elsif c == " "
641
+ # A space is only percent-encoded when it abuts the end of
642
+ # the opaque path (a following `?`/`#`); an interior space
643
+ # stays literal. (Trailing-at-EOF spaces are already gone
644
+ # via the leading/trailing strip.)
645
+ nxt = chars[ptr + 1]
646
+ url.path += (nxt == "?" || nxt == "#") ? "%20" : " "
647
+ elsif c
648
+ url.path += pe(c, method(:c0?))
649
+ end
650
+
651
+ when :query
652
+ if c.nil? || c == "#"
653
+ set = url.special? ? method(:special_query_set?) : method(:query_set?)
654
+ url.query += buffer.each_char.map { |ch| pe(ch, set) }.join
655
+ buffer = +""
656
+ if c == "#"
657
+ url.fragment = +""
658
+ state = :fragment
659
+ end
660
+ else
661
+ buffer << c
662
+ end
663
+
664
+ when :fragment
665
+ url.fragment += pe(c, method(:fragment_set?)) if c
666
+ end
667
+
668
+ break if ptr >= len
669
+
670
+ ptr += 1
671
+ end
672
+
673
+ url
674
+ end
675
+ end
676
+ end
677
+ end
@@ -66,6 +66,8 @@ module Dommy
66
66
  end
67
67
  end
68
68
 
69
+ include Bridge::Methods
70
+ js_methods %w[observe unobserve disconnect takeRecords]
69
71
  def __js_call__(method, args)
70
72
  case method
71
73
  when "observe"
@@ -65,6 +65,8 @@ module Dommy
65
65
  end
66
66
  end
67
67
 
68
+ include Bridge::Methods
69
+ js_methods %w[assign replace reload toString]
68
70
  def __js_call__(method, args)
69
71
  case method
70
72
  when "assign", "replace"