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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -13
  3. data/lib/dommy/animation.rb +288 -0
  4. data/lib/dommy/attr.rb +23 -11
  5. data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
  7. data/lib/dommy/backend.rb +129 -0
  8. data/lib/dommy/blob.rb +2 -2
  9. data/lib/dommy/compression_streams.rb +147 -0
  10. data/lib/dommy/cookie_store.rb +128 -0
  11. data/lib/dommy/crypto.rb +396 -0
  12. data/lib/dommy/css.rb +7 -7
  13. data/lib/dommy/custom_elements.rb +6 -6
  14. data/lib/dommy/document.rb +190 -32
  15. data/lib/dommy/dom_parser.rb +5 -4
  16. data/lib/dommy/element.rb +356 -53
  17. data/lib/dommy/event.rb +431 -25
  18. data/lib/dommy/event_source.rb +131 -0
  19. data/lib/dommy/fetch.rb +76 -6
  20. data/lib/dommy/file_reader.rb +176 -0
  21. data/lib/dommy/form_data.rb +1 -3
  22. data/lib/dommy/history.rb +82 -0
  23. data/lib/dommy/html_collection.rb +4 -4
  24. data/lib/dommy/html_elements.rb +130 -67
  25. data/lib/dommy/internal/cookie_jar.rb +2 -0
  26. data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
  27. data/lib/dommy/internal/dom_matching.rb +4 -4
  28. data/lib/dommy/internal/idna.rb +443 -0
  29. data/lib/dommy/internal/idna_data.rb +10379 -0
  30. data/lib/dommy/internal/ipv4_parser.rb +78 -0
  31. data/lib/dommy/internal/node_traversal.rb +1 -1
  32. data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
  33. data/lib/dommy/internal/observable_callback.rb +25 -0
  34. data/lib/dommy/internal/punycode.rb +202 -0
  35. data/lib/dommy/internal/range_text_serializer.rb +72 -0
  36. data/lib/dommy/internal/reflected_attributes.rb +45 -0
  37. data/lib/dommy/internal/template_content_registry.rb +6 -6
  38. data/lib/dommy/intersection_observer.rb +82 -0
  39. data/lib/dommy/{router.rb → location.rb} +8 -142
  40. data/lib/dommy/media_query_list.rb +118 -0
  41. data/lib/dommy/message_channel.rb +249 -0
  42. data/lib/dommy/{observer.rb → mutation_observer.rb} +21 -11
  43. data/lib/dommy/navigator.rb +365 -5
  44. data/lib/dommy/node.rb +12 -0
  45. data/lib/dommy/notification.rb +89 -0
  46. data/lib/dommy/parser.rb +13 -13
  47. data/lib/dommy/performance.rb +146 -0
  48. data/lib/dommy/performance_observer.rb +55 -0
  49. data/lib/dommy/range.rb +597 -0
  50. data/lib/dommy/resize_observer.rb +53 -0
  51. data/lib/dommy/shadow_root.rb +10 -8
  52. data/lib/dommy/streams.rb +386 -0
  53. data/lib/dommy/svg_elements.rb +3863 -0
  54. data/lib/dommy/text_codec.rb +175 -0
  55. data/lib/dommy/tree_walker.rb +21 -21
  56. data/lib/dommy/url.rb +274 -29
  57. data/lib/dommy/url_pattern.rb +144 -0
  58. data/lib/dommy/version.rb +1 -1
  59. data/lib/dommy/web_socket.rb +209 -0
  60. data/lib/dommy/window.rb +369 -0
  61. data/lib/dommy/worker.rb +143 -0
  62. data/lib/dommy/xml_http_request.rb +438 -0
  63. data/lib/dommy.rb +43 -5
  64. metadata +44 -29
  65. 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.__resolve_blob_url__(url)`. Returns nil for non-Blob input.
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 __resolve_blob_url__(url)
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 __reset_blob_urls__
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.__replace__(raw.query.to_s)
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.default_port
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
- @uri.host = value.to_s
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
- return "" if @uri.port.nil? || @uri.port == @uri.default_port
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.path.to_s
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 = @search_params.to_s
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
- @search_params.__replace__(q)
145
- sync_uri_query
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
- return "null" unless @uri.scheme && @uri.host
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
- port_part = (@uri.port && @uri.port != @uri.default_port) ? ":#{@uri.port}" : ""
162
- "#{@uri.scheme}://#{@uri.host}#{port_part}"
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 __notify_params_changed__
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
- base_uri = base.is_a?(URL) ? URI.parse(base.href) : URI.parse(base.to_s)
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
- if @uri.host
282
- out << "//"
283
- if @uri.user
284
- out << @uri.user
285
- out << ":#{@uri.password}" if @uri.password
286
- out << "@"
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
- out << @uri.host
290
- out << ":#{@uri.port}" if @uri.port && @uri.port != @uri.default_port
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 __replace__(query_string)
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&.__notify_params_changed__
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dommy
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end