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.
- checksums.yaml +7 -0
- data/README.md +213 -0
- data/lib/dommy/attr.rb +200 -0
- data/lib/dommy/blob.rb +182 -0
- data/lib/dommy/bridge.rb +141 -0
- data/lib/dommy/css.rb +283 -0
- data/lib/dommy/custom_elements.rb +125 -0
- data/lib/dommy/data_transfer.rb +98 -0
- data/lib/dommy/document.rb +674 -0
- data/lib/dommy/dom_exception.rb +258 -0
- data/lib/dommy/dom_parser.rb +88 -0
- data/lib/dommy/element.rb +1975 -0
- data/lib/dommy/event.rb +589 -0
- data/lib/dommy/fetch.rb +241 -0
- data/lib/dommy/form_data.rb +208 -0
- data/lib/dommy/html_collection.rb +207 -0
- data/lib/dommy/html_elements.rb +4455 -0
- data/lib/dommy/internal/cookie_jar.rb +27 -0
- data/lib/dommy/internal/dom_matching.rb +141 -0
- data/lib/dommy/internal/mutation_coordinator.rb +172 -0
- data/lib/dommy/internal/node_traversal.rb +36 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
- data/lib/dommy/internal/observer_manager.rb +31 -0
- data/lib/dommy/internal/observer_matcher.rb +31 -0
- data/lib/dommy/internal/scope_resolution.rb +27 -0
- data/lib/dommy/internal/shadow_root_registry.rb +35 -0
- data/lib/dommy/internal/template_content_registry.rb +97 -0
- data/lib/dommy/minitest/assertions.rb +105 -0
- data/lib/dommy/minitest.rb +17 -0
- data/lib/dommy/navigator.rb +271 -0
- data/lib/dommy/node.rb +218 -0
- data/lib/dommy/observer.rb +199 -0
- data/lib/dommy/parser.rb +29 -0
- data/lib/dommy/promise.rb +199 -0
- data/lib/dommy/router.rb +275 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
- data/lib/dommy/rspec/matchers.rb +230 -0
- data/lib/dommy/rspec.rb +18 -0
- data/lib/dommy/scheduler.rb +135 -0
- data/lib/dommy/shadow_root.rb +255 -0
- data/lib/dommy/storage.rb +112 -0
- data/lib/dommy/test_helpers.rb +78 -0
- data/lib/dommy/tree_walker.rb +425 -0
- data/lib/dommy/url.rb +479 -0
- data/lib/dommy/version.rb +5 -0
- data/lib/dommy/world.rb +209 -0
- data/lib/dommy.rb +119 -0
- 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
|
data/lib/dommy/parser.rb
ADDED
|
@@ -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
|
data/lib/dommy/router.rb
ADDED
|
@@ -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
|