dommy 0.11.0 → 0.13.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 (68) 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/bridge.rb +16 -3
  5. data/lib/dommy/browser.rb +43 -47
  6. data/lib/dommy/callable_invoker.rb +16 -0
  7. data/lib/dommy/compression_streams.rb +12 -62
  8. data/lib/dommy/custom_elements.rb +22 -16
  9. data/lib/dommy/document.rb +241 -47
  10. data/lib/dommy/element.rb +71 -23
  11. data/lib/dommy/encodings/tables.rb +751 -0
  12. data/lib/dommy/encodings.rb +299 -0
  13. data/lib/dommy/event.rb +10 -8
  14. data/lib/dommy/fetch.rb +60 -49
  15. data/lib/dommy/form_data.rb +10 -0
  16. data/lib/dommy/html_elements.rb +136 -62
  17. data/lib/dommy/internal/child_node.rb +1 -5
  18. data/lib/dommy/internal/exception_report.rb +123 -0
  19. data/lib/dommy/internal/idna.rb +4 -4
  20. data/lib/dommy/internal/idna_data.rb +513 -100
  21. data/lib/dommy/internal/leaf_node.rb +2 -8
  22. data/lib/dommy/internal/mutation_coordinator.rb +46 -19
  23. data/lib/dommy/internal/node_wrapper_cache.rb +23 -1
  24. data/lib/dommy/internal/parent_node.rb +14 -74
  25. data/lib/dommy/internal/range_text_serializer.rb +2 -1
  26. data/lib/dommy/internal/selector_ast.rb +17 -0
  27. data/lib/dommy/internal/selector_matcher.rb +20 -21
  28. data/lib/dommy/internal/selector_parser.rb +104 -32
  29. data/lib/dommy/internal/shadow_root_registry.rb +12 -1
  30. data/lib/dommy/internal/url_parser.rb +70 -18
  31. data/lib/dommy/internal/web_idl.rb +57 -0
  32. data/lib/dommy/js/custom_element_bridge.rb +10 -0
  33. data/lib/dommy/js/dom_interfaces.rb +15 -4
  34. data/lib/dommy/js/error_log.rb +137 -0
  35. data/lib/dommy/js/handle_table.rb +15 -0
  36. data/lib/dommy/js/host_bridge.rb +27 -0
  37. data/lib/dommy/js/host_runtime.js +186 -30
  38. data/lib/dommy/js/marshaller.rb +22 -2
  39. data/lib/dommy/js/runtime.rb +8 -1
  40. data/lib/dommy/js/script_boot.rb +58 -14
  41. data/lib/dommy/js/wire_tags.rb +8 -0
  42. data/lib/dommy/location.rb +14 -4
  43. data/lib/dommy/navigator.rb +3 -1
  44. data/lib/dommy/node.rb +19 -3
  45. data/lib/dommy/promise.rb +4 -4
  46. data/lib/dommy/range.rb +116 -183
  47. data/lib/dommy/scheduler.rb +35 -6
  48. data/lib/dommy/selection.rb +422 -0
  49. data/lib/dommy/shadow_root.rb +8 -3
  50. data/lib/dommy/static_range.rb +65 -0
  51. data/lib/dommy/streams.rb +1200 -187
  52. data/lib/dommy/svg_elements.rb +45 -0
  53. data/lib/dommy/text_codec.rb +110 -213
  54. data/lib/dommy/tree_walker.rb +9 -17
  55. data/lib/dommy/url.rb +34 -32
  56. data/lib/dommy/url_pattern/canonicalizer.rb +183 -0
  57. data/lib/dommy/url_pattern/component.rb +64 -0
  58. data/lib/dommy/url_pattern/constructor_string_parser.rb +281 -0
  59. data/lib/dommy/url_pattern/init_processor.rb +163 -0
  60. data/lib/dommy/url_pattern/pattern_parser.rb +343 -0
  61. data/lib/dommy/url_pattern/regexp_translator.rb +694 -0
  62. data/lib/dommy/url_pattern/tokenizer.rb +199 -0
  63. data/lib/dommy/url_pattern.rb +310 -103
  64. data/lib/dommy/version.rb +1 -1
  65. data/lib/dommy/window.rb +164 -35
  66. data/lib/dommy/xml_http_request.rb +18 -4
  67. data/lib/dommy.rb +5 -0
  68. metadata +17 -3
@@ -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
 
@@ -802,11 +810,11 @@ module Dommy
802
810
  href = base_el["href"].to_s
803
811
  return doc_url if href.empty?
804
812
 
805
- begin
806
- URI.join(doc_url.to_s.empty? ? "about:blank" : doc_url, href).to_s
807
- rescue URI::InvalidURIError
808
- doc_url
809
- end
813
+ # HTML "document base URL": the frozen base URL is the href parsed
814
+ # against the document's URL; one that does not parse falls back.
815
+ URL.new(href, doc_url.to_s.empty? ? nil : doc_url).href
816
+ rescue Bridge::TypeError
817
+ doc_url
810
818
  end
811
819
 
812
820
  # `document.domain` — host portion of the URL. Real browsers
@@ -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
data/lib/dommy/element.rb CHANGED
@@ -21,6 +21,19 @@ module Dommy
21
21
 
22
22
  # Public Ruby API (DocumentFragment surface)
23
23
 
24
+ # Node.cloneNode: a fresh, empty fragment, and — when `deep` — a clone of
25
+ # each child appended in tree order ("clone a node" step 5). Cloning the
26
+ # children one by one rather than re-parsing the fragment's serialization
27
+ # keeps each of them its own interface: a comment stays a comment, and a
28
+ # processing instruction does not come back as one.
29
+ def clone_node(deep = false)
30
+ copy = @document.create_document_fragment
31
+ return copy unless deep
32
+
33
+ child_nodes.each { |child| copy.append_child(child.clone_node(true)) }
34
+ copy
35
+ end
36
+
24
37
  def children
25
38
  element_children
26
39
  end
@@ -152,9 +165,7 @@ module Dommy
152
165
  when "isDefaultNamespace"
153
166
  is_default_namespace(args[0])
154
167
  when "cloneNode"
155
- deep = args.empty? ? false : !!args[0]
156
- deep ? @document.wrap_node(Parser.fragment(@__node__.to_html, owner_doc: @document.backend_doc)) : @document
157
- .wrap_node(Parser.fragment("", owner_doc: @document.backend_doc))
168
+ clone_node(args.first)
158
169
  when "querySelector"
159
170
  query_selector(Internal.css_query_arg!(args))
160
171
  when "querySelectorAll"
@@ -227,7 +238,7 @@ module Dommy
227
238
  end
228
239
 
229
240
  def insert_before(node, ref)
230
- coerce_node_argument!(node)
241
+ Internal::WebIDL.node!(node)
231
242
  ensure_pre_insertion_validity!(node, ref)
232
243
  ref_bn = ref.respond_to?(:__dommy_backend_node__) ? ref.__dommy_backend_node__ : nil
233
244
  ref_bn = nil unless ref_bn && ref_bn.parent == @__node__
@@ -250,7 +261,7 @@ module Dommy
250
261
  end
251
262
 
252
263
  def replace_child(new_child, old_child)
253
- coerce_node_argument!(new_child)
264
+ Internal::WebIDL.node!(new_child)
254
265
  # WHATWG "replace" step 1 runs the full ensure-pre-insertion-validity,
255
266
  # whose step 2 (node is an inclusive ancestor of parent — a cycle) comes
256
267
  # BEFORE step 3 (the reference child's parentage). So
@@ -726,12 +737,20 @@ module Dommy
726
737
  super
727
738
  end
728
739
 
740
+ # Node.cloneNode. CharacterData has no children, so `deep` decides nothing:
741
+ # the copy is a new node of the SAME interface carrying the same data, owned
742
+ # by this node's document. Each subclass builds its own kind — a CDATASection
743
+ # that cloned to a Text would be the wrong node.
744
+ def clone_node(_deep = false)
745
+ @document.create_text_node(@__node__.text)
746
+ end
747
+
729
748
  # Own __js_call__ methods, on top of CharacterDataNode's.
730
749
  js_methods %w[cloneNode]
731
750
  def __js_call__(method, args)
732
751
  case method
733
752
  when "cloneNode"
734
- @document.create_text_node(@__node__.text)
753
+ clone_node(args.first)
735
754
  else
736
755
  super
737
756
  end
@@ -744,6 +763,10 @@ module Dommy
744
763
  def node_type
745
764
  4
746
765
  end
766
+
767
+ def clone_node(_deep = false)
768
+ @document.create_cdata_section(@__node__.content)
769
+ end
747
770
  end
748
771
 
749
772
  class CommentNode < CharacterDataNode
@@ -751,12 +774,16 @@ module Dommy
751
774
  8
752
775
  end
753
776
 
777
+ def clone_node(_deep = false)
778
+ @document.create_comment(@__node__.content)
779
+ end
780
+
754
781
  # Own __js_call__ methods, on top of CharacterDataNode's.
755
782
  js_methods %w[cloneNode]
756
783
  def __js_call__(method, args)
757
784
  case method
758
785
  when "cloneNode"
759
- @document.create_comment(@__node__.content)
786
+ clone_node(args.first)
760
787
  else
761
788
  super
762
789
  end
@@ -791,12 +818,16 @@ module Dommy
791
818
  end
792
819
  end
793
820
 
821
+ def clone_node(_deep = false)
822
+ @document.create_processing_instruction(@__node__.target, @__node__.content)
823
+ end
824
+
794
825
  # Own __js_call__ methods, on top of CharacterDataNode's.
795
826
  js_methods %w[cloneNode]
796
827
  def __js_call__(method, args)
797
828
  case method
798
829
  when "cloneNode"
799
- @document.create_processing_instruction(@__node__.target, @__node__.content)
830
+ clone_node(args.first)
800
831
  else
801
832
  super
802
833
  end
@@ -1688,7 +1719,7 @@ module Dommy
1688
1719
  # inside a shadow tree, returns that ShadowRoot. Otherwise walks
1689
1720
  # until we hit the Nokogiri Document (then returns the Document).
1690
1721
  def root_node(options = nil)
1691
- composed = options.is_a?(Hash) && EventTarget.js_truthy?(options.key?("composed") ? options["composed"] : options[:composed])
1722
+ composed = Node.composed_option?(options)
1692
1723
  sr = @document.__internal_shadow_root_containing__(@__node__)
1693
1724
  if sr
1694
1725
  # Default: the shadow root is the root. `composed: true` is
@@ -1786,8 +1817,24 @@ module Dommy
1786
1817
  attributes.set_named_item(attr)
1787
1818
  end
1788
1819
 
1820
+ # setAttributeNodeNS is defined as the very same steps as setAttributeNode
1821
+ # ("set an attribute"): the namespace is the Attr's own, so there is nothing
1822
+ # left for the NS form to do differently. The JS bridge already routed it
1823
+ # here; this is the Ruby caller's way in.
1824
+ def set_attribute_node_ns(attr)
1825
+ set_attribute_node(attr)
1826
+ end
1827
+
1828
+ # removeAttributeNode step 1: "If this's attribute list does not contain
1829
+ # attr, throw a NotFoundError." What counts is the Attr itself, not its
1830
+ # name — one that belongs to another element, or to none, is not in this
1831
+ # list even when this element has an attribute of the same name.
1789
1832
  def remove_attribute_node(attr)
1790
- return nil unless attr.respond_to?(:name)
1833
+ owner = attr.owner_element if attr.respond_to?(:owner_element)
1834
+ unless owner.equal?(self)
1835
+ raise DOMException::NotFoundError,
1836
+ "the attribute #{attr.respond_to?(:name) ? attr.name.inspect : attr.inspect} is not this element's"
1837
+ end
1791
1838
 
1792
1839
  attributes.remove_named_item(attr.name)
1793
1840
  end
@@ -1982,7 +2029,7 @@ module Dommy
1982
2029
  raise DOMException::NotSupportedError, "<#{tag}> cannot host a shadow root"
1983
2030
  end
1984
2031
 
1985
- raise DOMException::NotSupportedError, "Shadow root already attached" if @__shadow_root
2032
+ raise DOMException::NotSupportedError, "Shadow root already attached" if __internal_shadow_root__
1986
2033
 
1987
2034
  opts = options.is_a?(Hash) ? options : {}
1988
2035
  mode_raw = opts.key?("mode") ? opts["mode"] : opts[:mode]
@@ -2007,16 +2054,18 @@ module Dommy
2007
2054
  # `el.shadowRoot` — returns the attached ShadowRoot only when
2008
2055
  # mode is "open"; closed shadows are hidden from external code.
2009
2056
  def shadow_root
2010
- return nil unless @__shadow_root
2011
- return nil if @__shadow_root.mode == "closed"
2057
+ root = __internal_shadow_root__
2058
+ return nil if root.nil? || root.mode == "closed"
2012
2059
 
2013
- @__shadow_root
2060
+ root
2014
2061
  end
2015
2062
 
2016
2063
  # Internal — gives access to the shadow root regardless of mode.
2017
- # Used by event composition / `composedPath()`.
2064
+ # Used by event composition / `composedPath()`. The document's registry
2065
+ # answers for a wrapper that is not the one attachShadow ran on: a custom
2066
+ # element upgrade replaces the host's wrapper, not its shadow root.
2018
2067
  def __internal_shadow_root__
2019
- @__shadow_root
2068
+ @__shadow_root ||= @document.__internal_shadow_root_for_host__(@__node__)
2020
2069
  end
2021
2070
 
2022
2071
  # `el.insertAdjacentElement(position, element)` — DOM spec positions:
@@ -2574,9 +2623,8 @@ module Dommy
2574
2623
  # so `a.href = "http://example.org/?ä"` reads back percent-encoded — which is
2575
2624
  # what the URL parser produces and what `URI.join` does not.
2576
2625
  def resolve_url(raw)
2577
- win = @document.default_view
2578
- base = win&.location ? win.location.href : ""
2579
- base = nil if base.to_s.empty?
2626
+ base = @document.base_uri.to_s
2627
+ base = nil if base.empty?
2580
2628
  Internal::UrlParser.serialize(Internal::UrlParser.parse(raw.to_s, base))
2581
2629
  rescue Internal::UrlParser::Failure
2582
2630
  raw.to_s
@@ -3261,7 +3309,7 @@ module Dommy
3261
3309
  end
3262
3310
 
3263
3311
  def insert_before(child, reference)
3264
- coerce_node_argument!(child)
3312
+ Internal::WebIDL.node!(child)
3265
3313
  # WHATWG pre-insert validates the reference the CALLER gave (step 1), and
3266
3314
  # only then, in step 3, replaces it with the node's next sibling when it is
3267
3315
  # the node being inserted — so "insert x before x" doesn't move x. Doing
@@ -3299,7 +3347,7 @@ module Dommy
3299
3347
  end
3300
3348
 
3301
3349
  def remove_child(child)
3302
- coerce_node_argument!(child)
3350
+ Internal::WebIDL.node!(child)
3303
3351
  node = unwrap_dom_node(child)
3304
3352
  unless node&.parent == @__node__
3305
3353
  raise DOMException::NotFoundError, "node is not a child of this element"
@@ -3315,8 +3363,8 @@ module Dommy
3315
3363
  # MutationObserver of both changes in one record so observers
3316
3364
  # see the swap atomically.
3317
3365
  def replace_child(new_child, old_child)
3318
- coerce_node_argument!(new_child)
3319
- coerce_node_argument!(old_child)
3366
+ Internal::WebIDL.node!(new_child)
3367
+ Internal::WebIDL.node!(old_child)
3320
3368
  # replaceChild shares the pre-insertion checks (ancestor, node type,
3321
3369
  # doctype placement); the reference child here is old_child, so step 3
3322
3370
  # also enforces that it is actually a child (NotFoundError otherwise).