dommy 0.11.0 → 0.12.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/backend/makiri_adapter.rb +2 -14
  3. data/lib/dommy/backend.rb +0 -5
  4. data/lib/dommy/custom_elements.rb +22 -16
  5. data/lib/dommy/document.rb +236 -42
  6. data/lib/dommy/element.rb +69 -20
  7. data/lib/dommy/event.rb +6 -3
  8. data/lib/dommy/internal/child_node.rb +1 -5
  9. data/lib/dommy/internal/leaf_node.rb +2 -8
  10. data/lib/dommy/internal/mutation_coordinator.rb +46 -19
  11. data/lib/dommy/internal/node_wrapper_cache.rb +23 -1
  12. data/lib/dommy/internal/parent_node.rb +14 -74
  13. data/lib/dommy/internal/range_text_serializer.rb +2 -1
  14. data/lib/dommy/internal/selector_ast.rb +17 -0
  15. data/lib/dommy/internal/selector_matcher.rb +20 -21
  16. data/lib/dommy/internal/selector_parser.rb +104 -32
  17. data/lib/dommy/internal/shadow_root_registry.rb +12 -1
  18. data/lib/dommy/internal/web_idl.rb +57 -0
  19. data/lib/dommy/js/custom_element_bridge.rb +10 -0
  20. data/lib/dommy/js/dom_interfaces.rb +14 -4
  21. data/lib/dommy/js/handle_table.rb +15 -0
  22. data/lib/dommy/js/host_bridge.rb +14 -0
  23. data/lib/dommy/js/host_runtime.js +79 -13
  24. data/lib/dommy/js/marshaller.rb +1 -0
  25. data/lib/dommy/node.rb +19 -3
  26. data/lib/dommy/range.rb +116 -183
  27. data/lib/dommy/selection.rb +422 -0
  28. data/lib/dommy/shadow_root.rb +8 -3
  29. data/lib/dommy/static_range.rb +65 -0
  30. data/lib/dommy/svg_elements.rb +45 -0
  31. data/lib/dommy/tree_walker.rb +9 -17
  32. data/lib/dommy/url_pattern/canonicalizer.rb +183 -0
  33. data/lib/dommy/url_pattern/component.rb +64 -0
  34. data/lib/dommy/url_pattern/constructor_string_parser.rb +281 -0
  35. data/lib/dommy/url_pattern/init_processor.rb +163 -0
  36. data/lib/dommy/url_pattern/pattern_parser.rb +343 -0
  37. data/lib/dommy/url_pattern/regexp_translator.rb +694 -0
  38. data/lib/dommy/url_pattern/tokenizer.rb +199 -0
  39. data/lib/dommy/url_pattern.rb +310 -103
  40. data/lib/dommy/version.rb +1 -1
  41. data/lib/dommy/window.rb +2 -1
  42. data/lib/dommy.rb +3 -0
  43. metadata +13 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47c59ce209a6e63313db2b6685126910463abfc26020ec5f24a7b7494b67b10f
4
- data.tar.gz: 43980f4f5eb7b2c5bfe76e41bd2786e9c8cad571df1eb882e0e6d5107fbe04c6
3
+ metadata.gz: 6306b86751026b1b48072c497fd34cd008c5b0bfe1838ae62ab3896d89a2aa3f
4
+ data.tar.gz: 817d56deed527b5f2718242937bb1a30a7d22738ca8f09276750505cf9368b5a
5
5
  SHA512:
6
- metadata.gz: b2627fa8969554f7ecb7a04ed16c331a77e604b44a9ca9e95412a89e48412744a3dd606892255e93eb56988cfda5214000a15821bbc5eb6ed7d18701740b3262
7
- data.tar.gz: 632bddbea02093111566d46ba9fc44af35ed17879bf9921c4ad477a138d44b83e2c2761337d34f5bdfa31cc150353a4b7afe47b0cca8d817e79eda95d7b556ad
6
+ metadata.gz: bfce9a98c80d522db534045b6ee378d0bdfb8e1f03646173e4fc74fd5e8ed46e35e723fe4ce5f7828cdab9d71b6e567c2022ad63d01f2f70af8a0baf0acd34cf
7
+ data.tar.gz: 6b0aa54054201df72970198f18450268d53cbe434b3da15bd9b49cd68eb98b9d94ae1acf80d40b668706052385fa8597e4580b683c472760ee970a10ee13d2f4
@@ -43,18 +43,6 @@ module Dommy
43
43
  node.clone_node(deep)
44
44
  end
45
45
 
46
- # Makiri documents have no node-level clone; re-parsing the serialized
47
- # document reproduces the full tree. Dispatch on the document kind so an XML
48
- # document round-trips through the XML parser (case/namespaces/CDATA) and an
49
- # HTML document through the HTML parser.
50
- def clone_document(doc)
51
- if doc.is_a?(::Makiri::XML::Document)
52
- ::Makiri::XML::Document.parse(doc.to_xml)
53
- else
54
- ::Makiri::HTML::Document.parse(doc.to_html)
55
- end
56
- end
57
-
58
46
  # A fresh, empty HTML-backed document (children dropped so it starts with no
59
47
  # documentElement). The backing for a shallow clone of an HTML document.
60
48
  def empty_document
@@ -233,8 +221,8 @@ module Dommy
233
221
  end
234
222
 
235
223
  # Both Makiri document families (HTML and XML) mint a real PI node and
236
- # serialize it (HTML as `<?target data>`, XML as `<?target data?>`), so PIs
237
- # — unlike CDATA — need no HTML-document fallback.
224
+ # serialize it as `<?target data?>`, so PIs — unlike CDATA — need no
225
+ # HTML-document fallback.
238
226
  def create_processing_instruction(target, data, doc)
239
227
  doc.create_processing_instruction(target, data)
240
228
  end
data/lib/dommy/backend.rb CHANGED
@@ -53,11 +53,6 @@ module Dommy
53
53
  current.clone_node(node, deep: deep)
54
54
  end
55
55
 
56
- # Deep copy of a whole document (DOM cloneNode on the document).
57
- def clone_document(doc)
58
- current.clone_document(doc)
59
- end
60
-
61
56
  # A fresh, empty HTML-backed document.
62
57
  def empty_document
63
58
  current.empty_document
@@ -86,19 +86,16 @@ module Dommy
86
86
  promise
87
87
  end
88
88
 
89
- # Walk `root`'s subtree and re-wrap any nodes whose tag is now
89
+ # Walk `root`'s shadow-including subtree and re-wrap any nodes whose tag is now
90
90
  # registered; fires `connectedCallback` for each upgraded node
91
91
  # that's currently attached to a document tree.
92
92
  def upgrade(root)
93
93
  return nil unless root.respond_to?(:__dommy_backend_node__)
94
94
 
95
- walk_descendants(root.__dommy_backend_node__) do |nk|
96
- next unless nk.element?
95
+ @window.document.__internal_each_shadow_including_element__(root.__dommy_backend_node__) do |nk|
97
96
  next unless @definitions.key?(nk.name)
98
97
 
99
- # Force re-wrap by clearing the document's cached wrapper.
100
- @window.document.__internal_reset_wrapper__(nk)
101
- wrapped = @window.document.wrap_node(nk)
98
+ wrapped = rewrap_for_upgrade(@window.document, nk)
102
99
  next unless wrapped
103
100
 
104
101
  replay_observed_attributes(wrapped)
@@ -137,6 +134,22 @@ module Dommy
137
134
  list&.each { |p| p.fulfill(klass) }
138
135
  end
139
136
 
137
+ # Re-wrap `nk` as its definition's class, or nil when there is nothing to
138
+ # upgrade. An element that is already an instance of the class is left alone:
139
+ # upgrading an element that is already custom does nothing. The new wrapper
140
+ # is told which one it replaces, because a script may hold the element — a
141
+ # JS-defined element upgrades that reference in place.
142
+ def rewrap_for_upgrade(doc, nk)
143
+ previous = doc.__internal_peek_wrapper__(nk)
144
+ klass = @definitions[nk.name]
145
+ return nil if klass.is_a?(Module) && previous.is_a?(klass)
146
+
147
+ doc.__internal_reset_wrapper__(nk)
148
+ wrapped = doc.wrap_node(nk)
149
+ wrapped.__internal_upgraded_from__(previous) if previous && wrapped.respond_to?(:__internal_upgraded_from__)
150
+ wrapped
151
+ end
152
+
140
153
  # When define() lands after the matching element is already in
141
154
  # the document, those nodes need upgrading: re-wrap them with the
142
155
  # new class and fire connectedCallback.
@@ -145,11 +158,11 @@ module Dommy
145
158
  # Match by tag name rather than interpolating `name` into a CSS selector:
146
159
  # a spec-valid custom element name may contain "." (a CSS class selector
147
160
  # char) or other metacharacters, which would corrupt the query.
148
- doc.backend_doc.css("*").each do |nk|
161
+ # Shadow trees included, in shadow-including tree order (define step 18).
162
+ doc.__internal_each_shadow_including_element__(doc.backend_doc) do |nk|
149
163
  next unless nk.name == name
150
164
 
151
- doc.__internal_reset_wrapper__(nk)
152
- wrapped = doc.wrap_node(nk)
165
+ wrapped = rewrap_for_upgrade(doc, nk)
153
166
  next unless wrapped
154
167
 
155
168
  replay_observed_attributes(wrapped)
@@ -178,12 +191,5 @@ module Dommy
178
191
  )
179
192
  end
180
193
  end
181
-
182
- def walk_descendants(node, &blk)
183
- yield node
184
- return unless node.respond_to?(:children)
185
-
186
- node.children.each { |c| walk_descendants(c, &blk) }
187
- end
188
194
  end
189
195
  end
@@ -141,6 +141,12 @@ module Dommy
141
141
  end
142
142
  private :synthetic_insert
143
143
 
144
+ # A doctype is connected when its tree is a document's; a synthetic one is
145
+ # never in a tree at all.
146
+ def is_connected?
147
+ get_root_node.is_a?(Dommy::Document)
148
+ end
149
+
144
150
  def __js_get__(key)
145
151
  case key
146
152
  when "name"
@@ -168,6 +174,8 @@ module Dommy
168
174
  NodeList.new
169
175
  when "firstChild", "lastChild"
170
176
  nil
177
+ when "isConnected"
178
+ is_connected?
171
179
  end
172
180
  end
173
181
 
@@ -954,15 +962,7 @@ module Dommy
954
962
  # Ruby Proc, a JS-bridge callable, or an object with
955
963
  # `accept_node` / `acceptNode`.
956
964
  def create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
957
- TreeWalker.new(require_node_root(root), what_to_show, filter)
958
- end
959
-
960
- # The `root` of a TreeWalker / NodeIterator is a non-nullable WebIDL `Node`:
961
- # a null or non-Node argument is a TypeError before construction.
962
- def require_node_root(root)
963
- return root if root.is_a?(Dommy::Node)
964
-
965
- raise Bridge::TypeError, "createTreeWalker/createNodeIterator root must be a Node"
965
+ TreeWalker.new(Internal::WebIDL.node!(root), what_to_show, filter)
966
966
  end
967
967
 
968
968
  # WebIDL `unsigned long whatToShow = 0xFFFFFFFF`: an omitted or `undefined`
@@ -988,6 +988,12 @@ module Dommy
988
988
  # wrapper is owned by `this`. Per spec, the source node is left
989
989
  # in place. `deep: true` copies the entire subtree.
990
990
  def import_node(node, deep = false)
991
+ # Step 1: "If node is a document or shadow root, throw a
992
+ # NotSupportedError." Neither has anywhere to go in another document.
993
+ if node.is_a?(Document) || node.is_a?(ShadowRoot)
994
+ raise DOMException::NotSupportedError, "a #{node.is_a?(Document) ? "document" : "shadow root"} cannot be imported"
995
+ end
996
+
991
997
  # An Attr is a Node but not a backend-tree node: it is copied by rebuilding
992
998
  # it here with the same qualified name, namespace, prefix and value, owned
993
999
  # by no element (importNode never attaches the copy to anything).
@@ -1105,7 +1111,11 @@ module Dommy
1105
1111
 
1106
1112
  alias has_focus has_focus?
1107
1113
 
1114
+ # A document without a browsing context (one built by DOMParser or
1115
+ # createHTMLDocument) has no selection.
1108
1116
  def get_selection
1117
+ return nil unless @default_view
1118
+
1109
1119
  @__selection ||= Selection.new(self)
1110
1120
  end
1111
1121
 
@@ -1147,7 +1157,7 @@ module Dommy
1147
1157
  # `document.createNodeIterator(root, whatToShow?, filter?)` —
1148
1158
  # flat depth-first iteration.
1149
1159
  def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
1150
- root = require_node_root(root)
1160
+ root = Internal::WebIDL.node!(root)
1151
1161
  iterator = NodeIterator.new(root, what_to_show, filter)
1152
1162
  # The "NodeIterator pre-removing steps" run for iterators whose root's node
1153
1163
  # document is the removed node's document. Track the iterator on the root's
@@ -1348,6 +1358,8 @@ module Dommy
1348
1358
  #
1349
1359
  # Spec: https://dom.spec.whatwg.org/#dom-parentnode-movebefore
1350
1360
  def move_before(node, child = nil)
1361
+ Internal::WebIDL.node!(node)
1362
+ Internal::WebIDL.nullable_node!(child)
1351
1363
  bn = move_backend_node(node)
1352
1364
  ref_bn = move_backend_node(child)
1353
1365
  ref_bn = ref_bn.next_sibling if ref_bn && bn && ref_bn == bn
@@ -1356,7 +1368,7 @@ module Dommy
1356
1368
  old_parent = bn.parent
1357
1369
  old_previous = bn.previous_sibling
1358
1370
  old_next = bn.next_sibling
1359
- detach_node(bn) # steps 10-11, 14
1371
+ detach_node(bn, moving: true) # steps 10-11, 14
1360
1372
  ref_bn = nil if ref_bn && ref_bn.parent != @backend_doc
1361
1373
  __internal_ranges_will_insert__(@backend_doc, ref_bn, 1) # step 16
1362
1374
  new_previous = ref_bn ? ref_bn.previous_sibling : @backend_doc.children.to_a.last
@@ -1365,11 +1377,12 @@ module Dommy
1365
1377
  notify_child_list_mutation(
1366
1378
  target_node: old_parent, added_nodes: [], removed_nodes: [bn],
1367
1379
  previous_sibling: old_previous && wrap_node(old_previous),
1368
- next_sibling: old_next && wrap_node(old_next)
1380
+ next_sibling: old_next && wrap_node(old_next), moving: true
1369
1381
  )
1370
1382
  end
1371
1383
  notify_document_child_list(added: [bn], previous_sibling: new_previous && wrap_node(new_previous),
1372
- next_sibling: ref_bn && wrap_node(ref_bn))
1384
+ next_sibling: ref_bn && wrap_node(ref_bn), moving: true)
1385
+ __internal_notify_moved_subtree__(bn) # step 19.3, into a document
1373
1386
  nil
1374
1387
  end
1375
1388
 
@@ -1582,11 +1595,18 @@ module Dommy
1582
1595
  nil
1583
1596
  end
1584
1597
 
1585
- # `document.cloneNode(deep)` → a fresh Document over a (deep) copy of the
1586
- # Makiri tree, preserving the content type.
1598
+ # `document.cloneNode(deep)` → a fresh Document of the same kind, keeping
1599
+ # the content type. "Clone a single node" creates a document EMPTY, and a
1600
+ # deep clone then copies the original's children into it — so nothing
1601
+ # sprouts an html/head/body of its own, and a document with no children
1602
+ # clones to one with no children.
1587
1603
  def clone_node(deep)
1588
- copy = deep ? Backend.clone_document(@backend_doc) : Backend.empty_document_like(@backend_doc)
1589
- Document.new(nil, backend_doc: copy).tap { |d| d.content_type = @content_type }
1604
+ copy = Document.new(nil, backend_doc: Backend.empty_document_like(@backend_doc))
1605
+ copy.content_type = @content_type
1606
+ return copy unless deep
1607
+
1608
+ child_nodes.each { |child| copy.append_child(copy.import_node(child, true)) }
1609
+ copy
1590
1610
  end
1591
1611
 
1592
1612
  def backend_node(node)
@@ -1987,9 +2007,7 @@ module Dommy
1987
2007
  document_remove_child(args[0])
1988
2008
  when "insertBefore"
1989
2009
  raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
1990
- unless args[1].nil? || args[1].equal?(Bridge::UNDEFINED) || args[1].is_a?(Dommy::Node)
1991
- raise Bridge::TypeError, "The reference child is not a Node."
1992
- end
2010
+ Internal::WebIDL.nullable_node!(args[1])
1993
2011
 
1994
2012
  document_insert_before(args[0], args[1])
1995
2013
  when "replaceChild"
@@ -2002,7 +2020,7 @@ module Dommy
2002
2020
  when "cloneNode"
2003
2021
  clone_node(args[0])
2004
2022
  when "normalize"
2005
- nil # the document has no text children to merge
2023
+ normalize
2006
2024
  when "writeln"
2007
2025
  write(*(args + ["\n"]))
2008
2026
  when "exitFullscreen"
@@ -2208,6 +2226,33 @@ module Dommy
2208
2226
  @shadow_registry.find_for_fragment(fragment_node)
2209
2227
  end
2210
2228
 
2229
+ def __internal_shadow_root_for_host__(host_node)
2230
+ @shadow_registry.find_for_host(host_node)
2231
+ end
2232
+
2233
+ # Every element among `root`'s shadow-including inclusive descendants, as
2234
+ # backend nodes, in shadow-including tree order: an element, then the shadow
2235
+ # tree it hosts, then its children. The whole list, shadow trees included, is
2236
+ # taken before the first element is yielded. Callbacks run synchronously here,
2237
+ # and one that attaches a shadow root or inserts elements triggers those
2238
+ # changes' own reactions; walking into them as well would run them twice.
2239
+ def __internal_each_shadow_including_element__(root, &block)
2240
+ shadow_including_elements(root).each(&block)
2241
+ nil
2242
+ end
2243
+
2244
+ def shadow_including_elements(root, list = [])
2245
+ elements = root.respond_to?(:element?) && root.element? ? [root] : []
2246
+ elements.concat(root.css("*").to_a) if root.respond_to?(:css)
2247
+ elements.each do |element|
2248
+ list << element
2249
+ shadow = @shadow_registry.find_for_host(element)
2250
+ shadow_including_elements(shadow.__dommy_backend_node__, list) if shadow
2251
+ end
2252
+ list
2253
+ end
2254
+ private :shadow_including_elements
2255
+
2211
2256
  def __internal_shadow_root_containing__(node)
2212
2257
  @shadow_registry.find_enclosing(node)
2213
2258
  end
@@ -2235,6 +2280,12 @@ module Dommy
2235
2280
  @mutation_coordinator.notify_connected_subtree(nk)
2236
2281
  end
2237
2282
 
2283
+ # The custom element reactions of a move (moveBefore), for a node that ended
2284
+ # up connected.
2285
+ def __internal_notify_moved_subtree__(nk)
2286
+ @mutation_coordinator.notify_moved_subtree(nk)
2287
+ end
2288
+
2238
2289
  def __internal_notify_disconnected_subtree__(nk)
2239
2290
  @mutation_coordinator.notify_disconnected_subtree(nk)
2240
2291
  end
@@ -2260,14 +2311,16 @@ module Dommy
2260
2311
  added_nodes:,
2261
2312
  removed_nodes:,
2262
2313
  previous_sibling: nil,
2263
- next_sibling: nil
2314
+ next_sibling: nil,
2315
+ moving: false
2264
2316
  )
2265
2317
  @mutation_coordinator.notify_child_list_mutation(
2266
2318
  target_node: target_node,
2267
2319
  added_nodes: added_nodes,
2268
2320
  removed_nodes: removed_nodes,
2269
2321
  previous_sibling: previous_sibling,
2270
- next_sibling: next_sibling
2322
+ next_sibling: next_sibling,
2323
+ moving: moving
2271
2324
  )
2272
2325
  end
2273
2326
 
@@ -2302,13 +2355,14 @@ module Dommy
2302
2355
  node && wrap_node(node)
2303
2356
  end
2304
2357
 
2305
- def notify_document_child_list(added: [], removed: [], previous_sibling: nil, next_sibling: nil)
2358
+ def notify_document_child_list(added: [], removed: [], previous_sibling: nil, next_sibling: nil, moving: false)
2306
2359
  notify_child_list_mutation(
2307
2360
  target_node: @backend_doc,
2308
2361
  added_nodes: added,
2309
2362
  removed_nodes: removed,
2310
2363
  previous_sibling: previous_sibling,
2311
- next_sibling: next_sibling
2364
+ next_sibling: next_sibling,
2365
+ moving: moving
2312
2366
  )
2313
2367
  end
2314
2368
 
@@ -2316,10 +2370,18 @@ module Dommy
2316
2370
  # batch several removals into one childList record (replaceChildren,
2317
2371
  # textContent=, replaceChild) use this and queue the record themselves;
2318
2372
  # `remove_node_with_notify` is this plus a per-node record.
2319
- def detach_node(node)
2373
+ #
2374
+ # `moving:` marks the first half of the "move" primitive (moveBefore). A
2375
+ # move takes the node out of its old parent's children only to put it
2376
+ # straight back into the same shadow-including tree: it never leaves the
2377
+ # document, so nothing that reacts to a removal from the document may run.
2378
+ def detach_node(node, moving: false)
2320
2379
  pre_remove_node(node)
2321
2380
  add_transient_observers_for(node)
2322
2381
  node.unlink
2382
+ # A removal can take a shadow tree, and a selection range in it, out of
2383
+ # the document without moving the range; the selection lets go of it.
2384
+ @__selection&.__internal_node_removed__ unless moving
2323
2385
  node
2324
2386
  end
2325
2387
 
@@ -2455,6 +2517,73 @@ module Dommy
2455
2517
  end
2456
2518
  end
2457
2519
 
2520
+ # Node.normalize() on the document: every Text run in the tree.
2521
+ def normalize
2522
+ __internal_normalize__(@backend_doc)
2523
+ end
2524
+
2525
+ # Node#normalize — merge each run of adjacent exclusive Text descendants
2526
+ # into its first node (preserving that node's identity, so a JS reference
2527
+ # to it survives) and drop empty Text nodes. Recurses the whole subtree,
2528
+ # so it serves Element, DocumentFragment, ShadowRoot and the Document alike.
2529
+ #
2530
+ # A run is merged one sibling at a time: append the sibling's data to the
2531
+ # survivor (a characterData record), hand its live range boundaries over,
2532
+ # remove it (a childList record), then the next. Read literally, the spec
2533
+ # concatenates every sibling's data first (steps 3-4, one "replace data")
2534
+ # and removes them afterwards (step 7), which would queue ONE
2535
+ # characterData record per run. Every shipping engine merges pairwise
2536
+ # instead — Blink, WebCore and Gecko all answer [characterData, childList,
2537
+ # characterData, childList, …] for a run of four text nodes, confirmed by
2538
+ # running the same script in Chromium 141, WebKitGTK 2.52.6 and Firefox —
2539
+ # and the WPT suite fixes only the childList side, so the records follow
2540
+ # the engines. The tree and every live range boundary end up exactly where
2541
+ # the spec's steps put them: a boundary in a later sibling (or on the
2542
+ # parent, pointing at one) is shifted down by each earlier removal and
2543
+ # then handed over at the survivor's length of that moment, which is the
2544
+ # same offset the batch steps compute up front.
2545
+ # https://github.com/takahashim/dommy/issues/24
2546
+ def __internal_normalize__(root)
2547
+ text_nodes = []
2548
+ root.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
2549
+
2550
+ text_nodes.each do |node|
2551
+ next unless node.parent # already removed as part of an earlier run
2552
+
2553
+ if node.content.to_s.empty?
2554
+ remove_node_with_notify(node)
2555
+ next
2556
+ end
2557
+
2558
+ sib = node.next
2559
+ while sib.respond_to?(:text?) && sib.text?
2560
+ following = sib.next
2561
+ data = sib.content.to_s
2562
+ # The offset the sibling's data lands at inside the survivor — the
2563
+ # length of what it already holds, measured before the append.
2564
+ length = wrap_node(node).length
2565
+ # An empty sibling has nothing to append: the engines skip the data
2566
+ # step for it (Gecko checks the length, Blink and WebCore behave the
2567
+ # same), so it is removed without a characterData record. Its range
2568
+ # boundaries still move to the survivor's join.
2569
+ unless data.empty?
2570
+ old = node.content.to_s
2571
+ node.content = old + data
2572
+ notify_character_data_mutation(target_node: node, old_value: old)
2573
+ end
2574
+ # WHATWG normalize() step 6: the merged-away sibling hands its live
2575
+ # range boundaries to the survivor at that offset BEFORE it is
2576
+ # removed, or the plain removing steps would strand them on the
2577
+ # parent.
2578
+ __internal_ranges_normalize_merge__(node, sib, length)
2579
+ remove_node_with_notify(sib)
2580
+ sib = following
2581
+ end
2582
+ end
2583
+
2584
+ nil
2585
+ end
2586
+
2458
2587
  def __internal_ranges_replaced_data__(node, offset, count, new_length)
2459
2588
  return unless live_ranges?
2460
2589
 
@@ -2574,40 +2703,105 @@ module Dommy
2574
2703
  # `deep: true` recurses into children. Used by importNode and
2575
2704
  # adoptNode for cross-document transfer.
2576
2705
  def clone_into_doc(source, deep, source_document = self)
2577
- copy = if source.element?
2578
- new_el = Backend.create_element(source.name, @backend_doc)
2579
- Backend.attribute_nodes(source).each { |a| new_el[a.name] = a.value }
2580
- new_el
2706
+ copy = clone_single_node_into_doc(source, source_document)
2707
+
2708
+ if source.element? && source.name == "template"
2709
+ # A <template>'s contents live in a separate content fragment, not its
2710
+ # child list, so the generic deep pass over `children` misses them.
2711
+ clone_template_content(source, copy, source_document) if deep
2712
+ elsif deep && source.respond_to?(:children)
2713
+ source.children.each do |child|
2714
+ copy.add_child(clone_into_doc(child, true, source_document))
2715
+ end
2716
+ end
2717
+
2718
+ copy
2719
+ end
2720
+
2721
+ # "Clone a single node" (§4.4): the copy implements the SAME interface as
2722
+ # the original — a ProcessingInstruction clones to one, not to the comment
2723
+ # its serialization looks like — and carries the same data. Elements are
2724
+ # cloned by #clone_element_into_doc; children are the caller's business.
2725
+ def clone_single_node_into_doc(source, source_document)
2726
+ if source.element?
2727
+ clone_element_into_doc(source, source_document)
2728
+ elsif (cdata = Backend.cdata_class) && source.is_a?(cdata)
2729
+ # CDATA is a Text subtype in the backend, so ask about it first.
2730
+ Backend.create_cdata(source.content, @backend_doc)
2581
2731
  elsif source.text?
2582
2732
  Backend.create_text(source.content, @backend_doc)
2583
2733
  elsif source.is_a?(Backend.comment_class)
2584
2734
  Backend.create_comment(source.content, @backend_doc)
2735
+ elsif source.is_a?(Backend.processing_instruction_class)
2736
+ Backend.create_processing_instruction(source.target, source.content, @backend_doc)
2585
2737
  elsif source.is_a?(Backend.document_fragment_class)
2586
2738
  # A DocumentFragment clones to a fragment (its children are appended by
2587
- # the deep pass below), NOT to its first child — `importNode(<template>
2739
+ # the deep pass), NOT to its first child — `importNode(<template>
2588
2740
  # .content, true)` must return a fragment so `.firstElementChild` works
2589
2741
  # (Vue/Alpine x-for clone template content this way). Built via the
2590
2742
  # document's own `fragment` (as TemplateContentRegistry does) rather than
2591
2743
  # `document_fragment_class.new`, so it works on backends whose fragment
2592
2744
  # class isn't directly instantiable (Makiri).
2593
2745
  Parser.fragment("", owner_doc: @backend_doc)
2746
+ elsif (doctype = Backend.document_type_class) && source.is_a?(doctype)
2747
+ wrapper = source_document.wrap_node(source)
2748
+ Backend.create_document_type(wrapper.name, wrapper.public_id, wrapper.system_id, @backend_doc)
2594
2749
  else
2595
- # Fallback: serialize + reparse via fragment for unusual types.
2596
- fragment = Parser.fragment(source.to_html, owner_doc: @backend_doc)
2597
- fragment.children.first || Backend.create_text("", @backend_doc)
2750
+ Backend.create_text("", @backend_doc)
2598
2751
  end
2752
+ end
2599
2753
 
2600
- if source.element? && source.name == "template"
2601
- # A <template>'s contents live in a separate content fragment, not its
2602
- # child list, so the generic deep pass over `children` misses them.
2603
- clone_template_content(source, copy, source_document) if deep
2604
- elsif deep && source.respond_to?(:children)
2605
- source.children.each do |child|
2606
- copy.add_child(clone_into_doc(child, true, source_document))
2754
+ # An element's copy keeps its namespace, its prefix and the exact spelling
2755
+ # of its local name. None of that is on the backend node — the HTML backend
2756
+ # tracks no namespace for an element script created, and createElementNS's
2757
+ # case lives on the wrapper — so the original is read through its wrapper
2758
+ # and the copy is given the same metadata.
2759
+ def clone_element_into_doc(source, source_document)
2760
+ wrapper = source_document.wrap_node(source)
2761
+ namespace, prefix, local, qualified = clone_name_parts(wrapper, source)
2762
+ copy = Backend.create_element_loose(qualified, prefix, local, namespace, @backend_doc) ||
2763
+ Backend.create_element(source.name, @backend_doc)
2764
+ copy_attributes_into(source, copy)
2765
+ note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
2766
+ copy
2767
+ end
2768
+
2769
+ # The four parts of the original's name — namespace, prefix, local name and
2770
+ # qualified name. The wrapper's own metadata has them whenever there is
2771
+ # anything to carry (createElementNS's case and prefix, an XML document's,
2772
+ # the HTML parser's SVG names); otherwise the element is an unprefixed one
2773
+ # whose namespace the backend knows.
2774
+ def clone_name_parts(wrapper, source)
2775
+ meta = wrapper.__internal_namespace_metadata__ if wrapper.respond_to?(:__internal_namespace_metadata__)
2776
+ return meta if meta
2777
+
2778
+ local = wrapper ? wrapper.local_name.to_s : source.name
2779
+ [wrapper&.namespace_uri, nil, local, local]
2780
+ end
2781
+
2782
+ # "Clone a single node" step 2.1 clones the attributes one by one, so each
2783
+ # keeps its own namespace and prefix — an `xml:b` must not flatten into an
2784
+ # attribute whose local name is the qualified string "xml:b".
2785
+ def copy_attributes_into(source, copy)
2786
+ Backend.attribute_nodes(source).each do |attr|
2787
+ info = Backend.attribute_ns_info(attr)
2788
+ if info[:namespace_uri]
2789
+ Backend.set_attribute_ns(copy, info[:namespace_uri], info[:prefix], info[:local_name],
2790
+ info[:qualified_name], info[:value])
2791
+ else
2792
+ copy[info[:qualified_name]] = info[:value]
2607
2793
  end
2608
2794
  end
2795
+ nil
2796
+ end
2609
2797
 
2610
- copy
2798
+ # Give the copy's wrapper the namespace metadata whenever the backend node
2799
+ # alone would report something else (see #clone_element_into_doc).
2800
+ def note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
2801
+ derived = Backend.namespace_of(copy)&.href || (html_document? ? Element::HTML_NAMESPACE : nil)
2802
+ return if derived == namespace && prefix.nil? && local == copy.name
2803
+
2804
+ wrap_cloned_element_ns(copy, namespace, prefix, local, qualified)
2611
2805
  end
2612
2806
 
2613
2807
  # Clone a <template>'s content into a fragment registered as `copy`'s