dommy 0.5.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +213 -0
  3. data/lib/dommy/attr.rb +200 -0
  4. data/lib/dommy/blob.rb +182 -0
  5. data/lib/dommy/bridge.rb +141 -0
  6. data/lib/dommy/css.rb +283 -0
  7. data/lib/dommy/custom_elements.rb +125 -0
  8. data/lib/dommy/data_transfer.rb +98 -0
  9. data/lib/dommy/document.rb +674 -0
  10. data/lib/dommy/dom_exception.rb +258 -0
  11. data/lib/dommy/dom_parser.rb +88 -0
  12. data/lib/dommy/element.rb +1975 -0
  13. data/lib/dommy/event.rb +589 -0
  14. data/lib/dommy/fetch.rb +241 -0
  15. data/lib/dommy/form_data.rb +208 -0
  16. data/lib/dommy/html_collection.rb +207 -0
  17. data/lib/dommy/html_elements.rb +4455 -0
  18. data/lib/dommy/internal/cookie_jar.rb +27 -0
  19. data/lib/dommy/internal/dom_matching.rb +141 -0
  20. data/lib/dommy/internal/mutation_coordinator.rb +172 -0
  21. data/lib/dommy/internal/node_traversal.rb +36 -0
  22. data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
  23. data/lib/dommy/internal/observer_manager.rb +31 -0
  24. data/lib/dommy/internal/observer_matcher.rb +31 -0
  25. data/lib/dommy/internal/scope_resolution.rb +27 -0
  26. data/lib/dommy/internal/shadow_root_registry.rb +35 -0
  27. data/lib/dommy/internal/template_content_registry.rb +97 -0
  28. data/lib/dommy/minitest/assertions.rb +105 -0
  29. data/lib/dommy/minitest.rb +17 -0
  30. data/lib/dommy/navigator.rb +271 -0
  31. data/lib/dommy/node.rb +218 -0
  32. data/lib/dommy/observer.rb +199 -0
  33. data/lib/dommy/parser.rb +29 -0
  34. data/lib/dommy/promise.rb +199 -0
  35. data/lib/dommy/router.rb +275 -0
  36. data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
  37. data/lib/dommy/rspec/matchers.rb +230 -0
  38. data/lib/dommy/rspec.rb +18 -0
  39. data/lib/dommy/scheduler.rb +135 -0
  40. data/lib/dommy/shadow_root.rb +255 -0
  41. data/lib/dommy/storage.rb +112 -0
  42. data/lib/dommy/test_helpers.rb +78 -0
  43. data/lib/dommy/tree_walker.rb +425 -0
  44. data/lib/dommy/url.rb +479 -0
  45. data/lib/dommy/version.rb +5 -0
  46. data/lib/dommy/world.rb +209 -0
  47. data/lib/dommy.rb +119 -0
  48. metadata +110 -0
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "internal/observer_matcher"
4
+
5
+ module Dommy
6
+ # MutationRecord — produced for childList, attributes, or
7
+ # characterData mutations and delivered to the observer callback.
8
+ # Mirrors the browser MutationRecord interface; `oldValue` is only
9
+ # populated when the observer asked for it via `attributeOldValue`
10
+ # / `characterDataOldValue`.
11
+ class MutationRecord
12
+ def initialize(
13
+ type:,
14
+ target:,
15
+ added_nodes: [],
16
+ removed_nodes: [],
17
+ previous_sibling: nil,
18
+ next_sibling: nil,
19
+ attribute_name: nil,
20
+ attribute_namespace: nil,
21
+ old_value: nil
22
+ )
23
+ @type = type
24
+ @target = target
25
+ @added_nodes = added_nodes
26
+ @removed_nodes = removed_nodes
27
+ @previous_sibling = previous_sibling
28
+ @next_sibling = next_sibling
29
+ @attribute_name = attribute_name
30
+ @attribute_namespace = attribute_namespace
31
+ @old_value = old_value
32
+ end
33
+
34
+ attr_reader(
35
+ :type,
36
+ :target,
37
+ :added_nodes,
38
+ :removed_nodes,
39
+ :previous_sibling,
40
+ :next_sibling,
41
+ :attribute_name,
42
+ :attribute_namespace,
43
+ :old_value
44
+ )
45
+
46
+ def __js_get__(key)
47
+ case key
48
+ when "type"
49
+ @type
50
+ when "target"
51
+ @target
52
+ when "addedNodes"
53
+ @added_nodes
54
+ when "removedNodes"
55
+ @removed_nodes
56
+ when "previousSibling"
57
+ @previous_sibling
58
+ when "nextSibling"
59
+ @next_sibling
60
+ when "attributeName"
61
+ @attribute_name
62
+ when "attributeNamespace"
63
+ @attribute_namespace
64
+ when "oldValue"
65
+ @old_value
66
+ end
67
+ end
68
+ end
69
+
70
+ class MutationObserver
71
+ def initialize(window, callback)
72
+ @window = window
73
+ @document = window.document
74
+ @callback = callback
75
+ @observed = []
76
+ @records = []
77
+ @scheduled = false
78
+ end
79
+
80
+ def __js_call__(method, args)
81
+ case method
82
+ when "observe"
83
+ observe(args[0], args[1])
84
+ when "disconnect"
85
+ disconnect
86
+ when "takeRecords"
87
+ take_records
88
+ end
89
+ end
90
+
91
+ # Matches a wrapped target against this observer's scope.
92
+ # Called by MutationCoordinator.
93
+ def matches_wrapped?(target_wrapped)
94
+ find_matching_entry(target_wrapped) != nil
95
+ end
96
+
97
+ # Find the observer entry that matches target_wrapped.
98
+ # Returns the entry with options (attributes, attributeFilter, etc.)
99
+ # or nil if target doesn't match any observed scope.
100
+ def find_matching_entry(target_wrapped)
101
+ matcher = Internal::ObserverMatcher.new
102
+ @observed.find do |entry|
103
+ observed_wrapped = entry[:target]
104
+ next false unless observed_wrapped
105
+
106
+ if observed_wrapped.is_a?(Document)
107
+ matcher.matches_document?(target_wrapped, subtree: entry[:subtree])
108
+ else
109
+ matcher.matches?(observed_wrapped, target_wrapped, subtree: entry[:subtree])
110
+ end
111
+ end
112
+ end
113
+
114
+ def enqueue(record)
115
+ @records << record
116
+ return nil if @scheduled
117
+
118
+ @scheduled = true
119
+ @window.scheduler.queue_microtask(proc { flush })
120
+ nil
121
+ end
122
+
123
+ # Public: introspection used by linkedom-style tests that peek at
124
+ # pending records without draining (`observer.records[0]`).
125
+ def records
126
+ @records.dup
127
+ end
128
+
129
+ private
130
+
131
+ def observe(target, options)
132
+ opts = options.is_a?(Hash) ? options : {}
133
+ attribute_filter = opts["attributeFilter"] || opts[:attributeFilter]
134
+ attribute_filter = attribute_filter.map { |s| s.to_s.downcase } if attribute_filter.is_a?(Array)
135
+ # `attributes: true` is implied if attributeFilter / attributeOldValue
136
+ # is supplied; `characterData: true` is implied if
137
+ # characterDataOldValue is supplied. Matches the spec's option
138
+ # normalization in MutationObserverInit.
139
+ attrs_implied = !attribute_filter.nil? || truthy_option(opts, "attributeOldValue")
140
+ char_implied = truthy_option(opts, "characterDataOldValue")
141
+ attributes_on = truthy_option(opts, "attributes") || attrs_implied
142
+ child_list_on = truthy_option(opts, "childList")
143
+ character_data_on = truthy_option(opts, "characterData") || char_implied
144
+
145
+ # Per spec, observe() must request at least one of childList,
146
+ # attributes, or characterData; otherwise TypeError.
147
+ unless child_list_on || attributes_on || character_data_on
148
+ raise TypeError, "MutationObserver.observe: at least one of childList, attributes, characterData must be true"
149
+ end
150
+
151
+ @observed <<
152
+ {
153
+ target: target,
154
+ child_list: child_list_on,
155
+ subtree: truthy_option(opts, "subtree"),
156
+ attributes: attributes_on,
157
+ attribute_filter: attribute_filter,
158
+ attribute_old_value: truthy_option(opts, "attributeOldValue"),
159
+ character_data: character_data_on,
160
+ character_data_old_value: truthy_option(opts, "characterDataOldValue")
161
+ }
162
+ @document.register_observer(self)
163
+ nil
164
+ end
165
+
166
+ def disconnect
167
+ @records.clear
168
+ @scheduled = false
169
+ @observed.clear
170
+ @document.unregister_observer(self)
171
+ nil
172
+ end
173
+
174
+ def take_records
175
+ out = @records.dup
176
+ @records.clear
177
+ @scheduled = false
178
+ out
179
+ end
180
+
181
+ def flush
182
+ @scheduled = false
183
+ return if @records.empty?
184
+
185
+ records = @records.dup
186
+ @records.clear
187
+ if @callback.respond_to?(:__js_call__)
188
+ @callback.__js_call__("call", [records])
189
+ elsif @callback.respond_to?(:call)
190
+ @callback.call(records)
191
+ end
192
+ end
193
+
194
+ def truthy_option(hash, key)
195
+ value = hash[key] || hash[key.to_sym]
196
+ value == true || value.to_s == "true"
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Dommy
6
+ # Thin wrapper around Nokogiri's HTML5 fragment parser. Pinned to
7
+ # `max_errors: 0` for silent recovery on malformed HTML (matching
8
+ # browser behavior).
9
+ #
10
+ # Known quirks: `<table>`-only fragments wrap children in an
11
+ # implicit `<tbody>`; `<select>` reparents non-option children
12
+ # outside itself.
13
+ #
14
+ # `owner_doc` is critical: when a node parsed via a detached
15
+ # fragment gets `add_child`'d into a Document with a different
16
+ # Nokogiri owner, libxml2 silently **copies** the node (new
17
+ # object_id) instead of moving it. That breaks identity-dependent
18
+ # caches (e.g. `Document#wrap_node` and any reconciler that keys
19
+ # off node identity). Always pass the destination document.
20
+ module Parser
21
+ def self.fragment(html, owner_doc: nil)
22
+ if owner_doc
23
+ owner_doc.fragment(html.to_s)
24
+ else
25
+ Nokogiri::HTML5.fragment(html.to_s, max_errors: 0)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ class ErrorValue
5
+ def initialize(message = nil, name: "Error")
6
+ @message = message.to_s
7
+ @name = name
8
+ end
9
+
10
+ def __js_get__(key)
11
+ case key
12
+ when "message"
13
+ @message
14
+ when "name"
15
+ @name
16
+ else
17
+ nil
18
+ end
19
+ end
20
+
21
+ def to_s
22
+ return @name if @message.empty?
23
+
24
+ "#{@name}: #{@message}"
25
+ end
26
+ end
27
+
28
+ # Note: `PromiseConstructor` and `PromiseSettler` live in
29
+ # `Dommy::Bridge::*` — they're bridge-adapter classes for the
30
+ # `JS.global[:Promise]` view, not part of the public DOM surface.
31
+
32
+ class PromiseValue
33
+ Handler = Struct.new(:on_fulfilled, :on_rejected, :child)
34
+
35
+ def self.resolve(window, value)
36
+ promise = new(window)
37
+ promise.fulfill(value)
38
+ promise
39
+ end
40
+
41
+ def self.reject(window, reason)
42
+ promise = new(window)
43
+ promise.reject(reason)
44
+ promise
45
+ end
46
+
47
+ def initialize(window)
48
+ @window = window
49
+ @state = :pending
50
+ @value = nil
51
+ @handlers = []
52
+ end
53
+
54
+ def __js_call__(method, args)
55
+ case method
56
+ when "then"
57
+ attach_then(args[0], args[1])
58
+ when "catch"
59
+ attach_then(nil, args[0])
60
+ else
61
+ nil
62
+ end
63
+ end
64
+
65
+ def fulfill(value)
66
+ settle(:fulfilled, value)
67
+ end
68
+
69
+ def reject(reason)
70
+ settle(:rejected, reason)
71
+ end
72
+
73
+ # Synchronously unwrap the promise's settled value, or raise its
74
+ # rejection. Dommy's scheduler is deterministic, so "wait" is
75
+ # spelled "drain queued microtasks then read the state."
76
+ #
77
+ # This is the bridge between dommy's async APIs (fetch, etc.) and
78
+ # Ruby tests that want to write straight-line code:
79
+ #
80
+ # response = win.__js_call__("fetch", [url]).await
81
+ # text = response.text
82
+ #
83
+ # Raises `RuntimeError` if the promise is still pending after a
84
+ # microtask drain — that's a sign that real-time work (e.g. a
85
+ # `setTimeout`) needs to advance via `advance_time` first.
86
+ def await
87
+ @window&.scheduler&.drain_microtasks
88
+
89
+ case @state
90
+ when :fulfilled
91
+ @value
92
+ when :rejected
93
+ raise unwrap_rejection(@value)
94
+ else
95
+ raise "Promise#await: still pending after microtask drain"
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def unwrap_rejection(value)
102
+ case value
103
+ when Exception
104
+ value
105
+ when ErrorValue
106
+ RuntimeError.new(value.to_s)
107
+ else
108
+ RuntimeError.new(value.to_s)
109
+ end
110
+ end
111
+
112
+ def attach_then(on_fulfilled, on_rejected)
113
+ child = self.class.new(@window)
114
+ @handlers << Handler.new(on_fulfilled, on_rejected, child)
115
+ schedule_flush if settled?
116
+ child
117
+ end
118
+
119
+ def settle(state, value)
120
+ return self if settled?
121
+
122
+ if value.is_a?(PromiseValue)
123
+ return adopt(value)
124
+ end
125
+
126
+ @state = state
127
+ @value = value
128
+ schedule_flush
129
+ self
130
+ end
131
+
132
+ def adopt(other)
133
+ other.__js_call__(
134
+ "then",
135
+ [
136
+ proc { |resolved| fulfill(resolved) },
137
+ proc { |reason| reject(reason) }
138
+ ]
139
+ )
140
+ self
141
+ end
142
+
143
+ def settled?
144
+ @state != :pending
145
+ end
146
+
147
+ def schedule_flush
148
+ @window.scheduler.queue_microtask(proc { flush_handlers })
149
+ nil
150
+ end
151
+
152
+ def flush_handlers
153
+ return unless settled?
154
+ return if @handlers.empty?
155
+
156
+ handlers = @handlers.dup
157
+ @handlers.clear
158
+ handlers.each do |handler|
159
+ run_handler(handler)
160
+ end
161
+ end
162
+
163
+ def run_handler(handler)
164
+ callback = @state == :fulfilled ? handler.on_fulfilled : handler.on_rejected
165
+ if callback.nil?
166
+ propagate(handler.child)
167
+ return
168
+ end
169
+
170
+ result = invoke_callback(callback, @value)
171
+ if result.is_a?(PromiseValue)
172
+ result.__js_call__(
173
+ "then",
174
+ [
175
+ proc { |resolved| handler.child.fulfill(resolved) },
176
+ proc { |reason| handler.child.reject(reason) }
177
+ ]
178
+ )
179
+ else
180
+ handler.child.fulfill(result)
181
+ end
182
+
183
+ rescue StandardError => e
184
+ handler.child.reject(ErrorValue.new(e.message, name: e.class.to_s))
185
+ end
186
+
187
+ def propagate(child)
188
+ @state == :fulfilled ? child.fulfill(@value) : child.reject(@value)
189
+ end
190
+
191
+ def invoke_callback(callback, value)
192
+ if callback.respond_to?(:__js_call__)
193
+ callback.__js_call__("call", [value])
194
+ else
195
+ callback.call(value)
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Dommy
6
+ # `window.location` polyfill. The Window owns one Location and one
7
+ # History instance, and they share the same underlying state. Hash
8
+ # / pushState / replaceState all flow through `__set_url__`.
9
+ class Location
10
+ def initialize(window, origin: "http://localhost", pathname: "/", search: "", hash: "")
11
+ @window = window
12
+ @origin = origin
13
+ @pathname = pathname
14
+ @search = search
15
+ @hash = hash
16
+ end
17
+
18
+ def __js_get__(key)
19
+ case key
20
+ when "origin"
21
+ @origin
22
+ when "pathname"
23
+ @pathname
24
+ when "search"
25
+ @search
26
+ when "hash"
27
+ @hash
28
+ when "href"
29
+ href
30
+ when "host"
31
+ URI(@origin).host || ""
32
+ when "hostname"
33
+ URI(@origin).host || ""
34
+ when "protocol"
35
+ URI(@origin).scheme ? "#{URI(@origin).scheme}:" : ""
36
+ when "port"
37
+ (URI(@origin).port || 80).to_s
38
+ end
39
+ end
40
+
41
+ def __js_set__(key, value)
42
+ case key
43
+ when "href"
44
+ __set_url__(value.to_s)
45
+ when "hash"
46
+ new_hash = value.to_s
47
+ new_hash = "##{new_hash}" unless new_hash.empty? || new_hash.start_with?("#")
48
+ previous = @hash
49
+ @hash = new_hash
50
+ @window.fire_hashchange(previous, @hash) if previous != @hash
51
+ when "pathname"
52
+ @pathname = value.to_s
53
+ when "search"
54
+ s = value.to_s
55
+ @search = s.empty? || s.start_with?("?") ? s : "?#{s}"
56
+ when "host"
57
+ # `host` is "hostname[:port]" — split and update origin.
58
+ update_origin_host(value.to_s)
59
+ when "hostname"
60
+ update_origin_hostname(value.to_s)
61
+ when "port"
62
+ update_origin_port(value.to_s)
63
+ when "protocol"
64
+ update_origin_protocol(value.to_s)
65
+ end
66
+ end
67
+
68
+ def __js_call__(method, args)
69
+ case method
70
+ when "assign", "replace"
71
+ __set_url__(args[0].to_s)
72
+ when "reload"
73
+ nil
74
+ when "toString"
75
+ href
76
+ end
77
+ end
78
+
79
+ def href
80
+ "#{@origin}#{@pathname}#{@search}#{@hash}"
81
+ end
82
+
83
+ # Internal — accepts an absolute or relative URL string and
84
+ # updates pathname / search / hash. Called by History pushState /
85
+ # replaceState and by `location.href = ...`.
86
+ def __set_url__(raw)
87
+ previous_hash = @hash
88
+ if raw.start_with?("#")
89
+ @hash = raw
90
+ else
91
+ uri = URI.join(@origin + @pathname + @search + @hash, raw) rescue URI(raw)
92
+ @pathname = uri.path.to_s == "" ? "/" : uri.path
93
+ @search = uri.query ? "?#{uri.query}" : ""
94
+ @hash = uri.fragment ? "##{uri.fragment}" : ""
95
+ end
96
+
97
+ @window.fire_hashchange(previous_hash, @hash) if previous_hash != @hash
98
+ end
99
+
100
+ private
101
+
102
+ def origin_parts
103
+ uri = URI(@origin)
104
+ {scheme: uri.scheme, host: uri.host, port: uri.port}
105
+ rescue URI::InvalidURIError, ArgumentError
106
+ {scheme: "http", host: "localhost", port: 80}
107
+ end
108
+
109
+ def rebuild_origin(scheme:, host:, port:)
110
+ default_port = (scheme == "https" ? 443 : 80)
111
+ port_segment = (port && port != default_port) ? ":#{port}" : ""
112
+ @origin = "#{scheme}://#{host}#{port_segment}"
113
+ end
114
+
115
+ def update_origin_host(value)
116
+ hostname, port = value.split(":", 2)
117
+ parts = origin_parts
118
+ rebuild_origin(scheme: parts[:scheme], host: hostname, port: port&.to_i || parts[:port])
119
+ end
120
+
121
+ def update_origin_hostname(value)
122
+ parts = origin_parts
123
+ rebuild_origin(scheme: parts[:scheme], host: value, port: parts[:port])
124
+ end
125
+
126
+ def update_origin_port(value)
127
+ parts = origin_parts
128
+ rebuild_origin(scheme: parts[:scheme], host: parts[:host], port: value.to_i)
129
+ end
130
+
131
+ def update_origin_protocol(value)
132
+ parts = origin_parts
133
+ scheme = value.to_s.sub(/:\z/, "")
134
+ rebuild_origin(scheme: scheme, host: parts[:host], port: parts[:port])
135
+ end
136
+ end
137
+
138
+ # `window.history` polyfill. Stack-based; back/forward move the
139
+ # cursor. pushState appends; replaceState mutates the current entry.
140
+ # Each entry is `{ state:, url: }`. Popstate fires when back /
141
+ # forward triggers a different cursor (not on pushState per spec).
142
+ class History
143
+ def initialize(window, location)
144
+ @window = window
145
+ @location = location
146
+ # Initial entry mirrors the live Location. Bookmark URL is
147
+ # resynthesized lazily from Location each time we read it.
148
+ @stack = [{state: nil, url: nil}]
149
+ @cursor = 0
150
+ @scroll_restoration = "auto"
151
+ end
152
+
153
+ def __js_get__(key)
154
+ case key
155
+ when "length"
156
+ @stack.size
157
+ when "state"
158
+ @stack[@cursor][:state]
159
+ when "scrollRestoration"
160
+ @scroll_restoration
161
+ end
162
+ end
163
+
164
+ def __js_set__(key, value)
165
+ case key
166
+ when "scrollRestoration"
167
+ # Per spec, only "auto" and "manual" are accepted. Invalid
168
+ # values silently retain the current value.
169
+ v = value.to_s
170
+ @scroll_restoration = v if %w[auto manual].include?(v)
171
+ end
172
+
173
+ nil
174
+ end
175
+
176
+ def __js_call__(method, args)
177
+ case method
178
+ when "pushState"
179
+ push(args[0], args[2])
180
+ when "replaceState"
181
+ replace(args[0], args[2])
182
+ when "back"
183
+ go(-1)
184
+ when "forward"
185
+ go(1)
186
+ when "go"
187
+ go((args[0] || 0).to_i)
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ def push(state, url)
194
+ @stack = @stack[0..@cursor]
195
+ @location.__set_url__(url.to_s) if url
196
+ @stack << {state: state, url: nil}
197
+ @cursor = @stack.size - 1
198
+ end
199
+
200
+ def replace(state, url)
201
+ @location.__set_url__(url.to_s) if url
202
+ @stack[@cursor] = {state: state, url: nil}
203
+ end
204
+
205
+ def go(delta)
206
+ target = @cursor + delta
207
+ return if target < 0 || target >= @stack.size
208
+
209
+ @cursor = target
210
+ @window.fire_popstate(@stack[@cursor][:state])
211
+ end
212
+ end
213
+
214
+ # `URL` constructor — Ruby `URI` wrap. Browser URL API surface:
215
+ # `[:origin]`, `[:pathname]`, `[:search]`, `[:hash]`, `[:href]`.
216
+ # Supports the two-arg form `new URL(raw, base)`.
217
+ class Url
218
+ def initialize(raw, base = nil)
219
+ uri = if base && !base.empty?
220
+ URI.join(base, raw)
221
+ else
222
+ URI(raw.to_s)
223
+ end
224
+
225
+ @origin = origin_of(uri)
226
+ @pathname = uri.path.to_s == "" ? "/" : uri.path
227
+ @search = uri.query ? "?#{uri.query}" : ""
228
+ @hash = uri.fragment ? "##{uri.fragment}" : ""
229
+ @href = uri.to_s
230
+ rescue URI::InvalidURIError, ArgumentError
231
+ @origin = ""
232
+ @pathname = ""
233
+ @search = ""
234
+ @hash = ""
235
+ @href = raw.to_s
236
+ end
237
+
238
+ def __js_get__(key)
239
+ case key
240
+ when "origin"
241
+ @origin
242
+ when "pathname"
243
+ @pathname
244
+ when "search"
245
+ @search
246
+ when "hash"
247
+ @hash
248
+ when "href"
249
+ @href
250
+ when "toString"
251
+ @href
252
+ end
253
+ end
254
+
255
+ def __js_set__(_key, _value)
256
+ nil
257
+ end
258
+
259
+ def __js_call__(method, _args)
260
+ method == "toString" ? @href : nil
261
+ end
262
+
263
+ private
264
+
265
+ def origin_of(uri)
266
+ scheme = uri.scheme
267
+ host = uri.host
268
+ return "" unless scheme && host
269
+
270
+ port = uri.port
271
+ default = (scheme == "https" ? 443 : 80)
272
+ port == default ? "#{scheme}://#{host}" : "#{scheme}://#{host}:#{port}"
273
+ end
274
+ end
275
+ end