dommy 0.9.0 → 0.10.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/lib/dommy/attr.rb +46 -8
- data/lib/dommy/backend/makiri_adapter.rb +34 -0
- data/lib/dommy/backend.rb +29 -0
- data/lib/dommy/blob.rb +48 -6
- data/lib/dommy/browser.rb +239 -36
- data/lib/dommy/callable_invoker.rb +6 -2
- data/lib/dommy/document.rb +525 -57
- data/lib/dommy/element.rb +479 -239
- data/lib/dommy/event.rb +296 -15
- data/lib/dommy/fetch.rb +431 -25
- data/lib/dommy/history.rb +24 -3
- data/lib/dommy/html_collection.rb +145 -21
- data/lib/dommy/html_elements.rb +1675 -266
- data/lib/dommy/interaction/driver.rb +155 -14
- data/lib/dommy/interaction/event_synthesis.rb +115 -7
- data/lib/dommy/interaction/field_interactor.rb +60 -0
- data/lib/dommy/internal/child_node.rb +199 -0
- data/lib/dommy/internal/css/cascade.rb +44 -2
- data/lib/dommy/internal/global_functions.rb +23 -10
- data/lib/dommy/internal/mutation_coordinator.rb +27 -7
- data/lib/dommy/internal/node_wrapper_cache.rb +56 -14
- data/lib/dommy/internal/observer_manager.rb +6 -0
- data/lib/dommy/internal/observer_matcher.rb +6 -8
- data/lib/dommy/internal/parent_node.rb +37 -73
- data/lib/dommy/internal/selector_matcher.rb +89 -0
- data/lib/dommy/internal/selector_parser.rb +44 -7
- data/lib/dommy/js/custom_element_bridge.rb +13 -2
- data/lib/dommy/js/dom_interfaces.rb +19 -4
- data/lib/dommy/js/host_bridge.rb +21 -0
- data/lib/dommy/js/host_runtime.js +926 -64
- data/lib/dommy/js/script_boot.rb +80 -0
- data/lib/dommy/location.rb +76 -13
- data/lib/dommy/mutation_observer.rb +3 -4
- data/lib/dommy/navigation.rb +263 -0
- data/lib/dommy/node.rb +180 -27
- data/lib/dommy/range.rb +15 -3
- data/lib/dommy/scheduler.rb +16 -0
- data/lib/dommy/shadow_root.rb +33 -0
- data/lib/dommy/storage.rb +61 -10
- data/lib/dommy/tree_walker.rb +18 -36
- data/lib/dommy/url.rb +4 -2
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +45 -2
- data/lib/dommy/window.rb +126 -7
- data/lib/dommy/xml_http_request.rb +30 -4
- data/lib/dommy.rb +2 -0
- metadata +6 -10
|
@@ -1,32 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "cgi"
|
|
4
|
-
require "erb"
|
|
5
3
|
require "base64"
|
|
6
4
|
|
|
5
|
+
require_relative "url_parser"
|
|
6
|
+
|
|
7
7
|
module Dommy
|
|
8
8
|
module Internal
|
|
9
9
|
# Stateless global functions exposed on the JS global (Window) that don't
|
|
10
|
-
# depend on any window state.
|
|
11
|
-
# dependency just for URI component encoding.
|
|
10
|
+
# depend on any window state.
|
|
12
11
|
module GlobalFunctions
|
|
13
12
|
module_function
|
|
14
13
|
|
|
15
|
-
# JS
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# WebIDL DOMString conversion of a JS value: null → "null", undefined →
|
|
15
|
+
# "undefined", booleans → "true"/"false", everything else its string form.
|
|
16
|
+
# (Ruby's `nil.to_s` is "", which would silently drop a `btoa(null)` arg.)
|
|
17
|
+
def js_domstring(value)
|
|
18
|
+
return "null" if value.nil?
|
|
19
|
+
return "undefined" if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
|
|
20
|
+
|
|
21
|
+
value.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# JS `encodeURIComponent`: UTF-8 percent-encode everything except the
|
|
25
|
+
# unreserved marks `A-Za-z0-9 - _ . ! ~ * ' ( )`. (Neither
|
|
26
|
+
# `ERB::Util.url_encode` nor `CGI.escape` matches this set — the former
|
|
27
|
+
# encodes `!~*'()`, the latter uses `+` for space.)
|
|
18
28
|
def encode_uri_component(value)
|
|
19
|
-
|
|
29
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-_.!~*'()]/n) { |c| format("%%%02X", c.ord) }
|
|
20
30
|
end
|
|
21
31
|
|
|
32
|
+
# JS `decodeURIComponent`: percent-decode only — unlike form-urlencoded
|
|
33
|
+
# decoding, "+" stays a literal "+". Malformed UTF-8 becomes U+FFFD
|
|
34
|
+
# (a real engine throws URIError; nothing downstream relies on that).
|
|
22
35
|
def decode_uri_component(value)
|
|
23
|
-
|
|
36
|
+
UrlParser.percent_decode(value.to_s).force_encoding(Encoding::UTF_8).scrub("\u{FFFD}")
|
|
24
37
|
end
|
|
25
38
|
|
|
26
39
|
# JS `btoa`: base64-encode a binary (Latin1) string. Each code unit must be
|
|
27
40
|
# 0..255; anything beyond Latin1 is an InvalidCharacterError (per spec).
|
|
28
41
|
def btoa(value)
|
|
29
|
-
codepoints = value.
|
|
42
|
+
codepoints = js_domstring(value).codepoints
|
|
30
43
|
if codepoints.any? { |c| c > 0xFF }
|
|
31
44
|
raise DOMException::InvalidCharacterError.new(
|
|
32
45
|
"Failed to execute 'btoa': characters outside the Latin1 range cannot be base64-encoded."
|
|
@@ -144,14 +144,23 @@ module Dommy
|
|
|
144
144
|
nk.children.each { |c| notify_disconnected_subtree(c) } if nk.respond_to?(:children)
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
-
def notify_attribute_changed(element, name, old_value, new_value)
|
|
147
|
+
def notify_attribute_changed(element, name, old_value, new_value, namespace = nil)
|
|
148
148
|
return unless element&.respond_to?(:attribute_changed_callback)
|
|
149
149
|
|
|
150
150
|
klass = element.class
|
|
151
151
|
return unless klass.respond_to?(:observed_attributes)
|
|
152
152
|
return unless klass.observed_attributes.include?(name.to_s.downcase)
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
# attributeChangedCallback's 4th arg is the attribute's namespace (null
|
|
155
|
+
# for a plain HTML attribute). Pass it only to callbacks that accept it
|
|
156
|
+
# (the JS bridge, or a 4-arg Ruby callback) so existing 3-arg Ruby custom
|
|
157
|
+
# elements keep working.
|
|
158
|
+
cb = element.method(:attribute_changed_callback)
|
|
159
|
+
if cb.arity.negative? || cb.arity >= 4
|
|
160
|
+
element.attribute_changed_callback(name, old_value, new_value, namespace)
|
|
161
|
+
else
|
|
162
|
+
element.attribute_changed_callback(name, old_value, new_value)
|
|
163
|
+
end
|
|
155
164
|
rescue StandardError
|
|
156
165
|
nil
|
|
157
166
|
end
|
|
@@ -169,13 +178,24 @@ module Dommy
|
|
|
169
178
|
return nil unless target
|
|
170
179
|
return nil if added_nodes.empty? && removed_nodes.empty?
|
|
171
180
|
|
|
181
|
+
# Custom Element connected/disconnected callbacks, script execution, and
|
|
182
|
+
# blank-iframe load all require the subtree to be connected to the
|
|
183
|
+
# document (the script/iframe paths already check is_connected?, and
|
|
184
|
+
# connectedCallback fires only when connected). So skip the O(subtree)
|
|
185
|
+
# walk for mutations within a still-detached tree — the common case
|
|
186
|
+
# during bulk DOM construction, where nothing in the walk can fire.
|
|
187
|
+
if !target.respond_to?(:is_connected?) || target.is_connected?
|
|
188
|
+
added_nodes.each { |nk| notify_connected_subtree(nk) }
|
|
189
|
+
removed_nodes.each { |nk| notify_disconnected_subtree(nk) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# MutationRecords are only needed when something is observing; skip the
|
|
193
|
+
# eager wrapping + record entirely when no observer is registered.
|
|
194
|
+
return nil unless @observer_manager.any?
|
|
195
|
+
|
|
172
196
|
wrapped_added = added_nodes.map { |node| @document.wrap_node(node) }.compact
|
|
173
197
|
wrapped_removed = removed_nodes.map { |node| @document.wrap_node(node) }.compact
|
|
174
198
|
|
|
175
|
-
# Fire Custom Element lifecycle callbacks (synchronous, before MutationObserver microtask)
|
|
176
|
-
added_nodes.each { |nk| notify_connected_subtree(nk) }
|
|
177
|
-
removed_nodes.each { |nk| notify_disconnected_subtree(nk) }
|
|
178
|
-
|
|
179
199
|
# Capture previousSibling / nextSibling (the position within target)
|
|
180
200
|
prev_w = previous_sibling
|
|
181
201
|
next_w = next_sibling
|
|
@@ -223,7 +243,7 @@ module Dommy
|
|
|
223
243
|
new_value = target_node[attr]
|
|
224
244
|
|
|
225
245
|
# Custom Element attributeChangedCallback (synchronous)
|
|
226
|
-
notify_attribute_changed(target, attr, old_value, new_value)
|
|
246
|
+
notify_attribute_changed(target, attr, old_value, new_value, namespace)
|
|
227
247
|
|
|
228
248
|
@observer_manager.observers_matching(target).each do |observer|
|
|
229
249
|
entry = observer.find_matching_entry(target)
|
|
@@ -115,14 +115,14 @@ module Dommy
|
|
|
115
115
|
# WHATWG createAttribute: an HTML document lower-cases the name (an XML
|
|
116
116
|
# document preserves it). Attr.new no longer folds case, so do it here.
|
|
117
117
|
str = str.downcase if @document.html_document?
|
|
118
|
-
Attr.new(str)
|
|
118
|
+
Attr.new(str, document: @document)
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
def create_attribute_ns(namespace_uri, qualified_name)
|
|
122
122
|
namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
|
|
123
123
|
qualified_name = domstring(qualified_name)
|
|
124
124
|
ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
|
|
125
|
-
Attr.new(qualified_name, namespace_uri: ns, prefix: prefix, local_name: local)
|
|
125
|
+
Attr.new(qualified_name, namespace_uri: ns, prefix: prefix, local_name: local, document: @document)
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
def create_element_ns(namespace_uri, qualified_name)
|
|
@@ -135,7 +135,17 @@ module Dommy
|
|
|
135
135
|
qualified_name = domstring(qualified_name)
|
|
136
136
|
ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name, context: :element)
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
# An XML backend rejects some DOM-valid qualified names (an invalid char
|
|
139
|
+
# in the local part, which DOM permits): the loose creator builds them
|
|
140
|
+
# verbatim. A genuinely invalid name it (or the strict path) rejects with
|
|
141
|
+
# an ArgumentError becomes an InvalidCharacterError, per DOM.
|
|
142
|
+
el =
|
|
143
|
+
begin
|
|
144
|
+
Backend.create_element_loose(qualified_name, prefix, local, ns, @document.backend_doc) ||
|
|
145
|
+
Backend.create_element(qualified_name, @document.backend_doc)
|
|
146
|
+
rescue ArgumentError
|
|
147
|
+
raise DOMException::InvalidCharacterError, "'#{qualified_name}' is not a valid element name"
|
|
148
|
+
end
|
|
139
149
|
Backend.add_namespace_definition(el, prefix, ns) if ns
|
|
140
150
|
|
|
141
151
|
wrapper = build_element_wrapper(el, namespace: ns, local_name: local)
|
|
@@ -143,6 +153,18 @@ module Dommy
|
|
|
143
153
|
wrapper
|
|
144
154
|
end
|
|
145
155
|
|
|
156
|
+
# Wrap a freshly-cloned backend element whose original was built via
|
|
157
|
+
# createElementNS: route the wrapper class by the known local name (the
|
|
158
|
+
# backend node name may be the full qualified name, e.g. "foo:div", which
|
|
159
|
+
# would otherwise resolve to HTMLUnknownElement) and reapply its namespace
|
|
160
|
+
# metadata (namespaceURI / prefix / localName / tagName).
|
|
161
|
+
def wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
|
|
162
|
+
reset_wrapper(node)
|
|
163
|
+
wrapper = build_element_wrapper(node, namespace: namespace, local_name: local)
|
|
164
|
+
wrapper.__internal_set_namespace__(namespace, prefix, local, qualified_name)
|
|
165
|
+
wrapper
|
|
166
|
+
end
|
|
167
|
+
|
|
146
168
|
# Query methods
|
|
147
169
|
|
|
148
170
|
def query_selector(selector)
|
|
@@ -183,14 +205,7 @@ module Dommy
|
|
|
183
205
|
end
|
|
184
206
|
|
|
185
207
|
def get_elements_by_tag_name(name)
|
|
186
|
-
|
|
187
|
-
doc = @document.backend_doc
|
|
188
|
-
cache = self
|
|
189
|
-
if n == "*"
|
|
190
|
-
HTMLCollection.new { doc.css("*").map { |x| cache.wrap(x) }.compact }
|
|
191
|
-
else
|
|
192
|
-
HTMLCollection.new { doc.css(n).map { |x| cache.wrap(x) }.compact }
|
|
193
|
-
end
|
|
208
|
+
HTMLCollection.elements_by_tag_name(@document.backend_doc, @document, name)
|
|
194
209
|
end
|
|
195
210
|
|
|
196
211
|
def get_elements_by_name(name)
|
|
@@ -202,15 +217,27 @@ module Dommy
|
|
|
202
217
|
end
|
|
203
218
|
end
|
|
204
219
|
|
|
220
|
+
# DOM "ASCII whitespace" (https://infra.spec.whatwg.org/#ascii-whitespace):
|
|
221
|
+
# TAB, LF, FF, CR, SPACE — NOT Ruby's `\s` (which also matches VT / U+000B)
|
|
222
|
+
# and NOT any Unicode space (U+00A0, U+2000…). Class tokens split on exactly
|
|
223
|
+
# this set, so a class of a single U+000B or U+00A0 is ONE token.
|
|
224
|
+
ASCII_WHITESPACE = /[\t\n\f\r ]+/
|
|
225
|
+
|
|
205
226
|
def get_elements_by_class_name(name)
|
|
206
|
-
tokens = name.to_s.split(
|
|
227
|
+
tokens = name.to_s.split(ASCII_WHITESPACE).reject(&:empty?)
|
|
207
228
|
doc = @document.backend_doc
|
|
208
229
|
cache = self
|
|
209
230
|
HTMLCollection.new do
|
|
210
231
|
next [] if tokens.empty?
|
|
211
232
|
|
|
212
|
-
|
|
213
|
-
|
|
233
|
+
# Match class tokens directly rather than composing a `.tok` CSS
|
|
234
|
+
# selector string — an exotic class token (control chars, Unicode
|
|
235
|
+
# spaces, quotes) can't be safely embedded in a selector, and the
|
|
236
|
+
# split must be ASCII-whitespace, not the CSS engine's tokenization.
|
|
237
|
+
doc.css("[class]").select do |n|
|
|
238
|
+
classes = n["class"].to_s.split(ASCII_WHITESPACE)
|
|
239
|
+
tokens.all? { |t| classes.include?(t) }
|
|
240
|
+
end.map { |n| cache.wrap(n) }.compact
|
|
214
241
|
end
|
|
215
242
|
end
|
|
216
243
|
|
|
@@ -219,6 +246,13 @@ module Dommy
|
|
|
219
246
|
@wrappers.delete(identity_key(nokogiri_node))
|
|
220
247
|
end
|
|
221
248
|
|
|
249
|
+
# The cached wrapper for `node`, or nil — WITHOUT creating one (unlike
|
|
250
|
+
# #wrap). Used by cross-document adoption to find the live descendant
|
|
251
|
+
# wrappers that must be reseated onto the imported copy.
|
|
252
|
+
def peek(node)
|
|
253
|
+
node && @wrappers[identity_key(node)]
|
|
254
|
+
end
|
|
255
|
+
|
|
222
256
|
# Register an externally-built wrapper. Used by
|
|
223
257
|
# Document#adopt_node when migrating a wrapper from another
|
|
224
258
|
# document so the existing Ruby object survives the move
|
|
@@ -293,6 +327,12 @@ module Dommy
|
|
|
293
327
|
|
|
294
328
|
def build_wrapper_for(node)
|
|
295
329
|
case node
|
|
330
|
+
when Backend.document_class
|
|
331
|
+
# The backend document node has no wrapper of its own — map it to the
|
|
332
|
+
# Dommy Document that owns this cache, so a top-level node's parentNode /
|
|
333
|
+
# getRootNode resolves to the document (documentElement.parentNode ===
|
|
334
|
+
# document), matching the DOM tree.
|
|
335
|
+
@document
|
|
296
336
|
when Backend.element_class
|
|
297
337
|
build_element_wrapper(node)
|
|
298
338
|
when ->(n) { Backend.cdata_class && n.is_a?(Backend.cdata_class) }
|
|
@@ -306,6 +346,8 @@ module Dommy
|
|
|
306
346
|
ProcessingInstructionNode.new(@document, node)
|
|
307
347
|
when Backend.document_fragment_class
|
|
308
348
|
Fragment.new(@document, node)
|
|
349
|
+
when ->(n) { (dt = Backend.document_type_class) && n.is_a?(dt) }
|
|
350
|
+
DocumentType.new(backend_node: node, document: @document)
|
|
309
351
|
end
|
|
310
352
|
end
|
|
311
353
|
|
|
@@ -26,6 +26,12 @@ module Dommy
|
|
|
26
26
|
def all
|
|
27
27
|
@observers.dup
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# True when at least one observer is registered — a cheap gate so a
|
|
31
|
+
# mutation with no observers skips building MutationRecords entirely.
|
|
32
|
+
def any?
|
|
33
|
+
!@observers.empty?
|
|
34
|
+
end
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
37
|
end
|
|
@@ -4,9 +4,8 @@ module Dommy
|
|
|
4
4
|
module Internal
|
|
5
5
|
# Matches a mutation target against an observed node based on observer options.
|
|
6
6
|
# Works exclusively with wrapped DOM nodes (not Nokogiri internals).
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
end
|
|
7
|
+
module ObserverMatcher
|
|
8
|
+
module_function
|
|
10
9
|
|
|
11
10
|
# Does this observer's target scope match the mutation target?
|
|
12
11
|
# Returns true if:
|
|
@@ -20,11 +19,10 @@ module Dommy
|
|
|
20
19
|
observed_wrapped.contains?(target_wrapped)
|
|
21
20
|
end
|
|
22
21
|
|
|
23
|
-
# Special case: Document observation
|
|
24
|
-
#
|
|
25
|
-
def matches_document?(
|
|
26
|
-
|
|
27
|
-
false
|
|
22
|
+
# Special case: Document observation. Matches iff subtree=true
|
|
23
|
+
# (a plain target==observed match never applies to a Document).
|
|
24
|
+
def matches_document?(_target_wrapped, subtree:)
|
|
25
|
+
subtree
|
|
28
26
|
end
|
|
29
27
|
end
|
|
30
28
|
end
|
|
@@ -11,6 +11,11 @@ module Dommy
|
|
|
11
11
|
# coordinator already no-ops on empty added/removed sets and on an
|
|
12
12
|
# unwrappable target, so callers may invoke it unconditionally.
|
|
13
13
|
module ParentNode
|
|
14
|
+
# Argument coercion (`detach_dom_nodes`), childList notification, and the
|
|
15
|
+
# ChildNode `before`/`after`/`replaceWith` surface all live in ChildNode,
|
|
16
|
+
# shared with the leaf CharacterData nodes.
|
|
17
|
+
include ChildNode
|
|
18
|
+
|
|
14
19
|
# `appendChild(child)` — detach the node(s) from any current parent
|
|
15
20
|
# and append to the end of this node's child list.
|
|
16
21
|
def append_child(child)
|
|
@@ -37,7 +42,10 @@ module Dommy
|
|
|
37
42
|
nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
|
|
38
43
|
anchor = @__node__.children.first
|
|
39
44
|
if anchor
|
|
40
|
-
|
|
45
|
+
# Insert each node before the (fixed) original first child in order:
|
|
46
|
+
# forward iteration keeps document order (n1, n2, … then the old first
|
|
47
|
+
# child). Reversing here would emit them backwards.
|
|
48
|
+
nodes.each { |n| anchor.add_previous_sibling(n) }
|
|
41
49
|
else
|
|
42
50
|
nodes.each { |n| @__node__.add_child(n) }
|
|
43
51
|
end
|
|
@@ -57,84 +65,40 @@ module Dommy
|
|
|
57
65
|
nil
|
|
58
66
|
end
|
|
59
67
|
|
|
60
|
-
|
|
68
|
+
# Node#normalize — merge each run of adjacent exclusive Text descendants
|
|
69
|
+
# into its first node (preserving that node's identity, so a JS reference
|
|
70
|
+
# to it survives) and drop empty Text nodes. Recurses the whole subtree,
|
|
71
|
+
# so it works for Element, DocumentFragment, and ShadowRoot alike.
|
|
72
|
+
def normalize
|
|
73
|
+
text_nodes = []
|
|
74
|
+
@__node__.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
# callers pass the parent explicitly. The coordinator filters out
|
|
65
|
-
# empty added/removed sets, so this is always safe to call.
|
|
66
|
-
def notify_child_list(added: [], removed: [], target: @__node__)
|
|
67
|
-
@document.notify_child_list_mutation(
|
|
68
|
-
target_node: target,
|
|
69
|
-
added_nodes: added,
|
|
70
|
-
removed_nodes: removed
|
|
71
|
-
)
|
|
72
|
-
end
|
|
76
|
+
text_nodes.each do |node|
|
|
77
|
+
next unless node.parent # already removed as part of an earlier run
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return [] unless node
|
|
92
|
-
|
|
93
|
-
# WHATWG pre-insert adopts the node into this node's document before
|
|
94
|
-
# linking it. libxml2 reassigns ownership in place during add_child, so
|
|
95
|
-
# the explicit adopt is a no-op move there; Makiri can't move a node
|
|
96
|
-
# between document arenas, so a cross-document insert must adopt (an
|
|
97
|
-
# imported copy) first. adopt_node reseats the Dommy wrapper onto the
|
|
98
|
-
# adopted node, so JS identity (`parent.appendChild(x); x` ===
|
|
99
|
-
# `parent.lastChild`) survives. Same-document: the wrapper's backend
|
|
100
|
-
# node is unchanged, so this is identical to the previous behavior.
|
|
101
|
-
detach_with_notify(node)
|
|
102
|
-
[@document.adopt_node(value).__dommy_backend_node__]
|
|
79
|
+
if node.content.to_s.empty?
|
|
80
|
+
@document.remove_node_with_notify(node)
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
merged = []
|
|
85
|
+
sib = node.next
|
|
86
|
+
while sib.respond_to?(:text?) && sib.text?
|
|
87
|
+
merged << sib
|
|
88
|
+
sib = sib.next
|
|
89
|
+
end
|
|
90
|
+
next if merged.empty?
|
|
91
|
+
|
|
92
|
+
old = node.content.to_s
|
|
93
|
+
node.content = old + merged.map { |m| m.content.to_s }.join
|
|
94
|
+
@document.notify_character_data_mutation(target_node: node, old_value: old)
|
|
95
|
+
merged.each { |m| @document.remove_node_with_notify(m) }
|
|
103
96
|
end
|
|
104
|
-
end
|
|
105
97
|
|
|
106
|
-
|
|
107
|
-
# no-op when already same-document; otherwise Backend.adopt — in place for
|
|
108
|
-
# Nokogiri, an imported copy for Makiri (which can't move nodes between
|
|
109
|
-
# arenas). Used for fragment children, which have no standalone wrapper to
|
|
110
|
-
# reseat.
|
|
111
|
-
def adopt_into_document(node)
|
|
112
|
-
target = @document.backend_doc
|
|
113
|
-
node.document == target ? node : Backend.adopt(node, target)
|
|
98
|
+
nil
|
|
114
99
|
end
|
|
115
100
|
|
|
116
|
-
|
|
117
|
-
# record on that old parent first (WHATWG "remove" runs before the
|
|
118
|
-
# subsequent insert, so moving a node yields a removal record + an addition
|
|
119
|
-
# record). Returns the raw node, ready to be re-linked.
|
|
120
|
-
def detach_with_notify(node)
|
|
121
|
-
old_parent = node.parent
|
|
122
|
-
return node unless old_parent
|
|
123
|
-
|
|
124
|
-
# Capture the position (as wrapped nodes — the coordinator records
|
|
125
|
-
# explicit siblings verbatim) before unlinking.
|
|
126
|
-
prev_sib = node.previous_sibling && @document.wrap_node(node.previous_sibling)
|
|
127
|
-
next_sib = node.next_sibling && @document.wrap_node(node.next_sibling)
|
|
128
|
-
node.unlink
|
|
129
|
-
@document.notify_child_list_mutation(
|
|
130
|
-
target_node: old_parent,
|
|
131
|
-
added_nodes: [],
|
|
132
|
-
removed_nodes: [node],
|
|
133
|
-
previous_sibling: prev_sib,
|
|
134
|
-
next_sibling: next_sib
|
|
135
|
-
)
|
|
136
|
-
node
|
|
137
|
-
end
|
|
101
|
+
private
|
|
138
102
|
|
|
139
103
|
# Hierarchy guard hook. Default no-op (Fragment / ShadowRoot stay
|
|
140
104
|
# permissive, matching current behavior). Element overrides this to
|
|
@@ -358,6 +358,12 @@ module Dommy
|
|
|
358
358
|
when "hover"
|
|
359
359
|
hovered = element.owner_document&.__internal_hovered_element__
|
|
360
360
|
hovered && (element.equal?(hovered) || element.contains?(hovered))
|
|
361
|
+
when "invalid" then constraint_invalid?(element)
|
|
362
|
+
when "valid" then constraint_valid?(element)
|
|
363
|
+
when "required" then form_control_required?(element)
|
|
364
|
+
when "optional" then form_control_optional?(element)
|
|
365
|
+
when "read-only" then read_only_element?(element)
|
|
366
|
+
when "read-write" then read_write_element?(element)
|
|
361
367
|
when "active", "visited" then false # supported-but-currently-false (no pointer state / history)
|
|
362
368
|
when "dir" then dir_match?(element, pseudo.argument)
|
|
363
369
|
when "target" then element.get_attribute("id").to_s == Internal.target_id(element.owner_document).to_s && !Internal.target_id(element.owner_document).nil?
|
|
@@ -642,6 +648,89 @@ module Dommy
|
|
|
642
648
|
%w[button input select textarea optgroup option fieldset].include?(element.local_name.to_s.downcase)
|
|
643
649
|
end
|
|
644
650
|
|
|
651
|
+
# A candidate for constraint validation: a form-associated control whose
|
|
652
|
+
# `willValidate` is true (not disabled / readonly / barred).
|
|
653
|
+
def validation_candidate?(element)
|
|
654
|
+
element.respond_to?(:will_validate) && element.respond_to?(:validity) && element.will_validate
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# `:invalid` / `:valid` apply to candidates (by their validity) and to a
|
|
658
|
+
# form / fieldset (by whether any descendant candidate is invalid).
|
|
659
|
+
def constraint_invalid?(element)
|
|
660
|
+
name = element.local_name.to_s.downcase
|
|
661
|
+
if %w[form fieldset].include?(name)
|
|
662
|
+
descendant_candidates(element).any? { |c| !c.validity.valid }
|
|
663
|
+
else
|
|
664
|
+
validation_candidate?(element) && !element.validity.valid
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def constraint_valid?(element)
|
|
669
|
+
name = element.local_name.to_s.downcase
|
|
670
|
+
if %w[form fieldset].include?(name)
|
|
671
|
+
descendant_candidates(element).all? { |c| c.validity.valid }
|
|
672
|
+
else
|
|
673
|
+
validation_candidate?(element) && element.validity.valid
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def descendant_candidates(element)
|
|
678
|
+
element.query_selector_all("input, select, textarea, button").select do |c|
|
|
679
|
+
validation_candidate?(c)
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# `:required` / `:optional` apply to input / select / textarea per the
|
|
684
|
+
# `required` attribute.
|
|
685
|
+
def requirable_element?(element)
|
|
686
|
+
%w[input select textarea].include?(element.local_name.to_s.downcase)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def form_control_required?(element)
|
|
690
|
+
requirable_element?(element) && element.has_attribute?("required")
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def form_control_optional?(element)
|
|
694
|
+
requirable_element?(element) && !element.has_attribute?("required")
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# `:read-write` matches an editable control (a mutable text input / textarea,
|
|
698
|
+
# or an element with contenteditable); `:read-only` is its complement over
|
|
699
|
+
# the elements the pseudo-classes apply to.
|
|
700
|
+
def read_write_element?(element)
|
|
701
|
+
name = element.local_name.to_s.downcase
|
|
702
|
+
if name == "textarea"
|
|
703
|
+
return !element.has_attribute?("readonly") && !disabled_element?(element)
|
|
704
|
+
end
|
|
705
|
+
if name == "input"
|
|
706
|
+
return false unless mutable_input_type?(element)
|
|
707
|
+
|
|
708
|
+
return !element.has_attribute?("readonly") && !disabled_element?(element)
|
|
709
|
+
end
|
|
710
|
+
editable_via_contenteditable?(element)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def read_only_element?(element)
|
|
714
|
+
name = element.local_name.to_s.downcase
|
|
715
|
+
return !read_write_element?(element) if %w[input textarea].include?(name)
|
|
716
|
+
|
|
717
|
+
# For other elements, :read-only matches when not editable.
|
|
718
|
+
!editable_via_contenteditable?(element)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Text-like input types that can be read-write (not button/checkbox/etc.).
|
|
722
|
+
def mutable_input_type?(element)
|
|
723
|
+
%w[text search url tel email password date month week time
|
|
724
|
+
datetime-local number range color].include?(
|
|
725
|
+
(element.get_attribute("type") || "text").to_s.downcase
|
|
726
|
+
)
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def editable_via_contenteditable?(element)
|
|
730
|
+
v = element.get_attribute("contenteditable")
|
|
731
|
+
!v.nil? && v.to_s.downcase != "false"
|
|
732
|
+
end
|
|
733
|
+
|
|
645
734
|
def disabled_element?(element)
|
|
646
735
|
return true if element.has_attribute?("disabled")
|
|
647
736
|
|
|
@@ -12,7 +12,8 @@ module Dommy
|
|
|
12
12
|
# exactly the inputs the spec requires — cases Nokogiri's CSS parser silently
|
|
13
13
|
# accepts (`[*=test]`, `div % p`, `..x`) or rejects with the wrong error.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
15
|
+
# `parse!` returns the AST that `SelectorMatcher` matches against (it both
|
|
16
|
+
# validates and is the matcher's front end). It is a
|
|
16
17
|
# hand-written tokenizer + recursive-descent parser covering the productions
|
|
17
18
|
# the Selectors spec (and the WPT corpus) exercise: selector lists,
|
|
18
19
|
# combinators, type/universal selectors with namespace prefixes, id/class,
|
|
@@ -39,6 +40,17 @@ module Dommy
|
|
|
39
40
|
IDENT_FUNCTIONS = %w[lang dir].to_set.freeze
|
|
40
41
|
NESTED_SELECTOR_FUNCTIONS = %w[host host-context current].to_set.freeze
|
|
41
42
|
|
|
43
|
+
# A parsed AST is a pure function of (selector string, namespaces) and never
|
|
44
|
+
# goes stale as the DOM mutates (unlike the query-result caches, which tag
|
|
45
|
+
# entries with style_generation). So memoize globally: `matches?` / `closest`
|
|
46
|
+
# re-parse on every call, and query* re-parse on every cache miss (i.e. after
|
|
47
|
+
# any mutation), so the same handful of selectors are otherwise re-parsed
|
|
48
|
+
# constantly. Bounded via the same "clear at cap" idiom as the query caches.
|
|
49
|
+
# Lock-free plain Hash: `parse!` is pure Ruby (never releases the GVL), so a
|
|
50
|
+
# read/write is atomic under the GVL — a benign duplicate parse is the worst
|
|
51
|
+
# a race can cause, matching the existing per-document caches.
|
|
52
|
+
AST_CACHE_CAP = 2048
|
|
53
|
+
|
|
42
54
|
module_function
|
|
43
55
|
|
|
44
56
|
# `namespaces` maps a prefix String to its URI (with the symbol key
|
|
@@ -46,9 +58,32 @@ module Dommy
|
|
|
46
58
|
# stylesheet that declared `@namespace`. nil/empty (the DOM querySelector
|
|
47
59
|
# path) keeps any named prefix undeclared — a SyntaxError, as before.
|
|
48
60
|
def parse!(selector, namespaces: nil)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
key = [selector.to_s, namespaces]
|
|
62
|
+
cache = (@ast_cache ||= {})
|
|
63
|
+
if cache.key?(key)
|
|
64
|
+
cached = cache[key]
|
|
65
|
+
# An invalid selector is memoized as its SyntaxError so a repeat still
|
|
66
|
+
# throws (the message embeds the selector, so re-raising is exact).
|
|
67
|
+
raise cached if cached.is_a?(::Dommy::DOMException::SyntaxError)
|
|
68
|
+
|
|
69
|
+
return cached
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
ast =
|
|
73
|
+
begin
|
|
74
|
+
new_parser(selector.to_s, namespaces).parse_selector_list!
|
|
75
|
+
rescue InvalidSelector => e
|
|
76
|
+
error = ::Dommy::DOMException::SyntaxError.new("'#{selector}' is not a valid selector: #{e.message}")
|
|
77
|
+
ast_cache_store(cache, key, error)
|
|
78
|
+
raise error
|
|
79
|
+
end
|
|
80
|
+
ast_cache_store(cache, key, ast)
|
|
81
|
+
ast
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ast_cache_store(cache, key, value)
|
|
85
|
+
cache.clear if cache.size >= AST_CACHE_CAP
|
|
86
|
+
cache[key] = value
|
|
52
87
|
end
|
|
53
88
|
|
|
54
89
|
# Validate `selector`; raise DOMException::SyntaxError if it is not a valid
|
|
@@ -451,7 +486,9 @@ module Dommy
|
|
|
451
486
|
# Validate `name(...)` per the function's argument grammar.
|
|
452
487
|
def consume_function_args!(name, pseudo_element:)
|
|
453
488
|
advance # consume '('
|
|
454
|
-
|
|
489
|
+
# :is()/:where()/:matches() take a forgiving selector list, which may be
|
|
490
|
+
# empty (matches nothing); other functional pseudos require an argument.
|
|
491
|
+
arg = consume_function_argument_source(allow_empty: %w[is where matches].include?(name))
|
|
455
492
|
arg_parser = Parser.new(arg, in_has: @in_has, namespaces: @namespaces)
|
|
456
493
|
if pseudo_element
|
|
457
494
|
# ::slotted(<compound>), ::part(<ident>+), ::cue(<selector>), …
|
|
@@ -485,7 +522,7 @@ module Dommy
|
|
|
485
522
|
end
|
|
486
523
|
end
|
|
487
524
|
|
|
488
|
-
def consume_function_argument_source
|
|
525
|
+
def consume_function_argument_source(allow_empty: false)
|
|
489
526
|
skip_ws
|
|
490
527
|
start = @i
|
|
491
528
|
depth = 0
|
|
@@ -504,7 +541,7 @@ module Dommy
|
|
|
504
541
|
advance
|
|
505
542
|
end
|
|
506
543
|
arg = @s[start...@i].to_s.strip
|
|
507
|
-
fail!("empty function arguments") if arg.empty?
|
|
544
|
+
fail!("empty function arguments") if arg.empty? && !allow_empty
|
|
508
545
|
advance if peek == ")"
|
|
509
546
|
arg
|
|
510
547
|
end
|
|
@@ -36,6 +36,17 @@ module Dommy
|
|
|
36
36
|
nil
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Direct `new MyElement()`: create a fresh, unattached backing element for a
|
|
40
|
+
# registered tag (its ownerDocument is the window's document). The JS ctor
|
|
41
|
+
# is already running, so the element must NOT be re-upgraded — the bridge
|
|
42
|
+
# crosses it with upgrade suppressed. Returns nil when the tag is undefined.
|
|
43
|
+
def create(name)
|
|
44
|
+
return unless @window.respond_to?(:custom_elements)
|
|
45
|
+
return unless @window.custom_elements.get(name.to_s)
|
|
46
|
+
|
|
47
|
+
@window.document.create_element(name.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
39
50
|
private
|
|
40
51
|
|
|
41
52
|
# A Dommy custom element class for `name` whose reactions forward to the JS
|
|
@@ -82,8 +93,8 @@ module Dommy
|
|
|
82
93
|
self.class.js_bridge.invoke_lifecycle(self, "adoptedCallback", [])
|
|
83
94
|
end
|
|
84
95
|
|
|
85
|
-
def attribute_changed_callback(attr, old_value, new_value)
|
|
86
|
-
self.class.js_bridge.invoke_lifecycle(self, "attributeChangedCallback", [attr, old_value, new_value])
|
|
96
|
+
def attribute_changed_callback(attr, old_value, new_value, namespace = nil)
|
|
97
|
+
self.class.js_bridge.invoke_lifecycle(self, "attributeChangedCallback", [attr, old_value, new_value, namespace])
|
|
87
98
|
end
|
|
88
99
|
end
|
|
89
100
|
end
|