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
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:
@@ -3261,7 +3310,7 @@ module Dommy
3261
3310
  end
3262
3311
 
3263
3312
  def insert_before(child, reference)
3264
- coerce_node_argument!(child)
3313
+ Internal::WebIDL.node!(child)
3265
3314
  # WHATWG pre-insert validates the reference the CALLER gave (step 1), and
3266
3315
  # only then, in step 3, replaces it with the node's next sibling when it is
3267
3316
  # the node being inserted — so "insert x before x" doesn't move x. Doing
@@ -3299,7 +3348,7 @@ module Dommy
3299
3348
  end
3300
3349
 
3301
3350
  def remove_child(child)
3302
- coerce_node_argument!(child)
3351
+ Internal::WebIDL.node!(child)
3303
3352
  node = unwrap_dom_node(child)
3304
3353
  unless node&.parent == @__node__
3305
3354
  raise DOMException::NotFoundError, "node is not a child of this element"
@@ -3315,8 +3364,8 @@ module Dommy
3315
3364
  # MutationObserver of both changes in one record so observers
3316
3365
  # see the swap atomically.
3317
3366
  def replace_child(new_child, old_child)
3318
- coerce_node_argument!(new_child)
3319
- coerce_node_argument!(old_child)
3367
+ Internal::WebIDL.node!(new_child)
3368
+ Internal::WebIDL.node!(old_child)
3320
3369
  # replaceChild shares the pre-insertion checks (ancestor, node type,
3321
3370
  # doctype placement); the reference child here is old_child, so step 3
3322
3371
  # also enforces that it is actually a child (NotFoundError otherwise).
data/lib/dommy/event.rb CHANGED
@@ -377,8 +377,11 @@ module Dommy
377
377
  node.is_a?(ShadowRoot) && node.mode == "closed"
378
378
  end
379
379
 
380
+ # Every node answers getRootNode — a CharacterData node and a Document as
381
+ # much as an Element, whose override also finds its shadow root. A Window
382
+ # has no root.
380
383
  def root_of(node)
381
- node.respond_to?(:root_node) ? node.root_node : nil
384
+ node.respond_to?(:get_root_node) ? node.get_root_node : nil
382
385
  end
383
386
 
384
387
  # `phase` is :capture (capture listeners), :bubble (non-capture), or :both
@@ -1373,7 +1376,7 @@ module Dommy
1373
1376
  # `InputEvent` — fired by `input` / `beforeinput`. Carries `data`
1374
1377
  # (the inserted text, if any) and `inputType` (insertText,
1375
1378
  # deleteContentBackward, etc.).
1376
- class InputEvent < Event
1379
+ class InputEvent < UIEvent
1377
1380
  def initialize(type, init = nil)
1378
1381
  super
1379
1382
  @data = read_init(init, "data")
@@ -1581,7 +1584,7 @@ module Dommy
1581
1584
  # touchcancel. Carries three TouchLists plus modifier keys.
1582
1585
  #
1583
1586
  # Spec: https://w3c.github.io/touch-events/#touchevent-interface
1584
- class TouchEvent < Event
1587
+ class TouchEvent < UIEvent
1585
1588
  def initialize(type, init = nil)
1586
1589
  super
1587
1590
  @touches = TouchList.new(Array(read_init(init, "touches") || []))
@@ -137,11 +137,7 @@ module Dommy
137
137
  def validate_insert_before_ref!(args)
138
138
  raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
139
139
 
140
- ref = args[1]
141
- return if ref.nil? || (defined?(Bridge::UNDEFINED) && ref.equal?(Bridge::UNDEFINED))
142
- return if ref.is_a?(Dommy::Node)
143
-
144
- raise Bridge::TypeError, "The reference child is not a Node."
140
+ WebIDL.nullable_node!(args[1])
145
141
  end
146
142
 
147
143
  private
@@ -26,23 +26,17 @@ module Dommy
26
26
  end
27
27
 
28
28
  def remove_child(node)
29
- coerce_leaf_node_argument!(node)
29
+ WebIDL.node!(node)
30
30
  raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
31
31
  end
32
32
 
33
33
  private
34
34
 
35
35
  def reject_leaf_insertion!(node)
36
- coerce_leaf_node_argument!(node)
36
+ WebIDL.node!(node)
37
37
  raise DOMException::HierarchyRequestError, leaf_insertion_message
38
38
  end
39
39
 
40
- # WebIDL coerces the Node argument first: a null / non-Node value is a
41
- # TypeError before any DOM step runs.
42
- def coerce_leaf_node_argument!(value)
43
- raise Bridge::TypeError, "Argument is not a Node." unless value.is_a?(Dommy::Node)
44
- end
45
-
46
40
  def leaf_insertion_message
47
41
  "this node type does not support children"
48
42
  end
@@ -37,20 +37,24 @@ module Dommy
37
37
  nil
38
38
  end
39
39
 
40
- # Walk a subtree and fire connected/disconnected callbacks for all elements
40
+ # Connected callbacks, connected scripts and blank-iframe loads for every
41
+ # element among the subtree's shadow-including inclusive descendants, in
42
+ # shadow-including tree order — so the custom elements in an inserted
43
+ # host's shadow tree connect too.
41
44
  def notify_connected_subtree(nk)
42
- return unless nk.respond_to?(:element?)
43
-
44
- if nk.element?
45
- wrapped = @document.wrap_node(nk)
46
- if wrapped
47
- notify_connected(wrapped)
48
- run_connected_script(wrapped)
49
- fire_blank_iframe_load(wrapped)
50
- end
45
+ each_shadow_including_element(nk) do |element|
46
+ notify_connected(element)
47
+ run_connected_script(element)
48
+ fire_blank_iframe_load(element)
51
49
  end
50
+ end
52
51
 
53
- nk.children.each { |c| notify_connected_subtree(c) } if nk.respond_to?(:children)
52
+ # The wrapped elements of the document's shadow-including walk.
53
+ def each_shadow_including_element(nk)
54
+ @document.__internal_each_shadow_including_element__(nk) do |element_node|
55
+ wrapped = @document.wrap_node(element_node)
56
+ yield wrapped if wrapped
57
+ end
54
58
  end
55
59
 
56
60
  # A srcless ("blank"/about:blank) `<iframe>` connected to the document gets
@@ -133,15 +137,32 @@ module Dommy
133
137
  scheduler ? scheduler.queue_microtask(run) : run.call
134
138
  end
135
139
 
136
- def notify_disconnected_subtree(nk)
137
- return unless nk.respond_to?(:element?)
140
+ # WHATWG "move": each custom element among the moved node's
141
+ # shadow-including inclusive descendants, in shadow-including tree order,
142
+ # gets connectedMoveCallback. The caller checks that the new parent is
143
+ # connected.
144
+ def notify_moved_subtree(nk)
145
+ each_shadow_including_element(nk) { |element| notify_moved(element) }
146
+ end
138
147
 
139
- if nk.element?
140
- wrapped = @document.wrap_node(nk)
141
- notify_disconnected(wrapped) if wrapped
148
+ # HTML "enqueue a custom element callback reaction": a definition without
149
+ # connectedMoveCallback runs disconnectedCallback and then connectedCallback
150
+ # in its place. (A JS-defined element always answers the Ruby method; the
151
+ # bridge falls back on the JS side.)
152
+ def notify_moved(element)
153
+ if element.respond_to?(:connected_move_callback)
154
+ element.connected_move_callback
155
+ else
156
+ notify_disconnected(element)
157
+ notify_connected(element)
142
158
  end
159
+ rescue StandardError
160
+ nil
161
+ end
143
162
 
144
- nk.children.each { |c| notify_disconnected_subtree(c) } if nk.respond_to?(:children)
163
+ # Disconnected callbacks, over the same shadow-including walk.
164
+ def notify_disconnected_subtree(nk)
165
+ each_shadow_including_element(nk) { |element| notify_disconnected(element) }
145
166
  end
146
167
 
147
168
  def notify_attribute_changed(element, name, old_value, new_value, namespace = nil)
@@ -251,12 +272,18 @@ module Dommy
251
272
  end
252
273
 
253
274
  # Fire MutationObserver childList records
275
+ # `moving:` marks the records of a move (moveBefore). A move runs neither
276
+ # the insertion nor the removing steps, so the connected / disconnected
277
+ # walk below — lifecycle callbacks, script execution, blank-iframe load —
278
+ # is skipped for it; its custom element reactions are the move's own
279
+ # (notify_moved_subtree).
254
280
  def notify_child_list_mutation(
255
281
  target_node:,
256
282
  added_nodes:,
257
283
  removed_nodes:,
258
284
  previous_sibling: nil,
259
- next_sibling: nil
285
+ next_sibling: nil,
286
+ moving: false
260
287
  )
261
288
  @document.__internal_note_tree_mutation__
262
289
  target = @document.wrap_node(target_node)
@@ -269,7 +296,7 @@ module Dommy
269
296
  # connectedCallback fires only when connected). So skip the O(subtree)
270
297
  # walk for mutations within a still-detached tree — the common case
271
298
  # during bulk DOM construction, where nothing in the walk can fire.
272
- if !target.respond_to?(:is_connected?) || target.is_connected?
299
+ if !moving && (!target.respond_to?(:is_connected?) || target.is_connected?)
273
300
  added_nodes.each { |nk| notify_connected_subtree(nk) }
274
301
  removed_nodes.each { |nk| notify_disconnected_subtree(nk) }
275
302
  end
@@ -278,8 +278,15 @@ module Dommy
278
278
  end
279
279
  end
280
280
 
281
- # Clear cached wrapper (used by customElements.define for upgrades)
281
+ # Clear cached wrapper (used by customElements.define for upgrades).
282
+ # An upgrade replaces a node's wrapper without mutating the tree, so
283
+ # nothing bumps `dom_generation` and the query cache would keep handing
284
+ # out the wrapper this just retired — a `querySelector` after the upgrade
285
+ # would answer with the element's pre-upgrade self. Drop the memoized
286
+ # results too. Upgrades are rare (a handful per page), so clearing the
287
+ # whole cache costs less than tracking which selectors matched the node.
282
288
  def reset_wrapper(nokogiri_node)
289
+ @query_cache.clear
283
290
  @wrappers.delete(identity_key(nokogiri_node))
284
291
  end
285
292
 
@@ -420,6 +427,7 @@ module Dommy
420
427
  ruby_custom = custom_klass if custom_klass.is_a?(::Class)
421
428
  klass = ruby_custom || Dommy.element_class_for(local_name || node.name, ns)
422
429
  instance = klass.new(@document, node)
430
+ adjust_svg_tag_name(instance, node, ns) if local_name.nil?
423
431
 
424
432
  @wrappers[identity_key(node)] = instance
425
433
 
@@ -434,6 +442,20 @@ module Dommy
434
442
  instance
435
443
  end
436
444
 
445
+ # HTML's "adjust SVG tag name" (§13.2.6.5), the parser step that gives
446
+ # `<feMerge>` its camel case back after the tokenizer lower-cased it. The
447
+ # backend keeps the lower-case name, so — exactly as for the case
448
+ # createElementNS preserves — the adjusted name lives on the wrapper.
449
+ # Only names the parser produced are adjusted: createElementNS says what
450
+ # the local name is and passes it in, and an XML document is parsed
451
+ # verbatim to begin with.
452
+ def adjust_svg_tag_name(instance, node, namespace)
453
+ return unless namespace == Element::SVG_NAMESPACE && @document.html_document?
454
+
455
+ adjusted = Dommy::SVG_ADJUSTED_TAG_NAMES[node.name]
456
+ instance.__internal_set_namespace__(namespace, nil, adjusted, adjusted) if adjusted
457
+ end
458
+
437
459
  def custom_element_class_for(tag_name)
438
460
  # Custom elements are registered on window, not document.
439
461
  # Access via default_view if available.
@@ -19,7 +19,7 @@ module Dommy
19
19
  # `appendChild(child)` — detach the node(s) from any current parent
20
20
  # and append to the end of this node's child list.
21
21
  def append_child(child)
22
- coerce_node_argument!(child)
22
+ WebIDL.node!(child)
23
23
  ensure_pre_insertion_validity!(child, nil)
24
24
  # An append has a null reference child, so insert step 5 shifts nothing;
25
25
  # convert_for_insert still routes through it so every insertion site
@@ -120,7 +120,8 @@ module Dommy
120
120
  # primitive underneath it.
121
121
  #
122
122
  # A move is NOT remove + insert. It runs neither the removing steps nor
123
- # the insertion steps (so no disconnected/connected callbacks fire), it
123
+ # the insertion steps: a custom element gets connectedMoveCallback (or, with
124
+ # none defined, disconnectedCallback then connectedCallback) instead, it
124
125
  # never adopts — step 1 requires the same shadow-including root, so the
125
126
  # node document cannot change — and it carries its own validity checks
126
127
  # instead of "ensure pre-insertion validity". What it does share is the
@@ -129,7 +130,8 @@ module Dommy
129
130
  #
130
131
  # Spec: https://dom.spec.whatwg.org/#dom-parentnode-movebefore
131
132
  def move_before(node, child = nil)
132
- coerce_node_argument!(node)
133
+ WebIDL.node!(node)
134
+ WebIDL.nullable_node!(child)
133
135
  bn = insertion_backend_node(node)
134
136
  ref_bn = insertion_backend_node(child)
135
137
  # moveBefore step 2: a reference child that IS the node moves out of the
@@ -149,7 +151,7 @@ module Dommy
149
151
  # Steps 10-11 and 14. detach_node runs the live range and NodeIterator
150
152
  # pre-remove steps and then unlinks, without queuing a record — the move
151
153
  # queues its own pair at the end (steps 23-24).
152
- @document.detach_node(bn)
154
+ @document.detach_node(bn, moving: true)
153
155
 
154
156
  ref_bn = nil if ref_bn && ref_bn.parent != @__node__
155
157
  # Step 16 — measured after the removal, which step 14 has already done.
@@ -160,69 +162,16 @@ module Dommy
160
162
 
161
163
  # Steps 23-24: one record for the old parent, one for the new.
162
164
  notify_move_records(bn, old_parent, old_previous, old_next, new_previous, ref_bn)
165
+ # Step 19.3's custom element reactions, run as the call returns — only
166
+ # when the new parent is connected.
167
+ @document.__internal_notify_moved_subtree__(bn) if node.get_root_node({ "composed" => true }).is_a?(Dommy::Document)
163
168
  node
164
169
  end
165
170
 
166
- # Node#normalize — merge each run of adjacent exclusive Text descendants
167
- # into its first node (preserving that node's identity, so a JS reference
168
- # to it survives) and drop empty Text nodes. Recurses the whole subtree,
169
- # so it works for Element, DocumentFragment, and ShadowRoot alike.
170
- #
171
- # A run is merged one sibling at a time: append the sibling's data to the
172
- # survivor (a characterData record), hand its live range boundaries over,
173
- # remove it (a childList record), then the next. Read literally, the spec
174
- # concatenates every sibling's data first (steps 3-4, one "replace data")
175
- # and removes them afterwards (step 7), which would queue ONE
176
- # characterData record per run. Every shipping engine merges pairwise
177
- # instead — Blink, WebCore and Gecko all answer [characterData, childList,
178
- # characterData, childList, …] for a run of four text nodes, confirmed by
179
- # running the same script in Chromium 141, WebKitGTK 2.52.6 and Firefox —
180
- # and the WPT suite fixes only the childList side, so the records follow
181
- # the engines. The tree and every live range boundary end up exactly where
182
- # the spec's steps put them: a boundary in a later sibling (or on the
183
- # parent, pointing at one) is shifted down by each earlier removal and
184
- # then handed over at the survivor's length of that moment, which is the
185
- # same offset the batch steps compute up front.
186
- # https://github.com/takahashim/dommy/issues/24
171
+ # Node.normalize(). The algorithm lives on the Document, which is a
172
+ # receiver too.
187
173
  def normalize
188
- text_nodes = []
189
- @__node__.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
190
-
191
- text_nodes.each do |node|
192
- next unless node.parent # already removed as part of an earlier run
193
-
194
- if node.content.to_s.empty?
195
- @document.remove_node_with_notify(node)
196
- next
197
- end
198
-
199
- sib = node.next
200
- while sib.respond_to?(:text?) && sib.text?
201
- following = sib.next
202
- data = sib.content.to_s
203
- # The offset the sibling's data lands at inside the survivor — the
204
- # length of what it already holds, measured before the append.
205
- length = @document.wrap_node(node).length
206
- # An empty sibling has nothing to append: the engines skip the data
207
- # step for it (Gecko checks the length, Blink and WebCore behave the
208
- # same), so it is removed without a characterData record. Its range
209
- # boundaries still move to the survivor's join.
210
- unless data.empty?
211
- old = node.content.to_s
212
- node.content = old + data
213
- @document.notify_character_data_mutation(target_node: node, old_value: old)
214
- end
215
- # WHATWG normalize() step 6: the merged-away sibling hands its live
216
- # range boundaries to the survivor at that offset BEFORE it is
217
- # removed, or the plain removing steps would strand them on the
218
- # parent.
219
- @document.__internal_ranges_normalize_merge__(node, sib, length)
220
- @document.remove_node_with_notify(sib)
221
- sib = following
222
- end
223
- end
224
-
225
- nil
174
+ @document.__internal_normalize__(@__node__)
226
175
  end
227
176
 
228
177
  private
@@ -342,12 +291,12 @@ module Dommy
342
291
  if old_parent
343
292
  @document.notify_child_list_mutation(
344
293
  target_node: old_parent, added_nodes: [], removed_nodes: [bn],
345
- previous_sibling: wrap.call(old_previous), next_sibling: wrap.call(old_next)
294
+ previous_sibling: wrap.call(old_previous), next_sibling: wrap.call(old_next), moving: true
346
295
  )
347
296
  end
348
297
  @document.notify_child_list_mutation(
349
298
  target_node: @__node__, added_nodes: [bn], removed_nodes: [],
350
- previous_sibling: wrap.call(new_previous), next_sibling: wrap.call(ref_bn)
299
+ previous_sibling: wrap.call(new_previous), next_sibling: wrap.call(ref_bn), moving: true
351
300
  )
352
301
  end
353
302
 
@@ -406,15 +355,6 @@ module Dommy
406
355
  false
407
356
  end
408
357
 
409
- # WebIDL coercion for an `appendChild`/`insertBefore`/`replaceChild`
410
- # argument typed `Node`: a null / undefined / non-Node value is a
411
- # TypeError before any DOM step runs.
412
- def coerce_node_argument!(value)
413
- return value if value.is_a?(Dommy::Node)
414
-
415
- raise Bridge::TypeError, "Argument is not a Node."
416
- end
417
-
418
358
  # WHATWG "ensure pre-insertion validity" for an element-like parent
419
359
  # (Element / DocumentFragment / ShadowRoot — never a Document, so the
420
360
  # document-only constraints in step 6 don't apply here). Steps run in
@@ -65,8 +65,9 @@ module Dommy
65
65
  end
66
66
  end
67
67
 
68
+ # A Text node in the spec's sense, which a CDATASection is too.
68
69
  def text_node?(node)
69
- node.respond_to?(:node_type) && node.node_type == 3
70
+ node.respond_to?(:node_type) && [3, 4].include?(node.node_type)
70
71
  end
71
72
  end
72
73
  end
@@ -69,6 +69,23 @@ module Dommy
69
69
 
70
70
  TypeSelector = Struct.new(:namespace, :name) do
71
71
  def specificity = TYPE
72
+
73
+ # HTML matches a type selector against an HTML element by lowercasing
74
+ # the SELECTOR (see SelectorMatcher#matches_type?). Computed once per
75
+ # parsed selector — the matcher asks per element per query, and the AST
76
+ # it asks is the cached one.
77
+ def ascii_lowercased_name
78
+ @ascii_lowercased_name ||= name.to_s.downcase(:ascii)
79
+ end
80
+
81
+ # A name with no ASCII uppercase in it — nearly every selector ever
82
+ # written — is its own lowercased form, so HTML's rule and an exact
83
+ # comparison agree and the matcher need not ask what it is looking at.
84
+ def already_ascii_lowercase?
85
+ return @already_ascii_lowercase unless @already_ascii_lowercase.nil?
86
+
87
+ @already_ascii_lowercase = name.to_s == ascii_lowercased_name
88
+ end
72
89
  end
73
90
 
74
91
  UniversalSelector = Struct.new(:namespace) do