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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. 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
- cached = @wrappers[node.object_id]
20
- return cached if cached
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[node.object_id] = wrapper if wrapper
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::NAME)
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.nokogiri_doc))
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.nokogiri_doc))
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.nokogiri_doc))
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.nokogiri_doc))
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.nokogiri_doc.fragment(""))
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.nokogiri_doc)
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 = wrap(el)
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
- wrap(@document.nokogiri_doc.at_css(Internal.backend_safe_selector(selector.to_s), CSS_PSEUDO_HANDLERS))
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
- NodeList.new(@document.nokogiri_doc.css(Internal.backend_safe_selector(selector.to_s), CSS_PSEUDO_HANDLERS).map { |node| wrap(node) }.compact)
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
- wrap(@document.nokogiri_doc.at_css("##{id}"))
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.nokogiri_doc
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.nokogiri_doc
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.nokogiri_doc
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.object_id)
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.object_id] = wrapper
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
- def build_element_wrapper(node)
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
- klass = custom_klass || Dommy.element_class_for(node.name, Backend.namespace_of(node)&.href)
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.object_id] = instance
341
+ @wrappers[identity_key(node)] = instance
205
342
 
206
- if custom_klass && instance.respond_to?(:construct)
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
- check_insertion!(child)
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
- [detach_with_notify(node)]
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