dommy 0.5.0 → 0.7.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.
- checksums.yaml +4 -4
- data/README.md +31 -13
- data/lib/dommy/animation.rb +288 -0
- data/lib/dommy/attr.rb +23 -11
- data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
- data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
- data/lib/dommy/backend.rb +129 -0
- data/lib/dommy/blob.rb +2 -2
- data/lib/dommy/compression_streams.rb +147 -0
- data/lib/dommy/cookie_store.rb +128 -0
- data/lib/dommy/crypto.rb +396 -0
- data/lib/dommy/css.rb +7 -7
- data/lib/dommy/custom_elements.rb +6 -6
- data/lib/dommy/document.rb +190 -32
- data/lib/dommy/dom_parser.rb +5 -4
- data/lib/dommy/element.rb +356 -53
- data/lib/dommy/event.rb +431 -25
- data/lib/dommy/event_source.rb +131 -0
- data/lib/dommy/fetch.rb +76 -6
- data/lib/dommy/file_reader.rb +176 -0
- data/lib/dommy/form_data.rb +1 -3
- data/lib/dommy/history.rb +82 -0
- data/lib/dommy/html_collection.rb +4 -4
- data/lib/dommy/html_elements.rb +130 -67
- data/lib/dommy/internal/cookie_jar.rb +2 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
- data/lib/dommy/internal/dom_matching.rb +4 -4
- data/lib/dommy/internal/idna.rb +443 -0
- data/lib/dommy/internal/idna_data.rb +10379 -0
- data/lib/dommy/internal/ipv4_parser.rb +78 -0
- data/lib/dommy/internal/node_traversal.rb +1 -1
- data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
- data/lib/dommy/internal/observable_callback.rb +25 -0
- data/lib/dommy/internal/punycode.rb +202 -0
- data/lib/dommy/internal/range_text_serializer.rb +72 -0
- data/lib/dommy/internal/reflected_attributes.rb +45 -0
- data/lib/dommy/internal/template_content_registry.rb +6 -6
- data/lib/dommy/intersection_observer.rb +82 -0
- data/lib/dommy/{router.rb → location.rb} +8 -142
- data/lib/dommy/media_query_list.rb +118 -0
- data/lib/dommy/message_channel.rb +249 -0
- data/lib/dommy/{observer.rb → mutation_observer.rb} +21 -11
- data/lib/dommy/navigator.rb +365 -5
- data/lib/dommy/node.rb +12 -0
- data/lib/dommy/notification.rb +89 -0
- data/lib/dommy/parser.rb +13 -13
- data/lib/dommy/performance.rb +146 -0
- data/lib/dommy/performance_observer.rb +55 -0
- data/lib/dommy/range.rb +597 -0
- data/lib/dommy/resize_observer.rb +53 -0
- data/lib/dommy/shadow_root.rb +10 -8
- data/lib/dommy/streams.rb +386 -0
- data/lib/dommy/svg_elements.rb +3863 -0
- data/lib/dommy/text_codec.rb +175 -0
- data/lib/dommy/tree_walker.rb +21 -21
- data/lib/dommy/url.rb +274 -29
- data/lib/dommy/url_pattern.rb +144 -0
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +209 -0
- data/lib/dommy/window.rb +369 -0
- data/lib/dommy/worker.rb +143 -0
- data/lib/dommy/xml_http_request.rb +438 -0
- data/lib/dommy.rb +43 -5
- metadata +44 -29
- data/lib/dommy/world.rb +0 -209
data/lib/dommy/url.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Dommy
|
|
|
30
30
|
|
|
31
31
|
class << self
|
|
32
32
|
# Create a unique blob: URL that resolves back to `blob` via
|
|
33
|
-
# `URL.
|
|
33
|
+
# `URL.__test_resolve_blob_url__(url)`. Returns nil for non-Blob input.
|
|
34
34
|
def create_object_url(blob)
|
|
35
35
|
return nil unless blob.is_a?(Blob)
|
|
36
36
|
|
|
@@ -53,14 +53,31 @@ module Dommy
|
|
|
53
53
|
|
|
54
54
|
# Resolve a blob: URL back to its Blob, or nil if revoked / unknown.
|
|
55
55
|
# Internal — used by fetch / XHR implementations that load blob URLs.
|
|
56
|
-
def
|
|
56
|
+
def __test_resolve_blob_url__(url)
|
|
57
57
|
@blob_urls[url.to_s]
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
# Test seam: drop all registered blob URLs.
|
|
61
|
-
def
|
|
61
|
+
def __test_reset_blob_urls__
|
|
62
62
|
@blob_urls.clear
|
|
63
63
|
end
|
|
64
|
+
|
|
65
|
+
# WHATWG URL Standard — `URL.parse(input, base)` is the
|
|
66
|
+
# non-throwing static factory. Returns a URL on success, `nil`
|
|
67
|
+
# on parse failure. The constructor (`new URL(...)`) raises
|
|
68
|
+
# `SyntaxError` for the same failure case.
|
|
69
|
+
def parse(input, base = nil)
|
|
70
|
+
new(input, base)
|
|
71
|
+
rescue DOMException::SyntaxError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# WHATWG URL Standard — `URL.canParse(input, base)`. Boolean
|
|
76
|
+
# counterpart to `parse`: lets callers peek at validity
|
|
77
|
+
# without rescuing an exception or holding a URL reference.
|
|
78
|
+
def can_parse(input, base = nil)
|
|
79
|
+
!parse(input, base).nil?
|
|
80
|
+
end
|
|
64
81
|
end
|
|
65
82
|
|
|
66
83
|
attr_reader :search_params
|
|
@@ -78,7 +95,7 @@ module Dommy
|
|
|
78
95
|
def href=(value)
|
|
79
96
|
raw = parse_with_base(value.to_s, nil)
|
|
80
97
|
@uri = raw
|
|
81
|
-
@search_params.
|
|
98
|
+
@search_params.__internal_replace__(raw.query.to_s)
|
|
82
99
|
build_href
|
|
83
100
|
end
|
|
84
101
|
|
|
@@ -93,7 +110,7 @@ module Dommy
|
|
|
93
110
|
|
|
94
111
|
def host
|
|
95
112
|
port = @uri.port
|
|
96
|
-
default = @uri.
|
|
113
|
+
default = default_port_for(@uri.scheme.to_s.downcase)
|
|
97
114
|
hostpart = @uri.host.to_s
|
|
98
115
|
return hostpart if port.nil? || port == default
|
|
99
116
|
|
|
@@ -111,11 +128,14 @@ module Dommy
|
|
|
111
128
|
end
|
|
112
129
|
|
|
113
130
|
def hostname=(value)
|
|
114
|
-
|
|
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)
|
|
115
134
|
end
|
|
116
135
|
|
|
117
136
|
def port
|
|
118
|
-
|
|
137
|
+
default = default_port_for(@uri.scheme.to_s.downcase)
|
|
138
|
+
return "" if @uri.port.nil? || @uri.port == default
|
|
119
139
|
|
|
120
140
|
@uri.port.to_s
|
|
121
141
|
end
|
|
@@ -124,8 +144,18 @@ module Dommy
|
|
|
124
144
|
@uri.port = value.to_s.empty? ? nil : value.to_i
|
|
125
145
|
end
|
|
126
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 `"/"`.
|
|
127
151
|
def pathname
|
|
128
|
-
@uri.
|
|
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
|
|
129
159
|
end
|
|
130
160
|
|
|
131
161
|
def pathname=(value)
|
|
@@ -134,15 +164,19 @@ module Dommy
|
|
|
134
164
|
@uri.path = v
|
|
135
165
|
end
|
|
136
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`.
|
|
137
171
|
def search
|
|
138
|
-
q = @
|
|
139
|
-
q.empty? ? "" : "?#{q}"
|
|
172
|
+
q = @uri.query
|
|
173
|
+
q.nil? || q.empty? ? "" : "?#{q}"
|
|
140
174
|
end
|
|
141
175
|
|
|
142
176
|
def search=(value)
|
|
143
177
|
q = value.to_s.sub(/^\?/, "")
|
|
144
|
-
@
|
|
145
|
-
|
|
178
|
+
@uri.query = q.empty? ? nil : q
|
|
179
|
+
@search_params.__internal_replace__(q)
|
|
146
180
|
end
|
|
147
181
|
|
|
148
182
|
def hash
|
|
@@ -155,11 +189,30 @@ module Dommy
|
|
|
155
189
|
@uri.fragment = f.empty? ? nil : f
|
|
156
190
|
end
|
|
157
191
|
|
|
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.
|
|
158
195
|
def origin
|
|
159
|
-
|
|
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
|
|
200
|
+
|
|
201
|
+
default = default_port_for(scheme)
|
|
202
|
+
port_part = (@uri.port && @uri.port != default) ? ":#{@uri.port}" : ""
|
|
203
|
+
"#{scheme}://#{@uri.host}#{port_part}"
|
|
204
|
+
end
|
|
205
|
+
|
|
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?
|
|
160
212
|
|
|
161
|
-
|
|
162
|
-
|
|
213
|
+
URL.new(opaque).origin
|
|
214
|
+
rescue DOMException::SyntaxError
|
|
215
|
+
"null"
|
|
163
216
|
end
|
|
164
217
|
|
|
165
218
|
def username
|
|
@@ -253,44 +306,236 @@ module Dommy
|
|
|
253
306
|
# Called by URLSearchParams when it mutates; we need to keep the
|
|
254
307
|
# underlying URI's query string in sync so subsequent `href` is
|
|
255
308
|
# accurate.
|
|
256
|
-
def
|
|
309
|
+
def __internal_notify_params_changed__
|
|
257
310
|
sync_uri_query
|
|
258
311
|
end
|
|
259
312
|
|
|
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
|
+
|
|
260
334
|
private
|
|
261
335
|
|
|
336
|
+
def special_scheme?
|
|
337
|
+
SPECIAL_SCHEMES.include?(@uri.scheme.to_s.downcase)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def default_port_for(scheme)
|
|
341
|
+
DEFAULT_PORTS[scheme]
|
|
342
|
+
end
|
|
343
|
+
|
|
262
344
|
def parse_with_base(input, base)
|
|
263
|
-
str = input.to_s
|
|
345
|
+
str = preprocess(input.to_s)
|
|
264
346
|
uri = nil
|
|
265
347
|
if base
|
|
266
|
-
|
|
348
|
+
base_str = preprocess(base.is_a?(URL) ? base.href : base.to_s)
|
|
349
|
+
base_uri = URI.parse(base_str)
|
|
267
350
|
uri = URI.join(base_uri, str)
|
|
268
351
|
else
|
|
269
352
|
uri = URI.parse(str)
|
|
270
353
|
raise DOMException::SyntaxError, "Invalid URL: #{str}" unless uri.scheme
|
|
271
354
|
end
|
|
272
355
|
|
|
356
|
+
normalize_path_segments(uri) if special_scheme_for?(uri)
|
|
273
357
|
uri
|
|
274
358
|
rescue URI::InvalidURIError => e
|
|
275
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
|
|
427
|
+
|
|
428
|
+
i += 1
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
prefix + out
|
|
432
|
+
end
|
|
433
|
+
|
|
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
|
|
440
|
+
|
|
441
|
+
scheme = str.match(/\A([a-zA-Z][a-zA-Z0-9+.\-]*):/)[1].downcase
|
|
442
|
+
return str unless SPECIAL_SCHEMES.include?(scheme)
|
|
443
|
+
|
|
444
|
+
ip = Internal::Ipv4Parser.parse(m[2])
|
|
445
|
+
return str unless ip
|
|
446
|
+
|
|
447
|
+
"#{m[1]}#{ip}#{m[3]}"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def special_scheme_for?(uri)
|
|
451
|
+
SPECIAL_SCHEMES.include?(uri.scheme.to_s.downcase)
|
|
452
|
+
end
|
|
453
|
+
|
|
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?
|
|
459
|
+
|
|
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
|
|
473
|
+
|
|
474
|
+
# Preserve the trailing slash if the input had one.
|
|
475
|
+
result << "" if path.end_with?("/", ".") && result.last != ""
|
|
476
|
+
uri.path = result.join("/")
|
|
477
|
+
end
|
|
478
|
+
|
|
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{://})
|
|
487
|
+
|
|
488
|
+
str.sub(%r{(://)([^/?#]*)}) do
|
|
489
|
+
sep = Regexp.last_match(1)
|
|
490
|
+
authority = Regexp.last_match(2)
|
|
491
|
+
sep + rewrite_authority(authority)
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
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
|
+
}
|
|
500
|
+
|
|
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
|
|
276
507
|
end
|
|
277
508
|
|
|
278
509
|
def build_href
|
|
279
510
|
out = +""
|
|
280
511
|
out << "#{@uri.scheme}:" if @uri.scheme
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
512
|
+
|
|
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
|
|
526
|
+
|
|
527
|
+
out << @uri.host
|
|
528
|
+
default = default_port_for(@uri.scheme.to_s.downcase)
|
|
529
|
+
out << ":#{@uri.port}" if @uri.port && @uri.port != default
|
|
287
530
|
end
|
|
288
531
|
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
291
537
|
end
|
|
292
538
|
|
|
293
|
-
out << @uri.path.to_s
|
|
294
539
|
out << search
|
|
295
540
|
out << hash
|
|
296
541
|
out
|
|
@@ -408,7 +653,7 @@ module Dommy
|
|
|
408
653
|
@pairs.map { |k, v| "#{encode(k)}=#{encode(v)}" }.join("&")
|
|
409
654
|
end
|
|
410
655
|
|
|
411
|
-
def
|
|
656
|
+
def __internal_replace__(query_string)
|
|
412
657
|
@pairs = parse(query_string)
|
|
413
658
|
nil
|
|
414
659
|
end
|
|
@@ -473,7 +718,7 @@ module Dommy
|
|
|
473
718
|
end
|
|
474
719
|
|
|
475
720
|
def notify
|
|
476
|
-
@owner&.
|
|
721
|
+
@owner&.__internal_notify_params_changed__
|
|
477
722
|
end
|
|
478
723
|
end
|
|
479
724
|
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
# `URLPattern` — pattern matching for URL components, modelled on
|
|
5
|
+
# the WICG URLPattern spec. Supports the path-like syntax familiar
|
|
6
|
+
# from Express / Sinatra / React Router:
|
|
7
|
+
#
|
|
8
|
+
# /users/:id → captures `id`
|
|
9
|
+
# /docs/* → captures the rest of the path as `0`
|
|
10
|
+
# /api/:version+ → one-or-more segments
|
|
11
|
+
# /api/:version? → optional segment
|
|
12
|
+
#
|
|
13
|
+
# Each pattern is compiled per URL component (`protocol` / `username`
|
|
14
|
+
# / `password` / `hostname` / `port` / `pathname` / `search` / `hash`);
|
|
15
|
+
# `test(url)` returns boolean, `exec(url)` returns a result Hash
|
|
16
|
+
# whose `pathname.groups` etc. carry the captured values.
|
|
17
|
+
#
|
|
18
|
+
# Spec: https://urlpattern.spec.whatwg.org/
|
|
19
|
+
class URLPattern
|
|
20
|
+
COMPONENTS = %w[protocol username password hostname port pathname search hash].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(init = nil, _base_url = nil)
|
|
23
|
+
@patterns = {}
|
|
24
|
+
input = init.is_a?(Hash) ? init.transform_keys(&:to_s) : {"pathname" => init.to_s}
|
|
25
|
+
|
|
26
|
+
COMPONENTS.each do |comp|
|
|
27
|
+
pattern = input[comp]
|
|
28
|
+
@patterns[comp] = pattern ? compile(pattern.to_s) : compile("*")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test(input)
|
|
33
|
+
!exec(input).nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def exec(input)
|
|
37
|
+
values = extract_components(input)
|
|
38
|
+
result = {}
|
|
39
|
+
|
|
40
|
+
COMPONENTS.each do |comp|
|
|
41
|
+
compiled = @patterns[comp]
|
|
42
|
+
match = compiled[:regex].match(values[comp].to_s)
|
|
43
|
+
return nil unless match
|
|
44
|
+
|
|
45
|
+
groups = {}
|
|
46
|
+
compiled[:names].each_with_index do |name, idx|
|
|
47
|
+
groups[name] = match[idx + 1] if name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result[comp] = {"input" => values[comp].to_s, "groups" => groups}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
result["inputs"] = [input]
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def __js_call__(method, args)
|
|
58
|
+
case method
|
|
59
|
+
when "test"
|
|
60
|
+
test(args[0])
|
|
61
|
+
when "exec"
|
|
62
|
+
exec(args[0])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def extract_components(input)
|
|
69
|
+
if input.is_a?(Hash)
|
|
70
|
+
h = input.transform_keys(&:to_s)
|
|
71
|
+
COMPONENTS.each_with_object({}) { |c, m| m[c] = h[c].to_s }
|
|
72
|
+
else
|
|
73
|
+
url_string = input.is_a?(URL) ? input.href : input.to_s
|
|
74
|
+
# Bare path / search / hash inputs are accepted: try to parse
|
|
75
|
+
# as a URL; if that fails, treat the whole string as a pathname.
|
|
76
|
+
u = (URL.new(url_string) rescue nil) ||
|
|
77
|
+
URL.new("http://example.test#{url_string.start_with?("/") ? url_string : "/#{url_string}"}")
|
|
78
|
+
{
|
|
79
|
+
"protocol" => u.protocol.sub(/:$/, ""),
|
|
80
|
+
"username" => u.username,
|
|
81
|
+
"password" => u.password,
|
|
82
|
+
"hostname" => u.hostname,
|
|
83
|
+
"port" => u.port,
|
|
84
|
+
"pathname" => u.pathname,
|
|
85
|
+
"search" => u.search.sub(/^\?/, ""),
|
|
86
|
+
"hash" => u.hash.sub(/^#/, "")
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Compile a URLPattern fragment into a Regexp + ordered capture
|
|
92
|
+
# names. Supported syntax:
|
|
93
|
+
#
|
|
94
|
+
# * → match anything (named "0", "1", ...)
|
|
95
|
+
# :name → match `[^/]+`
|
|
96
|
+
# :name+ → match `[^/]+(?:/[^/]+)*`
|
|
97
|
+
# :name? → optional
|
|
98
|
+
#
|
|
99
|
+
# Literal chars are regex-escaped. This is a deliberately small
|
|
100
|
+
# subset of the spec — sufficient for typical routing patterns.
|
|
101
|
+
def compile(pattern)
|
|
102
|
+
names = []
|
|
103
|
+
wildcard_idx = 0
|
|
104
|
+
regex_src = +"\\A"
|
|
105
|
+
i = 0
|
|
106
|
+
|
|
107
|
+
while i < pattern.length
|
|
108
|
+
c = pattern[i]
|
|
109
|
+
case c
|
|
110
|
+
when "*"
|
|
111
|
+
names << wildcard_idx.to_s
|
|
112
|
+
wildcard_idx += 1
|
|
113
|
+
regex_src << "(.*)"
|
|
114
|
+
i += 1
|
|
115
|
+
when ":"
|
|
116
|
+
name_match = pattern[i + 1..].match(/\A([A-Za-z_][A-Za-z0-9_]*)([?+*]?)/)
|
|
117
|
+
break unless name_match
|
|
118
|
+
|
|
119
|
+
name = name_match[1]
|
|
120
|
+
modifier = name_match[2]
|
|
121
|
+
names << name
|
|
122
|
+
regex_src <<
|
|
123
|
+
case modifier
|
|
124
|
+
when "+"
|
|
125
|
+
"([^/]+(?:/[^/]+)*)"
|
|
126
|
+
when "?"
|
|
127
|
+
"([^/]*)"
|
|
128
|
+
when "*"
|
|
129
|
+
"(.*)"
|
|
130
|
+
else
|
|
131
|
+
"([^/]+)"
|
|
132
|
+
end
|
|
133
|
+
i += 1 + name_match[0].length
|
|
134
|
+
else
|
|
135
|
+
regex_src << Regexp.escape(c)
|
|
136
|
+
i += 1
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
regex_src << "\\z"
|
|
141
|
+
{regex: Regexp.new(regex_src), names: names}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/dommy/version.rb
CHANGED