dommy 0.8.0 → 0.9.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/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
|
@@ -6,9 +6,28 @@ 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
|
+
# Distinguishes "no namespace argument given" (derive from the backend /
|
|
10
|
+
# document) from an explicit nil namespace passed by createElementNS.
|
|
11
|
+
NAMESPACE_UNSET = Object.new.freeze
|
|
12
|
+
|
|
13
|
+
# Cap on distinct cached selectors before the query cache is cleared
|
|
14
|
+
# wholesale — a backstop against a page that generates unbounded unique
|
|
15
|
+
# selector strings; real pages reuse a small set (tens).
|
|
16
|
+
QUERY_CACHE_CAP = 512
|
|
17
|
+
|
|
9
18
|
def initialize(document)
|
|
10
19
|
@document = document
|
|
11
20
|
@wrappers = {}
|
|
21
|
+
# Memoizes document-rooted CSS query results within a DOM generation.
|
|
22
|
+
# querySelector(All) over a large tree is a full descendant walk, yet a
|
|
23
|
+
# heavy page issues the SAME selector hundreds of times between mutations
|
|
24
|
+
# (measured ~87% repeats on a real site). `Document#style_generation`
|
|
25
|
+
# bumps on every childList / attribute / characterData mutation — and on
|
|
26
|
+
# focus / active-element changes too, so `:focus`-dependent selectors are
|
|
27
|
+
# invalidated correctly — so a result tagged with the generation it was
|
|
28
|
+
# computed in stays valid until the next mutation, then is recomputed
|
|
29
|
+
# lazily. Keyed by [kind, selector] => [generation, value].
|
|
30
|
+
@query_cache = {}
|
|
12
31
|
end
|
|
13
32
|
|
|
14
33
|
# Returns the wrapped node, creating and caching if needed.
|
|
@@ -16,11 +35,22 @@ module Dommy
|
|
|
16
35
|
def wrap(node)
|
|
17
36
|
return nil unless node
|
|
18
37
|
|
|
19
|
-
|
|
20
|
-
|
|
38
|
+
key = identity_key(node)
|
|
39
|
+
cached = @wrappers[key]
|
|
40
|
+
# A hit is only trustworthy if the cached wrapper still describes the
|
|
41
|
+
# SAME kind of node as the one now at this identity key. The key is a
|
|
42
|
+
# backend pointer/object_id, and a backend MAY recycle a freed node's
|
|
43
|
+
# identity (Nokogiri reuses object_ids; Makiri reuses the lxb pointer of
|
|
44
|
+
# a *transient* node — e.g. a throwaway fragment parsed by
|
|
45
|
+
# `Parser.fragment` — once it's GC'd). When that happens the stale entry
|
|
46
|
+
# would hand back a wrapper of the wrong type (observed: a Fragment
|
|
47
|
+
# clone resolving to a cached TextNode). Validate cheaply via nodeType —
|
|
48
|
+
# compared against a static class→type map so we never dereference the
|
|
49
|
+
# cached wrapper's (possibly freed) backend node — and rebuild on a miss.
|
|
50
|
+
return cached if cached && cached_wrapper_live?(cached, node)
|
|
21
51
|
|
|
22
52
|
wrapper = build_wrapper_for(node)
|
|
23
|
-
@wrappers[
|
|
53
|
+
@wrappers[key] = wrapper if wrapper
|
|
24
54
|
wrapper
|
|
25
55
|
end
|
|
26
56
|
|
|
@@ -29,7 +59,7 @@ module Dommy
|
|
|
29
59
|
def create_element(name)
|
|
30
60
|
str = domstring(name)
|
|
31
61
|
raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
|
|
32
|
-
raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(Namespaces::
|
|
62
|
+
raise DOMException::InvalidCharacterError, "invalid element name: #{str.inspect}" unless str.match?(Namespaces::HTML_NAME)
|
|
33
63
|
|
|
34
64
|
# WHATWG createElement: lowercase (ASCII) the name only in an HTML
|
|
35
65
|
# document; the namespace is the HTML namespace for HTML/XHTML documents
|
|
@@ -44,25 +74,37 @@ module Dommy
|
|
|
44
74
|
namespace = @document.content_type == "application/xhtml+xml" ? Element::HTML_NAMESPACE : nil
|
|
45
75
|
end
|
|
46
76
|
|
|
47
|
-
wrapper = wrap_node(Backend.create_element(local, @document.
|
|
77
|
+
wrapper = wrap_node(Backend.create_element(local, @document.backend_doc))
|
|
48
78
|
wrapper.__internal_set_namespace__(namespace, nil, local, local)
|
|
49
79
|
wrapper
|
|
50
80
|
end
|
|
51
81
|
|
|
52
82
|
def create_text_node(text)
|
|
53
|
-
wrap_node(Backend.create_text(text.to_s, @document.
|
|
83
|
+
wrap_node(Backend.create_text(text.to_s, @document.backend_doc))
|
|
54
84
|
end
|
|
55
85
|
|
|
56
86
|
def create_cdata_section(text)
|
|
57
|
-
wrap_node(Backend.create_cdata(text.to_s, @document.
|
|
87
|
+
wrap_node(Backend.create_cdata(text.to_s, @document.backend_doc))
|
|
58
88
|
end
|
|
59
89
|
|
|
60
90
|
def create_comment(text)
|
|
61
|
-
wrap_node(Backend.create_comment(text.to_s, @document.
|
|
91
|
+
wrap_node(Backend.create_comment(text.to_s, @document.backend_doc))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# WHATWG Document.createProcessingInstruction: the target must be a valid
|
|
95
|
+
# XML Name and the data must not contain the PI close delimiter "?>", else
|
|
96
|
+
# InvalidCharacterError. The result is a real backend-backed PI node.
|
|
97
|
+
def create_processing_instruction(target, data)
|
|
98
|
+
t = domstring(target)
|
|
99
|
+
d = domstring(data)
|
|
100
|
+
raise DOMException::InvalidCharacterError, "invalid processing instruction target: #{t.inspect}" unless t.match?(Namespaces::NAME)
|
|
101
|
+
raise DOMException::InvalidCharacterError, "processing instruction data must not contain '?>'" if d.include?("?>")
|
|
102
|
+
|
|
103
|
+
wrap_node(Backend.create_processing_instruction(t, d, @document.backend_doc))
|
|
62
104
|
end
|
|
63
105
|
|
|
64
106
|
def create_document_fragment
|
|
65
|
-
wrap_node(@document.
|
|
107
|
+
wrap_node(@document.backend_doc.fragment(""))
|
|
66
108
|
end
|
|
67
109
|
|
|
68
110
|
def create_attribute(name)
|
|
@@ -70,6 +112,9 @@ module Dommy
|
|
|
70
112
|
raise DOMException::InvalidCharacterError, "name must not be empty" if str.empty?
|
|
71
113
|
raise DOMException::InvalidCharacterError, "invalid attribute name: #{str.inspect}" unless str.match?(Namespaces::NAME)
|
|
72
114
|
|
|
115
|
+
# WHATWG createAttribute: an HTML document lower-cases the name (an XML
|
|
116
|
+
# document preserves it). Attr.new no longer folds case, so do it here.
|
|
117
|
+
str = str.downcase if @document.html_document?
|
|
73
118
|
Attr.new(str)
|
|
74
119
|
end
|
|
75
120
|
|
|
@@ -88,12 +133,12 @@ module Dommy
|
|
|
88
133
|
# DOMString (undefined → "undefined", null → "null").
|
|
89
134
|
namespace_uri = nil if namespace_uri.equal?(Bridge::UNDEFINED)
|
|
90
135
|
qualified_name = domstring(qualified_name)
|
|
91
|
-
ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name)
|
|
136
|
+
ns, prefix, local = Namespaces.validate_and_extract(namespace_uri, qualified_name, context: :element)
|
|
92
137
|
|
|
93
|
-
el = Backend.create_element(qualified_name, @document.
|
|
138
|
+
el = Backend.create_element(qualified_name, @document.backend_doc)
|
|
94
139
|
Backend.add_namespace_definition(el, prefix, ns) if ns
|
|
95
140
|
|
|
96
|
-
wrapper =
|
|
141
|
+
wrapper = build_element_wrapper(el, namespace: ns, local_name: local)
|
|
97
142
|
wrapper.__internal_set_namespace__(ns, prefix, local, qualified_name)
|
|
98
143
|
wrapper
|
|
99
144
|
end
|
|
@@ -102,27 +147,44 @@ module Dommy
|
|
|
102
147
|
|
|
103
148
|
def query_selector(selector)
|
|
104
149
|
return nil if selector.nil?
|
|
105
|
-
Internal.validate_selector!(selector)
|
|
106
150
|
|
|
107
|
-
|
|
151
|
+
key = selector.to_s
|
|
152
|
+
hit = query_cache_get(:first, key)
|
|
153
|
+
return hit.first if hit # [result] tuple — distinguishes a cached nil match from a miss
|
|
154
|
+
|
|
155
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
156
|
+
result = Internal::SelectorMatcher.query_first(@document, ast)
|
|
157
|
+
query_cache_set(:first, key, [result])
|
|
158
|
+
result
|
|
108
159
|
end
|
|
109
160
|
|
|
110
161
|
def query_selector_all(selector)
|
|
111
162
|
return NodeList.new if selector.nil?
|
|
112
|
-
Internal.validate_selector!(selector)
|
|
113
163
|
|
|
114
|
-
|
|
164
|
+
key = selector.to_s
|
|
165
|
+
hit = query_cache_get(:all, key)
|
|
166
|
+
return NodeList.new(hit) if hit # NodeList.new copies, so the cached array is never aliased
|
|
167
|
+
|
|
168
|
+
ast = Internal::SelectorParser.parse!(selector)
|
|
169
|
+
matches = Internal::SelectorMatcher.query(@document, ast)
|
|
170
|
+
query_cache_set(:all, key, matches)
|
|
171
|
+
NodeList.new(matches)
|
|
115
172
|
end
|
|
116
173
|
|
|
117
174
|
def get_element_by_id(id)
|
|
118
175
|
return nil if id.nil? || id.to_s.empty?
|
|
119
176
|
|
|
120
|
-
|
|
177
|
+
# getElementById matches the `id` attribute literally — it is NOT a CSS
|
|
178
|
+
# selector, so an id with selector-special characters (e.g. React's
|
|
179
|
+
# `useId` values like `:rjm:`) is valid and must still resolve. Escape it
|
|
180
|
+
# to a valid id-selector ident before handing it to the backend's CSS
|
|
181
|
+
# engine (a raw "##{id}" would be an invalid selector and raise).
|
|
182
|
+
wrap(@document.backend_doc.at_css("##{Dommy::CSSNamespace.escape(id)}"))
|
|
121
183
|
end
|
|
122
184
|
|
|
123
185
|
def get_elements_by_tag_name(name)
|
|
124
186
|
n = name.to_s.downcase
|
|
125
|
-
doc = @document.
|
|
187
|
+
doc = @document.backend_doc
|
|
126
188
|
cache = self
|
|
127
189
|
if n == "*"
|
|
128
190
|
HTMLCollection.new { doc.css("*").map { |x| cache.wrap(x) }.compact }
|
|
@@ -132,7 +194,7 @@ module Dommy
|
|
|
132
194
|
end
|
|
133
195
|
|
|
134
196
|
def get_elements_by_name(name)
|
|
135
|
-
doc = @document.
|
|
197
|
+
doc = @document.backend_doc
|
|
136
198
|
cache = self
|
|
137
199
|
key = name.to_s
|
|
138
200
|
HTMLCollection.new do
|
|
@@ -142,7 +204,7 @@ module Dommy
|
|
|
142
204
|
|
|
143
205
|
def get_elements_by_class_name(name)
|
|
144
206
|
tokens = name.to_s.split(/\s+/).reject(&:empty?)
|
|
145
|
-
doc = @document.
|
|
207
|
+
doc = @document.backend_doc
|
|
146
208
|
cache = self
|
|
147
209
|
HTMLCollection.new do
|
|
148
210
|
next [] if tokens.empty?
|
|
@@ -154,7 +216,7 @@ module Dommy
|
|
|
154
216
|
|
|
155
217
|
# Clear cached wrapper (used by customElements.define for upgrades)
|
|
156
218
|
def reset_wrapper(nokogiri_node)
|
|
157
|
-
@wrappers.delete(nokogiri_node
|
|
219
|
+
@wrappers.delete(identity_key(nokogiri_node))
|
|
158
220
|
end
|
|
159
221
|
|
|
160
222
|
# Register an externally-built wrapper. Used by
|
|
@@ -162,11 +224,60 @@ module Dommy
|
|
|
162
224
|
# document so the existing Ruby object survives the move
|
|
163
225
|
# rather than being replaced by a freshly-built one.
|
|
164
226
|
def register(nokogiri_node, wrapper)
|
|
165
|
-
@wrappers[nokogiri_node
|
|
227
|
+
@wrappers[identity_key(nokogiri_node)] = wrapper
|
|
166
228
|
end
|
|
167
229
|
|
|
168
230
|
private
|
|
169
231
|
|
|
232
|
+
# Whether a cached wrapper still describes the node now at its identity
|
|
233
|
+
# key. A pure class→nodeType lookup: it must NOT dereference the wrapper's
|
|
234
|
+
# backend node, which may be a freed pointer after identity recycling.
|
|
235
|
+
# Unknown/exotic wrappers (Document, ShadowRoot, …) aren't produced by the
|
|
236
|
+
# fragment-parse paths that recycle identities, so we trust those entries
|
|
237
|
+
# rather than rebuild.
|
|
238
|
+
def cached_wrapper_live?(wrapper, node)
|
|
239
|
+
return true unless node.respond_to?(:node_type)
|
|
240
|
+
|
|
241
|
+
expected =
|
|
242
|
+
case wrapper
|
|
243
|
+
when Fragment then 11
|
|
244
|
+
when CDATASectionNode then 4 # subclass of TextNode — test first
|
|
245
|
+
when TextNode then 3
|
|
246
|
+
when CommentNode then 8
|
|
247
|
+
when ProcessingInstructionNode then 7
|
|
248
|
+
else
|
|
249
|
+
return true unless wrapper.is_a?(Element)
|
|
250
|
+
|
|
251
|
+
1
|
|
252
|
+
end
|
|
253
|
+
expected == node.node_type
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The cached value for [kind, selector] if it was computed in the current
|
|
257
|
+
# DOM generation, else nil (a miss, or a stale entry the caller recomputes).
|
|
258
|
+
def query_cache_get(kind, selector)
|
|
259
|
+
entry = @query_cache[[kind, selector]]
|
|
260
|
+
return nil unless entry && entry[0] == @document.style_generation
|
|
261
|
+
|
|
262
|
+
entry[1]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Store `value` for [kind, selector] tagged with the current generation,
|
|
266
|
+
# clearing the cache wholesale if it has grown past the cap.
|
|
267
|
+
def query_cache_set(kind, selector, value)
|
|
268
|
+
@query_cache.clear if @query_cache.size >= QUERY_CACHE_CAP
|
|
269
|
+
@query_cache[[kind, selector]] = [@document.style_generation, value]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# DOM identity key for a backend node, delegated to the backend since
|
|
273
|
+
# the right key differs: Nokogiri reuses one Ruby wrapper per C node
|
|
274
|
+
# (object_id stable) and may recycle a freed node's pointer, so it keys
|
|
275
|
+
# on object_id; Makiri mints fresh wrappers but never frees nodes, so it
|
|
276
|
+
# keys on the stable node pointer (pointer_id).
|
|
277
|
+
def identity_key(node)
|
|
278
|
+
Backend.identity_key(node)
|
|
279
|
+
end
|
|
280
|
+
|
|
170
281
|
# WebIDL DOMString coercion for a name/qualifiedName argument: JS
|
|
171
282
|
# `undefined` → "undefined", JS `null` (Ruby nil) → "null", else #to_s.
|
|
172
283
|
def domstring(value)
|
|
@@ -191,19 +302,45 @@ module Dommy
|
|
|
191
302
|
TextNode.new(@document, node)
|
|
192
303
|
when Backend.comment_class
|
|
193
304
|
CommentNode.new(@document, node)
|
|
305
|
+
when Backend.processing_instruction_class
|
|
306
|
+
ProcessingInstructionNode.new(@document, node)
|
|
194
307
|
when Backend.document_fragment_class
|
|
195
308
|
Fragment.new(@document, node)
|
|
196
309
|
end
|
|
197
310
|
end
|
|
198
311
|
|
|
199
|
-
|
|
312
|
+
# `namespace`/`local_name` let a caller that already knows the element's
|
|
313
|
+
# namespace and local name (e.g. createElementNS, which preserves case and
|
|
314
|
+
# carries a prefix the backend node name would otherwise fold in) route the
|
|
315
|
+
# wrapper class directly, rather than re-deriving it from the backend.
|
|
316
|
+
#
|
|
317
|
+
# When `namespace` is not supplied we derive it: the backend reports the
|
|
318
|
+
# null namespace for ordinary HTML elements, so in an HTML document an
|
|
319
|
+
# otherwise-namespaceless element is treated as HTML-namespaced (preserving
|
|
320
|
+
# HTML* interface routing); a non-HTML document leaves it null (generic
|
|
321
|
+
# Element). An explicit `namespace:` (including nil from createElementNS)
|
|
322
|
+
# is honored verbatim.
|
|
323
|
+
def build_element_wrapper(node, namespace: NAMESPACE_UNSET, local_name: nil)
|
|
324
|
+
ns =
|
|
325
|
+
if namespace.equal?(NAMESPACE_UNSET)
|
|
326
|
+
Backend.namespace_of(node)&.href || (@document.html_document? ? Element::HTML_NAMESPACE : nil)
|
|
327
|
+
else
|
|
328
|
+
namespace
|
|
329
|
+
end
|
|
330
|
+
# A JS-defined custom element (`customElements.define(name, classExpr)`
|
|
331
|
+
# from page script) registers its JS constructor — a HostCallback — not a
|
|
332
|
+
# Ruby class, so we cannot `.new(@document, node)` it. Wrap such a node as
|
|
333
|
+
# its plain built-in element instead (its server-rendered light-DOM
|
|
334
|
+
# content still displays; the JS upgrade is simply not run). Only a Ruby
|
|
335
|
+
# class definition routes a custom Ruby wrapper + #construct.
|
|
200
336
|
custom_klass = custom_element_class_for(node.name)
|
|
201
|
-
|
|
337
|
+
ruby_custom = custom_klass if custom_klass.is_a?(::Class)
|
|
338
|
+
klass = ruby_custom || Dommy.element_class_for(local_name || node.name, ns)
|
|
202
339
|
instance = klass.new(@document, node)
|
|
203
340
|
|
|
204
|
-
@wrappers[node
|
|
341
|
+
@wrappers[identity_key(node)] = instance
|
|
205
342
|
|
|
206
|
-
if
|
|
343
|
+
if ruby_custom && instance.respond_to?(:construct)
|
|
207
344
|
begin
|
|
208
345
|
instance.construct
|
|
209
346
|
rescue StandardError
|
|
@@ -14,7 +14,8 @@ module Dommy
|
|
|
14
14
|
# `appendChild(child)` — detach the node(s) from any current parent
|
|
15
15
|
# and append to the end of this node's child list.
|
|
16
16
|
def append_child(child)
|
|
17
|
-
|
|
17
|
+
coerce_node_argument!(child)
|
|
18
|
+
ensure_pre_insertion_validity!(child, nil)
|
|
18
19
|
nodes = detach_dom_nodes(child)
|
|
19
20
|
nodes.each { |n| @__node__.add_child(n) }
|
|
20
21
|
notify_child_list(added: nodes)
|
|
@@ -23,6 +24,7 @@ module Dommy
|
|
|
23
24
|
|
|
24
25
|
# ParentNode#append — mixed Node/String args appended in order.
|
|
25
26
|
def append(*args)
|
|
27
|
+
validate_insertion_args!(args)
|
|
26
28
|
nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
|
|
27
29
|
nodes.each { |n| @__node__.add_child(n) }
|
|
28
30
|
notify_child_list(added: nodes)
|
|
@@ -31,6 +33,7 @@ module Dommy
|
|
|
31
33
|
|
|
32
34
|
# ParentNode#prepend — insert before the current first child.
|
|
33
35
|
def prepend(*args)
|
|
36
|
+
validate_insertion_args!(args)
|
|
34
37
|
nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
|
|
35
38
|
anchor = @__node__.children.first
|
|
36
39
|
if anchor
|
|
@@ -45,6 +48,7 @@ module Dommy
|
|
|
45
48
|
# ParentNode#replaceChildren — remove all existing children, then
|
|
46
49
|
# append the new set. One mutation record carries both sides.
|
|
47
50
|
def replace_children(*args)
|
|
51
|
+
validate_insertion_args!(args)
|
|
48
52
|
removed = @__node__.children.to_a
|
|
49
53
|
removed.each(&:unlink)
|
|
50
54
|
nodes = args.flat_map { |arg| detach_dom_nodes(arg) }
|
|
@@ -78,20 +82,37 @@ module Dommy
|
|
|
78
82
|
# be defined before the including class bodies run.
|
|
79
83
|
def detach_dom_nodes(value)
|
|
80
84
|
case value
|
|
81
|
-
when Element, TextNode, CommentNode
|
|
82
|
-
[detach_with_notify(value.__dommy_backend_node__)]
|
|
83
85
|
when Fragment
|
|
84
|
-
value.extract_children
|
|
86
|
+
value.extract_children.map { |n| adopt_into_document(n) }
|
|
85
87
|
when String
|
|
86
88
|
[@document.create_text_node(value).__dommy_backend_node__]
|
|
87
89
|
else
|
|
88
90
|
node = value.respond_to?(:__dommy_backend_node__) ? value.__dommy_backend_node__ : nil
|
|
89
91
|
return [] unless node
|
|
90
92
|
|
|
91
|
-
|
|
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__]
|
|
92
103
|
end
|
|
93
104
|
end
|
|
94
105
|
|
|
106
|
+
# Bring a raw backend node into this node's document (WHATWG adopt). A
|
|
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)
|
|
114
|
+
end
|
|
115
|
+
|
|
95
116
|
# Detach a node from its current parent, queuing a childList removal
|
|
96
117
|
# record on that old parent first (WHATWG "remove" runs before the
|
|
97
118
|
# subsequent insert, so moving a node yields a removal record + an addition
|
|
@@ -121,6 +142,62 @@ module Dommy
|
|
|
121
142
|
def check_insertion!(_child)
|
|
122
143
|
nil
|
|
123
144
|
end
|
|
145
|
+
|
|
146
|
+
# WebIDL coercion for an `appendChild`/`insertBefore`/`replaceChild`
|
|
147
|
+
# argument typed `Node`: a null / undefined / non-Node value is a
|
|
148
|
+
# TypeError before any DOM step runs.
|
|
149
|
+
def coerce_node_argument!(value)
|
|
150
|
+
return value if value.is_a?(Dommy::Node)
|
|
151
|
+
|
|
152
|
+
raise Bridge::TypeError, "Argument is not a Node."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# WHATWG "ensure pre-insertion validity" for an element-like parent
|
|
156
|
+
# (Element / DocumentFragment / ShadowRoot — never a Document, so the
|
|
157
|
+
# document-only constraints in step 6 don't apply here). Steps run in
|
|
158
|
+
# spec order so the observable error matches: ancestor check (2), the
|
|
159
|
+
# reference child's parentage (3), the node's type (4), then the doctype
|
|
160
|
+
# placement rule (5).
|
|
161
|
+
def ensure_pre_insertion_validity!(node, child)
|
|
162
|
+
# Step 2 — node must not be an inclusive ancestor of this parent.
|
|
163
|
+
check_insertion!(node)
|
|
164
|
+
|
|
165
|
+
# Step 3 — a non-null reference child must be a child of this parent.
|
|
166
|
+
unless child.nil? || (defined?(Bridge::UNDEFINED) && child.equal?(Bridge::UNDEFINED))
|
|
167
|
+
ref = child.respond_to?(:__dommy_backend_node__) ? child.__dommy_backend_node__ : nil
|
|
168
|
+
unless ref && ref.parent == @__node__
|
|
169
|
+
raise DOMException::NotFoundError, "The reference child is not a child of this node."
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Step 4 — only an insertable node type may be inserted.
|
|
174
|
+
unless insertable_child?(node)
|
|
175
|
+
raise DOMException::HierarchyRequestError, "This node type cannot be inserted here."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Step 5 — a doctype may only be a child of a document, never of an
|
|
179
|
+
# element-like parent.
|
|
180
|
+
return unless node.is_a?(Dommy::DocumentType)
|
|
181
|
+
|
|
182
|
+
raise DOMException::HierarchyRequestError, "A doctype may only be a child of a document."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Validate each Node argument of append / prepend / replaceChildren (which
|
|
186
|
+
# also accept DOMStrings — those are always insertable as text, so skip
|
|
187
|
+
# anything that isn't a Node).
|
|
188
|
+
def validate_insertion_args!(args)
|
|
189
|
+
args.each { |arg| ensure_pre_insertion_validity!(arg, nil) if arg.is_a?(Dommy::Node) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The node types that may be inserted under an element-like parent:
|
|
193
|
+
# DocumentFragment, DocumentType, Element, and CharacterData (Text /
|
|
194
|
+
# Comment / CDATASection / ProcessingInstruction — all CharacterDataNode
|
|
195
|
+
# subclasses). A Document or Attr is not insertable.
|
|
196
|
+
def insertable_child?(value)
|
|
197
|
+
value.is_a?(Dommy::Element) || value.is_a?(Dommy::Fragment) ||
|
|
198
|
+
value.is_a?(Dommy::CharacterDataNode) ||
|
|
199
|
+
value.is_a?(Dommy::DocumentType)
|
|
200
|
+
end
|
|
124
201
|
end
|
|
125
202
|
end
|
|
126
203
|
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
module SelectorAST
|
|
6
|
+
Specificity = Struct.new(:a, :b, :c) do
|
|
7
|
+
def +(other)
|
|
8
|
+
self.class.new(a + other.a, b + other.b, c + other.c)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_a = [a, b, c]
|
|
12
|
+
|
|
13
|
+
def <=>(other)
|
|
14
|
+
to_a <=> other.to_a
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ZERO = Specificity.new(0, 0, 0).freeze
|
|
19
|
+
ID = Specificity.new(1, 0, 0).freeze
|
|
20
|
+
CLASS = Specificity.new(0, 1, 0).freeze
|
|
21
|
+
TYPE = Specificity.new(0, 0, 1).freeze
|
|
22
|
+
|
|
23
|
+
SelectorList = Struct.new(:selectors) do
|
|
24
|
+
def specificity
|
|
25
|
+
selectors.map(&:specificity).max || ZERO
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ComplexSelector = Struct.new(:parts) do
|
|
30
|
+
def rightmost = parts.last&.compound
|
|
31
|
+
|
|
32
|
+
def pseudo_element
|
|
33
|
+
rightmost&.pseudo_element
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pseudo_element?
|
|
37
|
+
!pseudo_element.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def without_pseudo_element
|
|
41
|
+
return self unless pseudo_element?
|
|
42
|
+
|
|
43
|
+
duped = parts.map { |part| Part.new(part.combinator, part.compound) }
|
|
44
|
+
last = duped.last
|
|
45
|
+
last.compound = last.compound.without_pseudo_element
|
|
46
|
+
self.class.new(duped)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def specificity
|
|
50
|
+
parts.reduce(ZERO) { |sum, part| sum + part.compound.specificity }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Part = Struct.new(:combinator, :compound)
|
|
55
|
+
RelativeSelector = Struct.new(:leading_combinator, :complex)
|
|
56
|
+
|
|
57
|
+
CompoundSelector = Struct.new(:type, :subclass_selectors, :pseudo_element) do
|
|
58
|
+
def without_pseudo_element
|
|
59
|
+
self.class.new(type, subclass_selectors, nil)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def specificity
|
|
63
|
+
sum = type ? type.specificity : ZERO
|
|
64
|
+
subclass_selectors.each { |selector| sum += selector.specificity }
|
|
65
|
+
sum += pseudo_element.specificity if pseudo_element
|
|
66
|
+
sum
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
TypeSelector = Struct.new(:namespace, :name) do
|
|
71
|
+
def specificity = TYPE
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
UniversalSelector = Struct.new(:namespace) do
|
|
75
|
+
def specificity = ZERO
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
IdSelector = Struct.new(:value) do
|
|
79
|
+
def specificity = ID
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
ClassSelector = Struct.new(:value) do
|
|
83
|
+
def specificity = CLASS
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
AttributeSelector = Struct.new(:namespace, :name, :matcher, :value, :case_flag) do
|
|
87
|
+
def specificity = CLASS
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
PseudoClass = Struct.new(:name, :argument) do
|
|
91
|
+
def specificity
|
|
92
|
+
case name
|
|
93
|
+
when "is", "not", "has"
|
|
94
|
+
argument_specificity
|
|
95
|
+
when "where"
|
|
96
|
+
ZERO
|
|
97
|
+
when "nth-child", "nth-last-child"
|
|
98
|
+
CLASS + (argument&.of_selector_list&.specificity || ZERO)
|
|
99
|
+
else
|
|
100
|
+
CLASS
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def argument_specificity
|
|
107
|
+
if argument.respond_to?(:specificity)
|
|
108
|
+
argument.specificity
|
|
109
|
+
elsif argument.respond_to?(:map)
|
|
110
|
+
argument.map { |item| item.respond_to?(:complex) ? item.complex.specificity : ZERO }.max || ZERO
|
|
111
|
+
else
|
|
112
|
+
ZERO
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
PseudoElement = Struct.new(:name, :argument) do
|
|
118
|
+
def specificity = TYPE
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
NthExpression = Struct.new(:a, :b, :of_selector_list)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|