dommy 0.7.0 → 0.8.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/animation.rb +9 -1
- data/lib/dommy/attr.rb +192 -39
- data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
- data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
- data/lib/dommy/backend.rb +46 -0
- data/lib/dommy/blob.rb +28 -9
- data/lib/dommy/bridge/constructor_registry.rb +28 -0
- data/lib/dommy/bridge/methods.rb +57 -0
- data/lib/dommy/bridge.rb +97 -0
- data/lib/dommy/callable_invoker.rb +36 -0
- data/lib/dommy/cookie_store.rb +3 -1
- data/lib/dommy/crypto.rb +7 -1
- data/lib/dommy/css.rb +46 -0
- data/lib/dommy/custom_elements.rb +27 -3
- data/lib/dommy/data_transfer.rb +4 -0
- data/lib/dommy/document.rb +615 -48
- data/lib/dommy/dom_parser.rb +28 -15
- data/lib/dommy/element.rb +999 -471
- data/lib/dommy/event.rb +260 -96
- data/lib/dommy/event_source.rb +6 -2
- data/lib/dommy/fetch.rb +505 -43
- data/lib/dommy/file_reader.rb +11 -3
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +43 -8
- data/lib/dommy/html_collection.rb +55 -2
- data/lib/dommy/html_elements.rb +102 -1519
- data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +26 -0
- data/lib/dommy/internal/idna.rb +16 -7
- data/lib/dommy/internal/ipv4_parser.rb +22 -7
- data/lib/dommy/internal/mutation_coordinator.rb +11 -2
- data/lib/dommy/internal/namespaces.rb +70 -0
- data/lib/dommy/internal/node_equality.rb +86 -0
- data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
- data/lib/dommy/internal/observable_callback.rb +1 -5
- data/lib/dommy/internal/parent_node.rb +126 -0
- data/lib/dommy/internal/reflected_attributes.rb +103 -13
- data/lib/dommy/internal/selector_parser.rb +664 -0
- data/lib/dommy/internal/url_parser.rb +677 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +7 -1
- data/lib/dommy/message_channel.rb +32 -2
- data/lib/dommy/mutation_observer.rb +55 -12
- data/lib/dommy/navigator.rb +26 -12
- data/lib/dommy/node.rb +158 -28
- data/lib/dommy/notification.rb +3 -1
- data/lib/dommy/performance.rb +4 -0
- data/lib/dommy/performance_observer.rb +2 -0
- data/lib/dommy/promise.rb +14 -14
- data/lib/dommy/range.rb +74 -5
- data/lib/dommy/resize_observer.rb +2 -0
- data/lib/dommy/scheduler.rb +34 -13
- data/lib/dommy/shadow_root.rb +23 -54
- data/lib/dommy/storage.rb +2 -0
- data/lib/dommy/streams.rb +18 -27
- data/lib/dommy/svg_elements.rb +204 -3606
- data/lib/dommy/text_codec.rb +174 -21
- data/lib/dommy/tree_walker.rb +255 -66
- data/lib/dommy/url.rb +287 -449
- data/lib/dommy/url_pattern.rb +2 -0
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +37 -7
- data/lib/dommy/window.rb +202 -213
- data/lib/dommy/worker.rb +7 -7
- data/lib/dommy/xml_http_request.rb +15 -5
- data/lib/dommy.rb +7 -0
- metadata +12 -3
|
@@ -24,5 +24,114 @@ module Dommy
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
CSS_PSEUDO_HANDLERS = CSSPseudoHandlers.new
|
|
27
|
+
|
|
28
|
+
# Adds `:scope` support. Nokogiri compiles `:scope` into a custom XPath
|
|
29
|
+
# function `nokogiri:scope(.)`, calling it as `scope(node_set)`; a scoped
|
|
30
|
+
# query (`el.querySelector(":scope > p")`) resolves it to the context
|
|
31
|
+
# element, so only that element matches. One instance per query — it carries
|
|
32
|
+
# the context node.
|
|
33
|
+
class ScopedCSSPseudoHandlers < CSSPseudoHandlers
|
|
34
|
+
def initialize(scope_node)
|
|
35
|
+
@scope_node = scope_node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def scope(list)
|
|
39
|
+
list.find_all { |node| node.pointer_id == @scope_node.pointer_id }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.scoped_pseudo_handlers(scope_node)
|
|
44
|
+
ScopedCSSPseudoHandlers.new(scope_node)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The complete set of CSS pseudo-classes (+ the four legacy single-colon
|
|
48
|
+
# pseudo-elements). A `:identifier` outside this set is an unknown selector
|
|
49
|
+
# token → SyntaxError, whereas a known-but-unimplemented one (`:hover`) is a
|
|
50
|
+
# valid selector that simply matches nothing.
|
|
51
|
+
KNOWN_PSEUDOS = %w[
|
|
52
|
+
active any-link autofill blank checked current default defined disabled empty
|
|
53
|
+
enabled first first-child first-of-type focus focus-visible focus-within
|
|
54
|
+
fullscreen future has host hover in-range indeterminate invalid is lang
|
|
55
|
+
last-child last-of-type left link local-link modal not nth-child nth-col
|
|
56
|
+
nth-last-child nth-last-col nth-last-of-type nth-of-type only-child
|
|
57
|
+
only-of-type optional out-of-range past placeholder-shown playing paused
|
|
58
|
+
read-only read-write required right root scope target target-within
|
|
59
|
+
user-invalid user-valid valid visited where dir
|
|
60
|
+
before after first-line first-letter
|
|
61
|
+
].to_set.freeze
|
|
62
|
+
|
|
63
|
+
# Validate a non-null CSS selector for `querySelector`/`matches`/`closest`,
|
|
64
|
+
# raising SyntaxError for syntactically invalid selectors. Delegates to the
|
|
65
|
+
# full grammar parser (SelectorParser), which catches everything the old
|
|
66
|
+
# heuristic did (empty string, leading combinator, unknown pseudo-class) plus
|
|
67
|
+
# the rest of the Selectors grammar (`[*=v]`, `..x`, `div % p`, unknown
|
|
68
|
+
# pseudo-elements, undeclared namespaces, …) that Nokogiri silently accepts.
|
|
69
|
+
def self.validate_selector!(selector)
|
|
70
|
+
SelectorParser.validate!(selector.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Coerce the JS argument of a query method (querySelector/All) per WebIDL: the
|
|
74
|
+
# selector is a *non-nullable* DOMString, so JS `null` → "null" and
|
|
75
|
+
# `undefined` → "undefined" (which then match `<null>` / `<undefined>` typed
|
|
76
|
+
# elements rather than returning nothing), while a missing argument is a
|
|
77
|
+
# TypeError. Used at every JS dispatch site so the behaviour is uniform.
|
|
78
|
+
def self.css_query_arg!(args)
|
|
79
|
+
raise ::Dommy::Bridge::TypeError, "1 argument required, but only 0 present" if args.empty?
|
|
80
|
+
|
|
81
|
+
value = args[0]
|
|
82
|
+
return "null" if value.nil?
|
|
83
|
+
return "undefined" if defined?(::Dommy::Bridge::UNDEFINED) && value.equal?(::Dommy::Bridge::UNDEFINED)
|
|
84
|
+
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Nokogiri's CSS→XPath compiler chokes on an escaped colon INSIDE an
|
|
89
|
+
# attribute selector (`[xlink\:href]`, a namespaced/SVG attribute → "Invalid
|
|
90
|
+
# predicate"), though it handles escaped colons in class/id selectors fine
|
|
91
|
+
# (`.md\:flex`, `#a\:b` — Tailwind). Those attribute selectors target
|
|
92
|
+
# XML-namespaced attributes the HTML backend doesn't model, so drop just the
|
|
93
|
+
# comma-clauses that use them; the rest of the selector list is preserved.
|
|
94
|
+
# (Real frameworks hit this constantly — Turbo's click handler matches
|
|
95
|
+
# `a[href], a[xlink\:href]` on every click.) Returns a backend-safe selector;
|
|
96
|
+
# if every clause was unsupported, returns one that compiles but never
|
|
97
|
+
# matches.
|
|
98
|
+
ATTR_ESCAPED_COLON = /\[[^\]]*\\:[^\]]*\]/
|
|
99
|
+
def self.backend_safe_selector(selector)
|
|
100
|
+
# First drop clauses whose subject is a pseudo-element (`::before`,
|
|
101
|
+
# `:first-line`) — they match no element, and the backend can't compile
|
|
102
|
+
# `::`. Then drop the escaped-colon attribute clauses below.
|
|
103
|
+
s = SelectorParser.matchable_selector(selector.to_s)
|
|
104
|
+
return s unless s.include?('\\') && s.match?(ATTR_ESCAPED_COLON)
|
|
105
|
+
|
|
106
|
+
kept = split_selector_list(s).reject { |clause| clause.match?(ATTR_ESCAPED_COLON) }
|
|
107
|
+
kept.empty? ? ":not(*)" : kept.join(", ")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Split a selector list on top-level commas only (commas inside [...], (...),
|
|
111
|
+
# or quotes are part of a single complex selector and must not split it).
|
|
112
|
+
def self.split_selector_list(selector)
|
|
113
|
+
clauses = []
|
|
114
|
+
depth = 0
|
|
115
|
+
quote = nil
|
|
116
|
+
current = +""
|
|
117
|
+
selector.each_char do |ch|
|
|
118
|
+
if quote
|
|
119
|
+
quote = nil if ch == quote
|
|
120
|
+
elsif ch == '"' || ch == "'"
|
|
121
|
+
quote = ch
|
|
122
|
+
elsif ch == "[" || ch == "("
|
|
123
|
+
depth += 1
|
|
124
|
+
elsif ch == "]" || ch == ")"
|
|
125
|
+
depth -= 1 if depth.positive?
|
|
126
|
+
elsif ch == "," && depth.zero?
|
|
127
|
+
clauses << current.strip
|
|
128
|
+
current = +""
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
current << ch
|
|
132
|
+
end
|
|
133
|
+
clauses << current.strip
|
|
134
|
+
clauses.reject(&:empty?)
|
|
135
|
+
end
|
|
27
136
|
end
|
|
28
137
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "erb"
|
|
5
|
+
|
|
6
|
+
module Dommy
|
|
7
|
+
module Internal
|
|
8
|
+
# Stateless global functions exposed on the JS global (Window) that don't
|
|
9
|
+
# depend on any window state. Kept here so Window doesn't carry the cgi/erb
|
|
10
|
+
# dependency just for URI component encoding.
|
|
11
|
+
module GlobalFunctions
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# JS `encodeURIComponent`: percent-encode everything except
|
|
15
|
+
# `A-Za-z0-9 - _ . ! ~ * ' ( )`. `ERB::Util.url_encode` matches this,
|
|
16
|
+
# unlike `CGI.escape` (which uses `+` for space).
|
|
17
|
+
def encode_uri_component(value)
|
|
18
|
+
ERB::Util.url_encode(value.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def decode_uri_component(value)
|
|
22
|
+
CGI.unescape(value.to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/dommy/internal/idna.rb
CHANGED
|
@@ -87,14 +87,21 @@ module Dommy
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# `domain` → ASCII-only form. Returns nil for nil input.
|
|
90
|
-
|
|
90
|
+
#
|
|
91
|
+
# `check_hyphens` / `verify_dns_length` default to the strict
|
|
92
|
+
# RFC 5891 profile. The WHATWG URL "domain to ASCII" operation
|
|
93
|
+
# instead runs UTS #46 with CheckHyphens=false and
|
|
94
|
+
# VerifyDnsLength=false (so leading/trailing/3-4 hyphens, empty
|
|
95
|
+
# labels, and over-long labels/domains are all permitted); the
|
|
96
|
+
# URL parser passes both as false.
|
|
97
|
+
def self.to_ascii(domain, check_hyphens: true, verify_dns_length: true)
|
|
91
98
|
return domain if domain.nil?
|
|
92
99
|
|
|
93
100
|
mapped = uts46_map(domain.to_s)
|
|
94
101
|
normalized = mapped.unicode_normalize(:nfc)
|
|
95
102
|
labels = normalized.split(".", -1)
|
|
96
103
|
|
|
97
|
-
validate_no_empty_intermediate(labels)
|
|
104
|
+
validate_no_empty_intermediate(labels) if verify_dns_length
|
|
98
105
|
bidi_domain = labels.any? { |l| bidi_label?(l) }
|
|
99
106
|
|
|
100
107
|
encoded = labels.map do |label|
|
|
@@ -111,13 +118,15 @@ module Dommy
|
|
|
111
118
|
validate_a_label_roundtrip(label, decoded)
|
|
112
119
|
end
|
|
113
120
|
|
|
114
|
-
validate_label(decoded, bidi_domain: bidi_domain)
|
|
121
|
+
validate_label(decoded, bidi_domain: bidi_domain, check_hyphens: check_hyphens)
|
|
115
122
|
encode_label(decoded)
|
|
116
123
|
end
|
|
117
124
|
|
|
118
|
-
|
|
125
|
+
if verify_dns_length
|
|
126
|
+
encoded.each { |label| validate_a_label_form(label) }
|
|
127
|
+
end
|
|
119
128
|
result = encoded.join(".")
|
|
120
|
-
validate_total_length(result)
|
|
129
|
+
validate_total_length(result) if verify_dns_length
|
|
121
130
|
result
|
|
122
131
|
end
|
|
123
132
|
|
|
@@ -165,10 +174,10 @@ module Dommy
|
|
|
165
174
|
|
|
166
175
|
# --- Step 5: validate per-label ---------------------------------
|
|
167
176
|
|
|
168
|
-
def self.validate_label(label, bidi_domain:)
|
|
177
|
+
def self.validate_label(label, bidi_domain:, check_hyphens: true)
|
|
169
178
|
return if label.empty?
|
|
170
179
|
|
|
171
|
-
validate_hyphens(label)
|
|
180
|
+
validate_hyphens(label) if check_hyphens
|
|
172
181
|
validate_no_leading_combining_mark(label)
|
|
173
182
|
check_contextj(label)
|
|
174
183
|
check_contexto(label)
|
|
@@ -63,15 +63,30 @@ module Dommy
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def self.parse_number(str)
|
|
66
|
-
return
|
|
66
|
+
return nil if str.empty?
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
radix = 10
|
|
69
|
+
digits = str
|
|
70
|
+
if str.length >= 2 && (str[0, 2] == "0x" || str[0, 2] == "0X")
|
|
71
|
+
radix = 16
|
|
72
|
+
digits = str[2..]
|
|
73
|
+
elsif str.length >= 2 && str[0] == "0"
|
|
74
|
+
radix = 8
|
|
75
|
+
digits = str[1..]
|
|
74
76
|
end
|
|
77
|
+
|
|
78
|
+
# A bare radix prefix (`0x`, `0`) denotes the number 0.
|
|
79
|
+
return 0 if digits.empty?
|
|
80
|
+
|
|
81
|
+
valid =
|
|
82
|
+
case radix
|
|
83
|
+
when 16 then digits.match?(/\A[0-9a-fA-F]+\z/)
|
|
84
|
+
when 8 then digits.match?(/\A[0-7]+\z/)
|
|
85
|
+
else digits.match?(/\A[0-9]+\z/)
|
|
86
|
+
end
|
|
87
|
+
return nil unless valid
|
|
88
|
+
|
|
89
|
+
digits.to_i(radix)
|
|
75
90
|
end
|
|
76
91
|
end
|
|
77
92
|
end
|
|
@@ -109,7 +109,13 @@ module Dommy
|
|
|
109
109
|
previous_sibling: prev_w,
|
|
110
110
|
next_sibling: next_w
|
|
111
111
|
)
|
|
112
|
+
# Only observers whose matching registration requested childList get the
|
|
113
|
+
# record (an `attributes`/`characterData`-only observer must not — e.g.
|
|
114
|
+
# `observe(t, {childList: false, attributes: true})`).
|
|
112
115
|
@observer_manager.observers_matching(target).each do |observer|
|
|
116
|
+
entry = observer.find_matching_entry(target)
|
|
117
|
+
next unless entry && entry[:child_list]
|
|
118
|
+
|
|
113
119
|
observer.enqueue(record)
|
|
114
120
|
end
|
|
115
121
|
|
|
@@ -117,11 +123,13 @@ module Dommy
|
|
|
117
123
|
end
|
|
118
124
|
|
|
119
125
|
# Fire MutationObserver attribute records
|
|
120
|
-
def notify_attribute_mutation(target_node:, attribute_name:, old_value:)
|
|
126
|
+
def notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil)
|
|
121
127
|
target = @document.wrap_node(target_node)
|
|
122
128
|
return nil unless target
|
|
123
129
|
|
|
124
|
-
|
|
130
|
+
# A namespaced attribute keeps its local name as-is; a plain HTML
|
|
131
|
+
# attribute is lower-cased.
|
|
132
|
+
attr = namespace ? attribute_name.to_s : attribute_name.to_s.downcase
|
|
125
133
|
new_value = target_node[attr]
|
|
126
134
|
|
|
127
135
|
# Custom Element attributeChangedCallback (synchronous)
|
|
@@ -139,6 +147,7 @@ module Dommy
|
|
|
139
147
|
type: "attributes",
|
|
140
148
|
target: target,
|
|
141
149
|
attribute_name: attr,
|
|
150
|
+
attribute_namespace: namespace,
|
|
142
151
|
old_value: entry[:attribute_old_value] ? old_value : nil
|
|
143
152
|
)
|
|
144
153
|
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# Namespace constants and the WHATWG DOM "validate and extract" algorithm,
|
|
6
|
+
# shared by createAttributeNS / setAttributeNS / createElementNS.
|
|
7
|
+
module Namespaces
|
|
8
|
+
HTML = "http://www.w3.org/1999/xhtml"
|
|
9
|
+
SVG = "http://www.w3.org/2000/svg"
|
|
10
|
+
MATHML = "http://www.w3.org/1998/Math/MathML"
|
|
11
|
+
XML = "http://www.w3.org/XML/1998/namespace"
|
|
12
|
+
XLINK = "http://www.w3.org/1999/xlink"
|
|
13
|
+
XMLNS = "http://www.w3.org/2000/xmlns/"
|
|
14
|
+
|
|
15
|
+
# XML Name / QName productions, matching the canonical
|
|
16
|
+
# `xml-name-validator` package (what WHATWG DOM "validate" relies on).
|
|
17
|
+
# Built from the XML 1.0 NameStartChar / NameChar Unicode ranges; an
|
|
18
|
+
# NCName excludes ":", a QName is one optional `prefix:` + local NCName.
|
|
19
|
+
NC_START = "A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D" \
|
|
20
|
+
"\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF" \
|
|
21
|
+
"\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{EFFFF}"
|
|
22
|
+
NC_EXTRA = "\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040"
|
|
23
|
+
NCSTART = "[#{NC_START}]"
|
|
24
|
+
NCCHAR = "[#{NC_START}#{NC_EXTRA}]"
|
|
25
|
+
|
|
26
|
+
# The full Name production (NameStartChar additionally includes ":").
|
|
27
|
+
NAME = Regexp.new("\\A[:#{NC_START}][:#{NC_START}#{NC_EXTRA}]*\\z")
|
|
28
|
+
# PrefixedName | UnprefixedName.
|
|
29
|
+
QNAME = Regexp.new(
|
|
30
|
+
"(?:\\A#{NCSTART}#{NCCHAR}*:#{NCSTART}#{NCCHAR}*\\z)|(?:\\A#{NCSTART}#{NCCHAR}*\\z)"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# https://dom.spec.whatwg.org/#validate-and-extract
|
|
36
|
+
# Returns [namespace_or_nil, prefix_or_nil, local_name]. Raises
|
|
37
|
+
# DOMException (InvalidCharacterError / NamespaceError) on bad input.
|
|
38
|
+
def validate_and_extract(namespace, qualified_name)
|
|
39
|
+
ns = namespace.to_s
|
|
40
|
+
ns = nil if ns.empty?
|
|
41
|
+
qname = qualified_name.to_s
|
|
42
|
+
|
|
43
|
+
unless qname.match?(QNAME)
|
|
44
|
+
raise DOMException::InvalidCharacterError, "invalid qualified name: #{qname.inspect}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
prefix = nil
|
|
48
|
+
local = qname
|
|
49
|
+
if qname.include?(":")
|
|
50
|
+
prefix, local = qname.split(":", 2)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if prefix && ns.nil?
|
|
54
|
+
raise DOMException::NamespaceError, "prefix #{prefix.inspect} with null namespace"
|
|
55
|
+
end
|
|
56
|
+
if prefix == "xml" && ns != XML
|
|
57
|
+
raise DOMException::NamespaceError, "prefix 'xml' must use the XML namespace"
|
|
58
|
+
end
|
|
59
|
+
if (qname == "xmlns" || prefix == "xmlns") && ns != XMLNS
|
|
60
|
+
raise DOMException::NamespaceError, "'xmlns' must use the XMLNS namespace"
|
|
61
|
+
end
|
|
62
|
+
if ns == XMLNS && qname != "xmlns" && prefix != "xmlns"
|
|
63
|
+
raise DOMException::NamespaceError, "the XMLNS namespace requires the 'xmlns' name/prefix"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
[ns, prefix, local]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
# WHATWG DOM "equals" / Node.isEqualNode (https://dom.spec.whatwg.org/#concept-node-equals).
|
|
6
|
+
# Operates on Dommy node wrappers through their bridge-facing accessors
|
|
7
|
+
# (__js_get__ for the per-type data, #child_nodes / #attributes for the
|
|
8
|
+
# structure), so it works uniformly across the heterogeneous node classes.
|
|
9
|
+
module NodeEquality
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def equal?(a, b)
|
|
13
|
+
return false if b.nil?
|
|
14
|
+
|
|
15
|
+
type = prop(a, "nodeType")
|
|
16
|
+
return false unless type == prop(b, "nodeType")
|
|
17
|
+
return false unless data_equal?(a, b, type)
|
|
18
|
+
|
|
19
|
+
kids_a = children(a)
|
|
20
|
+
kids_b = children(b)
|
|
21
|
+
return false unless kids_a.length == kids_b.length
|
|
22
|
+
|
|
23
|
+
kids_a.each_index { |i| return false unless equal?(kids_a[i], kids_b[i]) }
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Per-type "these two nodes have equal own properties" (children compared
|
|
28
|
+
# separately by #equal?).
|
|
29
|
+
def data_equal?(a, b, type)
|
|
30
|
+
case type
|
|
31
|
+
when Node::ELEMENT_NODE
|
|
32
|
+
prop(a, "namespaceURI") == prop(b, "namespaceURI") &&
|
|
33
|
+
prop(a, "prefix") == prop(b, "prefix") &&
|
|
34
|
+
prop(a, "localName") == prop(b, "localName") &&
|
|
35
|
+
attributes_equal?(a, b)
|
|
36
|
+
when Node::DOCUMENT_TYPE_NODE
|
|
37
|
+
prop(a, "nodeName") == prop(b, "nodeName") &&
|
|
38
|
+
prop(a, "publicId") == prop(b, "publicId") &&
|
|
39
|
+
prop(a, "systemId") == prop(b, "systemId")
|
|
40
|
+
when Node::PROCESSING_INSTRUCTION_NODE
|
|
41
|
+
prop(a, "target") == prop(b, "target") && prop(a, "data") == prop(b, "data")
|
|
42
|
+
when Node::TEXT_NODE, Node::CDATA_SECTION_NODE, Node::COMMENT_NODE
|
|
43
|
+
prop(a, "data") == prop(b, "data")
|
|
44
|
+
when Node::ATTRIBUTE_NODE
|
|
45
|
+
prop(a, "namespaceURI") == prop(b, "namespaceURI") &&
|
|
46
|
+
prop(a, "localName") == prop(b, "localName") &&
|
|
47
|
+
prop(a, "value") == prop(b, "value")
|
|
48
|
+
else
|
|
49
|
+
# Document / DocumentFragment carry no own properties to compare.
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Equal attribute lists, order-independent (each attribute of A must have
|
|
55
|
+
# a (namespace, localName, value) match in B, and the counts must match).
|
|
56
|
+
def attributes_equal?(a, b)
|
|
57
|
+
attrs_a = attribute_descriptors(a)
|
|
58
|
+
attrs_b = attribute_descriptors(b)
|
|
59
|
+
return false unless attrs_a.length == attrs_b.length
|
|
60
|
+
|
|
61
|
+
attrs_a.all? { |x| attrs_b.include?(x) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def attribute_descriptors(node)
|
|
65
|
+
return [] unless node.respond_to?(:attributes)
|
|
66
|
+
|
|
67
|
+
map = node.attributes
|
|
68
|
+
(0...map.length).map do |i|
|
|
69
|
+
attr = map.item(i)
|
|
70
|
+
[attr.namespace_uri, attr.local_name, attr.value]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def children(node)
|
|
75
|
+
return [] unless node.respond_to?(:child_nodes)
|
|
76
|
+
|
|
77
|
+
list = node.child_nodes
|
|
78
|
+
list.respond_to?(:to_a) ? list.to_a : (0...list.length).map { |i| list.item(i) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prop(node, key)
|
|
82
|
+
node.__js_get__(key)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -6,8 +6,6 @@ module Dommy
|
|
|
6
6
|
# Ensures that wrap_node(nokogiri_node) always returns the same Ruby object.
|
|
7
7
|
# Separates identity/caching management from Document's public DOM API.
|
|
8
8
|
class NodeWrapperCache
|
|
9
|
-
NAME_RE = /\A[a-z][\w.\-]*\z/i
|
|
10
|
-
|
|
11
9
|
def initialize(document)
|
|
12
10
|
@document = document
|
|
13
11
|
@wrappers = {}
|
|
@@ -29,17 +27,36 @@ module Dommy
|
|
|
29
27
|
# Factory methods
|
|
30
28
|
|
|
31
29
|
def create_element(name)
|
|
32
|
-
str = name
|
|
30
|
+
str = domstring(name)
|
|
33
31
|
raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
|
|
34
|
-
raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(
|
|
32
|
+
raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(Namespaces::NAME)
|
|
33
|
+
|
|
34
|
+
# WHATWG createElement: lowercase (ASCII) the name only in an HTML
|
|
35
|
+
# document; the namespace is the HTML namespace for HTML/XHTML documents
|
|
36
|
+
# and null for a non-XHTML XML document. Record the metadata so the
|
|
37
|
+
# element's localName/tagName/namespaceURI getters report it faithfully
|
|
38
|
+
# (in particular case preservation for XML/XHTML).
|
|
39
|
+
if @document.html_document?
|
|
40
|
+
local = str.downcase(:ascii)
|
|
41
|
+
namespace = Element::HTML_NAMESPACE
|
|
42
|
+
else
|
|
43
|
+
local = str
|
|
44
|
+
namespace = @document.content_type == "application/xhtml+xml" ? Element::HTML_NAMESPACE : nil
|
|
45
|
+
end
|
|
35
46
|
|
|
36
|
-
wrap_node(Backend.create_element(
|
|
47
|
+
wrapper = wrap_node(Backend.create_element(local, @document.nokogiri_doc))
|
|
48
|
+
wrapper.__internal_set_namespace__(namespace, nil, local, local)
|
|
49
|
+
wrapper
|
|
37
50
|
end
|
|
38
51
|
|
|
39
52
|
def create_text_node(text)
|
|
40
53
|
wrap_node(Backend.create_text(text.to_s, @document.nokogiri_doc))
|
|
41
54
|
end
|
|
42
55
|
|
|
56
|
+
def create_cdata_section(text)
|
|
57
|
+
wrap_node(Backend.create_cdata(text.to_s, @document.nokogiri_doc))
|
|
58
|
+
end
|
|
59
|
+
|
|
43
60
|
def create_comment(text)
|
|
44
61
|
wrap_node(Backend.create_comment(text.to_s, @document.nokogiri_doc))
|
|
45
62
|
end
|
|
@@ -49,46 +66,52 @@ module Dommy
|
|
|
49
66
|
end
|
|
50
67
|
|
|
51
68
|
def create_attribute(name)
|
|
52
|
-
str = name
|
|
69
|
+
str = domstring(name)
|
|
53
70
|
raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
|
|
54
|
-
raise DOMException::InvalidCharacterError, "invalid attribute name: #{str.inspect}" unless str.match?(
|
|
71
|
+
raise DOMException::InvalidCharacterError, "invalid attribute name: #{str.inspect}" unless str.match?(Namespaces::NAME)
|
|
55
72
|
|
|
56
73
|
Attr.new(str)
|
|
57
74
|
end
|
|
58
75
|
|
|
59
|
-
def create_attribute_ns(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Attr.new(str)
|
|
76
|
+
def create_attribute_ns(namespace_uri, qualified_name)
|
|
77
|
+
namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
|
|
78
|
+
qualified_name = domstring(qualified_name)
|
|
79
|
+
ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
|
|
80
|
+
Attr.new(qualified_name, namespace_uri: ns, prefix: prefix, local_name: local)
|
|
65
81
|
end
|
|
66
82
|
|
|
67
83
|
def create_element_ns(namespace_uri, qualified_name)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
# WHATWG "validate and extract": QName-validate the qualifiedName
|
|
85
|
+
# (InvalidCharacterError) and apply the prefix/namespace rules
|
|
86
|
+
# (NamespaceError), then build the element with its prefix bound.
|
|
87
|
+
# namespace is nullable (undefined → null); qualifiedName is a plain
|
|
88
|
+
# DOMString (undefined → "undefined", null → "null").
|
|
89
|
+
namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
|
|
90
|
+
qualified_name = domstring(qualified_name)
|
|
91
|
+
ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
|
|
92
|
+
|
|
93
|
+
el = Backend.create_element(qualified_name, @document.nokogiri_doc)
|
|
94
|
+
Backend.add_namespace_definition(el, prefix, ns) if ns
|
|
95
|
+
|
|
96
|
+
wrapper = wrap(el)
|
|
97
|
+
wrapper.__internal_set_namespace__(ns, prefix, local, qualified_name)
|
|
98
|
+
wrapper
|
|
78
99
|
end
|
|
79
100
|
|
|
80
101
|
# Query methods
|
|
81
102
|
|
|
82
103
|
def query_selector(selector)
|
|
83
|
-
return nil if selector.nil?
|
|
104
|
+
return nil if selector.nil?
|
|
105
|
+
Internal.validate_selector!(selector)
|
|
84
106
|
|
|
85
|
-
wrap(@document.nokogiri_doc.at_css(selector.to_s, CSS_PSEUDO_HANDLERS))
|
|
107
|
+
wrap(@document.nokogiri_doc.at_css(Internal.backend_safe_selector(selector.to_s), CSS_PSEUDO_HANDLERS))
|
|
86
108
|
end
|
|
87
109
|
|
|
88
110
|
def query_selector_all(selector)
|
|
89
|
-
return NodeList.new if selector.nil?
|
|
111
|
+
return NodeList.new if selector.nil?
|
|
112
|
+
Internal.validate_selector!(selector)
|
|
90
113
|
|
|
91
|
-
NodeList.new(@document.nokogiri_doc.css(selector.to_s, CSS_PSEUDO_HANDLERS).map { |node| wrap(node) }.compact)
|
|
114
|
+
NodeList.new(@document.nokogiri_doc.css(Internal.backend_safe_selector(selector.to_s), CSS_PSEUDO_HANDLERS).map { |node| wrap(node) }.compact)
|
|
92
115
|
end
|
|
93
116
|
|
|
94
117
|
def get_element_by_id(id)
|
|
@@ -144,6 +167,15 @@ module Dommy
|
|
|
144
167
|
|
|
145
168
|
private
|
|
146
169
|
|
|
170
|
+
# WebIDL DOMString coercion for a name/qualifiedName argument: JS
|
|
171
|
+
# `undefined` → "undefined", JS `null` (Ruby nil) → "null", else #to_s.
|
|
172
|
+
def domstring(value)
|
|
173
|
+
return "undefined" if value.equal?(Bridge::UNDEFINED)
|
|
174
|
+
return "null" if value.nil?
|
|
175
|
+
|
|
176
|
+
value.to_s
|
|
177
|
+
end
|
|
178
|
+
|
|
147
179
|
def wrap_node(node)
|
|
148
180
|
wrap(node)
|
|
149
181
|
end
|
|
@@ -152,6 +184,9 @@ module Dommy
|
|
|
152
184
|
case node
|
|
153
185
|
when Backend.element_class
|
|
154
186
|
build_element_wrapper(node)
|
|
187
|
+
when ->(n) { Backend.cdata_class && n.is_a?(Backend.cdata_class) }
|
|
188
|
+
# CDATA is a Text subtype in the backend, so match it before text_class.
|
|
189
|
+
CDATASectionNode.new(@document, node)
|
|
155
190
|
when Backend.text_class
|
|
156
191
|
TextNode.new(@document, node)
|
|
157
192
|
when Backend.comment_class
|
|
@@ -14,11 +14,7 @@ module Dommy
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
16
|
def invoke_callback(entries)
|
|
17
|
-
|
|
18
|
-
@callback.__js_call__("call", [entries, self])
|
|
19
|
-
elsif @callback.respond_to?(:call)
|
|
20
|
-
@callback.call(entries, self)
|
|
21
|
-
end
|
|
17
|
+
CallableInvoker.invoke(@callback, entries, self)
|
|
22
18
|
end
|
|
23
19
|
end
|
|
24
20
|
end
|