Class: Dommy::URL

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/url.rb

Overview

‘URL` — WHATWG-style URL parsing. Public API mirrors the JS class:

u = Dommy::URL.new("https://x.test/a/b?k=v#h")
u.protocol  # "https:"
u.host      # "x.test"
u.pathname  # "/a/b"
u.search    # "?k=v"
u.hash      # "#h"
u.search_params.get("k")  # "v"

Construction with a base URL is supported for relative inputs:

Dommy::URL.new("/a", "https://x.test").href
  # => "https://x.test/a"

Internally backed by Ruby’s URI library — good enough for the common test cases. Edge cases that URI rejects raise ‘DOMException::SyntaxError` (called `TypeError` in JS but Dommy uses the closest WHATWG name).

Constant Summary collapse

SPECIAL_SCHEMES =
%w[http https ws wss ftp file].freeze
TUPLE_ORIGIN_SCHEMES =

WHATWG: only http(s) / ws(s) / ftp produce a tuple origin. file / data / javascript / etc. resolve to ‘“null”`. `blob:` is handled specially (inner-URL origin).

%w[http https ws wss ftp].freeze
DEFAULT_PORTS =

Default ports per scheme (Ruby URI knows http/https/ftp; we add ws/wss).

{
  "http" => 80,
  "https" => 443,
  "ws" => 80,
  "wss" => 443,
  "ftp" => 21
}.freeze
UNSAFE_PATH_CHARS =

Chars that Ruby URI rejects in the path/query/fragment portion but WHATWG silently percent-encodes.

/[ "<>`{}|\\\^\[\]]/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(input, base = nil) ⇒ URL

Returns a new instance of URL.



85
86
87
88
89
# File 'lib/dommy/url.rb', line 85

def initialize(input, base = nil)
  raw = parse_with_base(input, base)
  @uri = raw
  @search_params = URLSearchParams.new(raw.query.to_s, owner: self)
end

Instance Attribute Details

#search_paramsObject (readonly)

Returns the value of attribute search_params.



83
84
85
# File 'lib/dommy/url.rb', line 83

def search_params
  @search_params
end

Class Method Details

.__test_reset_blob_urls__Object

Test seam: drop all registered blob URLs.



61
62
63
# File 'lib/dommy/url.rb', line 61

def __test_reset_blob_urls__
  @blob_urls.clear
end

.__test_resolve_blob_url__(url) ⇒ Object

Resolve a blob: URL back to its Blob, or nil if revoked / unknown. Internal — used by fetch / XHR implementations that load blob URLs.



56
57
58
# File 'lib/dommy/url.rb', line 56

def __test_resolve_blob_url__(url)
  @blob_urls[url.to_s]
end

.can_parse(input, base = nil) ⇒ Object

WHATWG URL Standard — ‘URL.canParse(input, base)`. Boolean counterpart to `parse`: lets callers peek at validity without rescuing an exception or holding a URL reference.



78
79
80
# File 'lib/dommy/url.rb', line 78

def can_parse(input, base = nil)
  !parse(input, base).nil?
end

.create_object_url(blob) ⇒ Object Also known as: createObjectURL

Create a unique blob: URL that resolves back to ‘blob` via `URL.test_resolve_blob_url(url)`. Returns nil for non-Blob input.



34
35
36
37
38
39
40
41
# File 'lib/dommy/url.rb', line 34

def create_object_url(blob)
  return nil unless blob.is_a?(Blob)

  id = "%032x" % rand(2 ** 128)
  url = "blob:dommy/#{id}"
  @blob_urls[url] = blob
  url
end

.parse(input, base = nil) ⇒ Object

WHATWG URL Standard — ‘URL.parse(input, base)` is the non-throwing static factory. Returns a URL on success, `nil` on parse failure. The constructor (`new URL(…)`) raises `SyntaxError` for the same failure case.



69
70
71
72
73
# File 'lib/dommy/url.rb', line 69

def parse(input, base = nil)
  new(input, base)
rescue DOMException::SyntaxError
  nil
end

.revoke_object_url(url) ⇒ Object Also known as: revokeObjectURL

Revoke a previously-created blob URL. No-op for unknown URLs, matching the spec.



47
48
49
50
# File 'lib/dommy/url.rb', line 47

def revoke_object_url(url)
  @blob_urls.delete(url.to_s)
  nil
end

Instance Method Details

#__internal_notify_params_changed__Object

Called by URLSearchParams when it mutates; we need to keep the underlying URI’s query string in sync so subsequent ‘href` is accurate.



309
310
311
# File 'lib/dommy/url.rb', line 309

def __internal_notify_params_changed__
  sync_uri_query
end

#__js_call__(method, _args) ⇒ Object



299
300
301
302
303
304
# File 'lib/dommy/url.rb', line 299

def __js_call__(method, _args)
  case method
  when "toString", "toJSON"
    href
  end
end

#__js_get__(key) ⇒ Object



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/dommy/url.rb', line 243

def __js_get__(key)
  case key
  when "href"
    href
  when "protocol"
    protocol
  when "host"
    host
  when "hostname"
    hostname
  when "port"
    port
  when "pathname"
    pathname
  when "search"
    search
  when "hash"
    hash
  when "origin"
    origin
  when "username"
    username
  when "password"
    password
  when "searchParams"
    @search_params
  end
end

#__js_set__(key, value) ⇒ Object



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/dommy/url.rb', line 272

def __js_set__(key, value)
  case key
  when "href"
    self.href = value
  when "protocol"
    self.protocol = value
  when "host"
    self.host = value
  when "hostname"
    self.hostname = value
  when "port"
    self.port = value
  when "pathname"
    self.pathname = value
  when "search"
    self.search = value
  when "hash"
    self.hash = value
  when "username"
    self.username = value
  when "password"
    self.password = value
  end

  nil
end

#blob_inner_originObject



206
207
208
209
210
211
212
213
214
215
216
# File 'lib/dommy/url.rb', line 206

def blob_inner_origin
  # `blob:<inner-url>` — the body after `blob:` is itself a URL
  # whose origin we adopt. Anything that fails to parse falls
  # back to "null".
  opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
  return "null" if opaque.nil? || opaque.empty?

  URL.new(opaque).origin
rescue DOMException::SyntaxError
  "null"
end

#hashObject



182
183
184
185
# File 'lib/dommy/url.rb', line 182

def hash
  f = @uri.fragment.to_s
  f.empty? ? "" : "##{f}"
end

#hash=(value) ⇒ Object



187
188
189
190
# File 'lib/dommy/url.rb', line 187

def hash=(value)
  f = value.to_s.sub(/^#/, "")
  @uri.fragment = f.empty? ? nil : f
end

#hostObject



111
112
113
114
115
116
117
118
# File 'lib/dommy/url.rb', line 111

def host
  port = @uri.port
  default = default_port_for(@uri.scheme.to_s.downcase)
  hostpart = @uri.host.to_s
  return hostpart if port.nil? || port == default

  "#{hostpart}:#{port}"
end

#host=(value) ⇒ Object



120
121
122
123
124
# File 'lib/dommy/url.rb', line 120

def host=(value)
  h, p = value.to_s.split(":", 2)
  @uri.host = h
  @uri.port = p.to_i if p
end

#hostnameObject



126
127
128
# File 'lib/dommy/url.rb', line 126

def hostname
  @uri.host.to_s
end

#hostname=(value) ⇒ Object



130
131
132
133
134
# File 'lib/dommy/url.rb', line 130

def hostname=(value)
  # WHATWG: a non-ASCII hostname assigned through the setter is
  # Punycode-encoded before storage (matches `new URL("...")`).
  @uri.host = Internal::IDNA.to_ascii(value.to_s)
end

#hrefObject



91
92
93
# File 'lib/dommy/url.rb', line 91

def href
  build_href
end

#href=(value) ⇒ Object



95
96
97
98
99
100
# File 'lib/dommy/url.rb', line 95

def href=(value)
  raw = parse_with_base(value.to_s, nil)
  @uri = raw
  @search_params.__internal_replace__(raw.query.to_s)
  build_href
end

#originObject

WHATWG URL §origin. Tuple origins for http(s) / ws(s) / ftp; ‘“null”` for file/data/javascript/etc. Blob URLs unwrap their inner URL recursively.



195
196
197
198
199
200
201
202
203
204
# File 'lib/dommy/url.rb', line 195

def origin
  scheme = @uri.scheme.to_s.downcase
  return blob_inner_origin if scheme == "blob"
  return "null" unless TUPLE_ORIGIN_SCHEMES.include?(scheme)
  return "null" unless @uri.host

  default = default_port_for(scheme)
  port_part = (@uri.port && @uri.port != default) ? ":#{@uri.port}" : ""
  "#{scheme}://#{@uri.host}#{port_part}"
end

#passwordObject



226
227
228
# File 'lib/dommy/url.rb', line 226

def password
  @uri.password.to_s
end

#password=(value) ⇒ Object



230
231
232
# File 'lib/dommy/url.rb', line 230

def password=(value)
  @uri.password = value.to_s.empty? ? nil : value.to_s
end

#pathnameObject

WHATWG: for opaque-body schemes (javascript:, mailto:, data:, tel:, blob:) the body sits in ‘URI`’s ‘opaque` slot, not `path`. For special schemes (http/https/ws/wss/ftp), an empty path is canonicalized to `“/”`.



151
152
153
154
155
156
157
158
159
# File 'lib/dommy/url.rb', line 151

def pathname
  opaque = @uri.respond_to?(:opaque) ? @uri.opaque : nil
  return opaque.to_s if opaque

  path = @uri.path.to_s
  return "/" if path.empty? && special_scheme?

  path
end

#pathname=(value) ⇒ Object



161
162
163
164
165
# File 'lib/dommy/url.rb', line 161

def pathname=(value)
  v = value.to_s
  v = "/#{v}" if !v.start_with?("/") && !v.empty?
  @uri.path = v
end

#portObject



136
137
138
139
140
141
# File 'lib/dommy/url.rb', line 136

def port
  default = default_port_for(@uri.scheme.to_s.downcase)
  return "" if @uri.port.nil? || @uri.port == default

  @uri.port.to_s
end

#port=(value) ⇒ Object



143
144
145
# File 'lib/dommy/url.rb', line 143

def port=(value)
  @uri.port = value.to_s.empty? ? nil : value.to_i
end

#protocolObject



102
103
104
# File 'lib/dommy/url.rb', line 102

def protocol
  @uri.scheme ? "#{@uri.scheme}:" : ""
end

#protocol=(value) ⇒ Object



106
107
108
109
# File 'lib/dommy/url.rb', line 106

def protocol=(value)
  s = value.to_s.sub(/:$/, "")
  @uri.scheme = s
end

#searchObject

WHATWG: ‘url.search` is the raw query string (with `?` prefix), preserving percent-encoding and stray `?` characters as parsed. `url.searchParams.toString()` re-serializes via the form-encoded contract (`+` for space, etc.) — distinct from `url.search`.



171
172
173
174
# File 'lib/dommy/url.rb', line 171

def search
  q = @uri.query
  q.nil? || q.empty? ? "" : "?#{q}"
end

#search=(value) ⇒ Object



176
177
178
179
180
# File 'lib/dommy/url.rb', line 176

def search=(value)
  q = value.to_s.sub(/^\?/, "")
  @uri.query = q.empty? ? nil : q
  @search_params.__internal_replace__(q)
end

#to_json(*_args) ⇒ Object



238
239
240
241
# File 'lib/dommy/url.rb', line 238

def to_json(*_args)
  # match JSON.stringify(url) -> "\"<href>\""
  href.inspect
end

#to_sObject



234
235
236
# File 'lib/dommy/url.rb', line 234

def to_s
  href
end

#usernameObject



218
219
220
# File 'lib/dommy/url.rb', line 218

def username
  @uri.user.to_s
end

#username=(value) ⇒ Object



222
223
224
# File 'lib/dommy/url.rb', line 222

def username=(value)
  @uri.user = value.to_s.empty? ? nil : value.to_s
end