dommy 0.7.0 → 0.8.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
@@ -11,28 +11,288 @@ require_relative "internal/observer_manager"
11
11
  require_relative "internal/template_content_registry"
12
12
 
13
13
  module Dommy
14
- # Stub DocumentType (`<!doctype html>`) — exposes `name` and `nodeType=10`.
15
- # Real browsers also expose `publicId` / `systemId` which we leave empty
16
- # since HTML5 doctypes don't carry those.
14
+ # DocumentType (`<!doctype html>`) — exposes name / publicId / systemId and
15
+ # nodeType=10. HTML5 doctypes carry empty public/system IDs, but
16
+ # `implementation.createDocumentType` can set them.
17
17
  class DocumentType
18
18
  include Node
19
19
 
20
20
  attr_reader :name
21
21
 
22
- def initialize(name)
22
+ # `owner_document:` links a live doctype (document.doctype) to its document so
23
+ # the ChildNode methods (remove/before/after/replaceWith) act on the tree; a
24
+ # standalone doctype (DOMImplementation.createDocumentType) has none, so those
25
+ # methods are no-ops per spec.
26
+ def initialize(name, public_id = "", system_id = "", owner_document: nil)
23
27
  @name = name.to_s
28
+ @public_id = public_id.to_s
29
+ @system_id = system_id.to_s
30
+ @owner_document = owner_document
31
+ end
32
+
33
+ # ChildNode mixin — the doctype's parent is the document.
34
+ def remove
35
+ @owner_document&.__internal_remove_doctype__(self)
36
+ nil
37
+ end
38
+
39
+ def before(*nodes)
40
+ return nil unless @owner_document
41
+
42
+ @owner_document.__internal_insert_at_doctype__(nodes, after: false)
43
+ nil
44
+ end
45
+
46
+ def after(*nodes)
47
+ return nil unless @owner_document
48
+
49
+ @owner_document.__internal_insert_at_doctype__(nodes, after: true)
50
+ nil
51
+ end
52
+
53
+ def replace_with(*nodes)
54
+ return nil unless @owner_document
55
+
56
+ @owner_document.__internal_insert_at_doctype__(nodes, after: false)
57
+ remove
58
+ nil
24
59
  end
25
60
 
26
61
  def __js_get__(key)
27
62
  case key
28
63
  when "name"
29
64
  @name
65
+ when "nodeName"
66
+ # WHATWG: a DocumentType's nodeName is its name.
67
+ @name
30
68
  when "nodeType"
31
69
  10
32
70
  when "publicId"
33
- ""
71
+ @public_id
34
72
  when "systemId"
35
- ""
73
+ @system_id
74
+ end
75
+ end
76
+
77
+ include EventTarget
78
+
79
+ def __internal_event_parent__
80
+ nil
81
+ end
82
+
83
+ include Bridge::Methods
84
+ js_methods %w[isEqualNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition
85
+ appendChild insertBefore removeChild replaceChild before after replaceWith remove
86
+ addEventListener removeEventListener dispatchEvent]
87
+ def __js_call__(method, args)
88
+ case method
89
+ when "hasChildNodes"
90
+ false
91
+ when "isEqualNode"
92
+ is_equal_node(args[0])
93
+ when "isSameNode"
94
+ is_same_node(args[0])
95
+ when "getRootNode"
96
+ get_root_node(args[0])
97
+ when "compareDocumentPosition"
98
+ compare_document_position(args[0])
99
+ when "appendChild", "insertBefore"
100
+ raise DOMException::HierarchyRequestError, "a DocumentType may not have children"
101
+ when "removeChild", "replaceChild"
102
+ raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
103
+ when "before"
104
+ before(*args)
105
+ when "after"
106
+ after(*args)
107
+ when "replaceWith"
108
+ replace_with(*args)
109
+ when "remove"
110
+ remove
111
+ when "normalize"
112
+ nil
113
+ when "addEventListener"
114
+ add_event_listener(args[0], args[1], args[2])
115
+ when "removeEventListener"
116
+ remove_event_listener(args[0], args[1], args[2])
117
+ when "dispatchEvent"
118
+ dispatch_event(args[0])
119
+ end
120
+ end
121
+ end
122
+
123
+ # ProcessingInstruction (`<?target data?>`) — a CharacterData-like node with a
124
+ # `target`; created via `document.createProcessingInstruction`.
125
+ class ProcessingInstruction
126
+ include Node
127
+
128
+ attr_reader :target
129
+
130
+ def initialize(target, data)
131
+ @target = target.to_s
132
+ @data = data.to_s
133
+ end
134
+
135
+ include EventTarget
136
+
137
+ def data = @data
138
+
139
+ def __js_get__(key)
140
+ case key
141
+ when "target"
142
+ @target
143
+ when "data", "nodeValue", "textContent"
144
+ @data
145
+ when "nodeName"
146
+ @target
147
+ when "nodeType"
148
+ 7
149
+ when "length"
150
+ @data.length
151
+ end
152
+ end
153
+
154
+ def __js_set__(key, value)
155
+ case key
156
+ when "data", "nodeValue", "textContent"
157
+ @data = value.to_s
158
+ else
159
+ return Bridge::UNHANDLED
160
+ end
161
+ nil
162
+ end
163
+
164
+ # A PI is CharacterData: its data methods are string operations on @data.
165
+ def substring_data(offset, count)
166
+ o = offset.to_i
167
+ raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > @data.length
168
+
169
+ @data[o, count.to_i] || ""
170
+ end
171
+
172
+ def append_data(value)
173
+ @data += value.to_s
174
+ nil
175
+ end
176
+
177
+ def insert_data(offset, value)
178
+ o = offset.to_i
179
+ raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > @data.length
180
+
181
+ @data = @data[0, o].to_s + value.to_s + (@data[o..] || "")
182
+ nil
183
+ end
184
+
185
+ def delete_data(offset, count)
186
+ o = offset.to_i
187
+ raise DOMException::IndexSizeError, "offset out of bounds" if o.negative? || o > @data.length
188
+
189
+ @data = @data[0, o].to_s + (@data[(o + count.to_i)..] || "")
190
+ nil
191
+ end
192
+
193
+ def replace_data(offset, count, value)
194
+ delete_data(offset, count)
195
+ insert_data(offset, value)
196
+ nil
197
+ end
198
+
199
+ def __internal_event_parent__
200
+ nil
201
+ end
202
+
203
+ include Bridge::Methods
204
+ js_methods %w[isEqualNode isSameNode getRootNode normalize hasChildNodes
205
+ appendData insertData deleteData replaceData substringData compareDocumentPosition
206
+ appendChild insertBefore removeChild replaceChild before after replaceWith remove
207
+ addEventListener removeEventListener dispatchEvent]
208
+ def __js_call__(method, args)
209
+ case method
210
+ when "hasChildNodes"
211
+ false
212
+ when "isEqualNode"
213
+ is_equal_node(args[0])
214
+ when "isSameNode"
215
+ is_same_node(args[0])
216
+ when "getRootNode"
217
+ get_root_node(args[0])
218
+ when "compareDocumentPosition"
219
+ compare_document_position(args[0])
220
+ when "appendChild", "insertBefore"
221
+ raise DOMException::HierarchyRequestError, "a ProcessingInstruction may not have children"
222
+ when "removeChild", "replaceChild"
223
+ raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
224
+ when "before", "after", "replaceWith", "remove"
225
+ # ChildNode mixin: a created PI has no parent, so these are no-ops per
226
+ # spec (they act only when the node is inserted in a tree).
227
+ nil
228
+ when "normalize"
229
+ nil
230
+ when "substringData"
231
+ substring_data(args[0], args[1])
232
+ when "appendData"
233
+ append_data(args[0])
234
+ when "insertData"
235
+ insert_data(args[0], args[1])
236
+ when "deleteData"
237
+ delete_data(args[0], args[1])
238
+ when "replaceData"
239
+ replace_data(args[0], args[1], args[2])
240
+ when "addEventListener"
241
+ add_event_listener(args[0], args[1], args[2])
242
+ when "removeEventListener"
243
+ remove_event_listener(args[0], args[1], args[2])
244
+ when "dispatchEvent"
245
+ dispatch_event(args[0])
246
+ end
247
+ end
248
+ end
249
+
250
+ # `document.implementation` — the DOMImplementation. Only the node factories
251
+ # WPT exercises are provided; createDocument/createHTMLDocument are not yet
252
+ # implemented (foreign documents).
253
+ class DOMImplementation
254
+ def initialize(document)
255
+ @document = document
256
+ end
257
+
258
+ def create_document_type(qualified_name, public_id, system_id)
259
+ DocumentType.new(qualified_name, public_id, system_id)
260
+ end
261
+
262
+ # createDocument(namespace, qualifiedName, doctype?) — a fresh XML document,
263
+ # with a document element (namespace, qualifiedName) when qualifiedName is
264
+ # non-empty. (The doctype argument is accepted but not stored, as document
265
+ # equality compares only structure that survives wrap_node.)
266
+ def create_document(namespace, qualified_name, _doctype = nil)
267
+ doc = Document.new(nil, nokogiri_doc: Backend.document_class.new)
268
+ qn = qualified_name.to_s
269
+ unless qn.empty?
270
+ el = doc.send(:create_element_ns, namespace, qualified_name)
271
+ doc.nokogiri_doc.root = el.__dommy_backend_node__
272
+ end
273
+ doc
274
+ end
275
+
276
+ # createHTMLDocument(title?) — a fresh HTML document (doctype + html > head,
277
+ # body), with an optional <title>.
278
+ def create_html_document(title = nil)
279
+ doc = Document.new(nil, nokogiri_doc: Backend.parse("<!DOCTYPE html><html><head></head><body></body></html>"))
280
+ doc.title = title.to_s unless title.nil? || title.equal?(Bridge::UNDEFINED)
281
+ doc
282
+ end
283
+
284
+ def __js_get__(_key) = nil
285
+
286
+ include Bridge::Methods
287
+ js_methods %w[createDocumentType createDocument createHTMLDocument]
288
+ def __js_call__(method, args)
289
+ case method
290
+ when "createDocumentType"
291
+ create_document_type(args[0], args[1], args[2])
292
+ when "createDocument"
293
+ create_document(args[0], args[1], args[2])
294
+ when "createHTMLDocument"
295
+ create_html_document(args[0])
36
296
  end
37
297
  end
38
298
  end
@@ -44,7 +304,7 @@ module Dommy
44
304
  include EventTarget
45
305
  include Node
46
306
 
47
- attr_reader :body, :nokogiri_doc
307
+ attr_reader :nokogiri_doc
48
308
  attr_accessor :default_view
49
309
  # content_type defaults to "text/html"; settable so an integration layer
50
310
  # can reflect the response Content-Type. Read-only over the JS bridge.
@@ -59,12 +319,33 @@ module Dommy
59
319
  @cookie_jar = Internal::CookieJar.new
60
320
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
61
321
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
322
+ @node_iterators = []
62
323
  @nokogiri_doc = nokogiri_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
63
- body_node = @nokogiri_doc.at_css("body")
64
- @body = wrap_node(body_node) if body_node
65
324
  @content_type = "text/html"
66
325
  end
67
326
 
327
+ # Whether this is an "HTML document" in the DOM sense (created by the HTML
328
+ # parser / `text/html`), as opposed to an XML document. It drives the
329
+ # case-folding rules: `createElement` lowercases names and `Element#tagName`
330
+ # uppercases HTML-namespace names only in an HTML document. An XML or XHTML
331
+ # document (e.g. an `application/xhtml+xml` / `text/xml` resource) preserves
332
+ # case.
333
+ def html_document?
334
+ @content_type == "text/html"
335
+ end
336
+
337
+ # `document.compatMode` — "CSS1Compat" in no-quirks mode, "BackCompat" in
338
+ # quirks mode. A missing doctype is quirks; a bare `<!DOCTYPE html>` (no
339
+ # public/system identifier) is no-quirks. (The full quirks algorithm keys off
340
+ # specific legacy public ids; this covers the common cases.)
341
+ def compat_mode
342
+ dt = @nokogiri_doc.internal_subset
343
+ return "BackCompat" unless dt
344
+ return "CSS1Compat" if dt.name.to_s.downcase == "html" && dt.external_id.nil?
345
+
346
+ "BackCompat"
347
+ end
348
+
68
349
  # ----- Public Ruby API (snake_case) -----
69
350
 
70
351
  def title
@@ -76,13 +357,23 @@ module Dommy
76
357
  end
77
358
 
78
359
  def document_element
79
- wrap_node(@nokogiri_doc.at_css("html"))
360
+ # The document's root element — `<html>` for HTML, the actual root for XML.
361
+ wrap_node(@nokogiri_doc.root)
80
362
  end
81
363
 
82
364
  def head
83
365
  wrap_node(@nokogiri_doc.at_css("head"))
84
366
  end
85
367
 
368
+ # Resolve `body` fresh from the tree (not memoized) so it tracks a swapped
369
+ # `<body>` — e.g. Turbo's page render does
370
+ # `documentElement.replaceChild(newBody, body)`, after which a stale cached
371
+ # wrapper would keep returning the detached old body. wrap_node caches by
372
+ # node, so identity (`document.body === document.body`) still holds.
373
+ def body
374
+ wrap_node(@nokogiri_doc.at_css("body"))
375
+ end
376
+
86
377
  # Serialize the whole document to HTML (including the doctype).
87
378
  def to_html
88
379
  @nokogiri_doc.to_html
@@ -185,6 +476,15 @@ module Dommy
185
476
  end
186
477
  end
187
478
 
479
+ # All child nodes of the document (doctype + document element, …), as a live,
480
+ # cached NodeList — unlike `children`, which is element-only. Cached so
481
+ # `document.childNodes === document.childNodes` and mutations are reflected.
482
+ def child_nodes
483
+ @live_child_nodes ||= LiveNodeList.new do
484
+ @nokogiri_doc.children.map { |n| wrap_node(n) }.compact
485
+ end
486
+ end
487
+
188
488
  def child_element_count
189
489
  children.size
190
490
  end
@@ -200,7 +500,18 @@ module Dommy
200
500
  # Currently-focused element (or body if none). Updated via
201
501
  # `el.focus()` / `el.blur()`.
202
502
  def active_element
203
- @active_element || @body
503
+ @active_element || body
504
+ end
505
+
506
+ # `document.contains(node)` — true if `node` is the document itself or any
507
+ # node attached to its tree (per Node.contains, which all nodes including the
508
+ # document expose). Per spec, false for null / a non-Node.
509
+ def contains?(other)
510
+ return true if other.equal?(self)
511
+ return false unless other.respond_to?(:__dommy_backend_node__)
512
+
513
+ node = other.__dommy_backend_node__
514
+ node.document == @nokogiri_doc && node.ancestors.include?(@nokogiri_doc)
204
515
  end
205
516
 
206
517
  def __internal_set_active_element__(el)
@@ -226,6 +537,25 @@ module Dommy
226
537
  TreeWalker.new(root, what_to_show, filter)
227
538
  end
228
539
 
540
+ # WebIDL `unsigned long whatToShow = 0xFFFFFFFF`: an omitted or `undefined`
541
+ # argument uses the default; `null` coerces to 0; otherwise ToUint32.
542
+ def coerce_what_to_show(args, index)
543
+ return NodeFilter::SHOW_ALL if args.length <= index
544
+ value = args[index]
545
+ return NodeFilter::SHOW_ALL if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
546
+ return 0 if value.nil?
547
+
548
+ value.to_i % (2**32)
549
+ end
550
+
551
+ # A `null`/`undefined` filter argument means "no filter".
552
+ def normalize_filter(value)
553
+ return nil if value.nil?
554
+ return nil if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
555
+
556
+ value
557
+ end
558
+
229
559
  # Copy a node from another document into this one. The returned
230
560
  # wrapper is owned by `this`. Per spec, the source node is left
231
561
  # in place. `deep: true` copies the entire subtree.
@@ -340,7 +670,11 @@ module Dommy
340
670
  # `document.createNodeIterator(root, whatToShow?, filter?)` —
341
671
  # flat depth-first iteration.
342
672
  def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
343
- NodeIterator.new(root, what_to_show, filter)
673
+ iterator = NodeIterator.new(root, what_to_show, filter)
674
+ # Track live iterators so node removal can run the "NodeIterator
675
+ # pre-removing steps" (adjusting referenceNode) before a node detaches.
676
+ @node_iterators << iterator
677
+ iterator
344
678
  end
345
679
 
346
680
  # Minimal DocumentType — represents the `<!doctype html>` line.
@@ -348,7 +682,114 @@ module Dommy
348
682
  # stub object whose only useful field is `name`. Tests just need
349
683
  # `nodeType == 10`.
350
684
  def doctype
351
- @doctype ||= DocumentType.new("html")
685
+ return nil if @doctype_removed
686
+
687
+ @doctype ||= DocumentType.new("html", owner_document: self)
688
+ end
689
+
690
+ def implementation
691
+ @implementation ||= DOMImplementation.new(self)
692
+ end
693
+
694
+ def create_processing_instruction(target, data)
695
+ ProcessingInstruction.new(target, data)
696
+ end
697
+
698
+ # Append a node as a child of the document itself (e.g. a comment alongside
699
+ # the document element). Adopts the node into this document.
700
+ def append_child(node)
701
+ return node unless node.respond_to?(:__dommy_backend_node__)
702
+
703
+ @nokogiri_doc.add_child(node.__dommy_backend_node__)
704
+ node
705
+ end
706
+
707
+ # ParentNode / Node mutation on the document's direct children (the doctype
708
+ # and the document element). Operate on the Nokogiri document node; string
709
+ # arguments (which would need a text child the document can't hold) are
710
+ # ignored rather than raising.
711
+ def document_insert(args, prepend:)
712
+ nodes = args.filter_map { |a| backend_node(a) }
713
+ if prepend && (first = @nokogiri_doc.children.first)
714
+ nodes.reverse_each { |n| first.add_previous_sibling(n) }
715
+ else
716
+ nodes.each { |n| @nokogiri_doc.add_child(n) }
717
+ end
718
+ nil
719
+ end
720
+
721
+ def document_replace_children(args)
722
+ @nokogiri_doc.children.each(&:unlink)
723
+ args.filter_map { |a| backend_node(a) }.each { |n| @nokogiri_doc.add_child(n) }
724
+ nil
725
+ end
726
+
727
+ def document_remove_child(node)
728
+ # The doctype is synthesized from the Nokogiri DTD rather than wrapped as a
729
+ # tree node, so remove the internal subset directly.
730
+ return __internal_remove_doctype__(node) if node.is_a?(DocumentType)
731
+
732
+ bn = backend_node(node)
733
+ raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @nokogiri_doc
734
+
735
+ bn.unlink
736
+ node
737
+ end
738
+
739
+ def document_insert_before(node, ref)
740
+ bn = backend_node(node)
741
+ return node unless bn
742
+
743
+ ref_node = ref && backend_node(ref)
744
+ if ref_node && ref_node.parent == @nokogiri_doc
745
+ ref_node.add_previous_sibling(bn)
746
+ else
747
+ @nokogiri_doc.add_child(bn)
748
+ end
749
+ node
750
+ end
751
+
752
+ def document_replace_child(new_child, old_child)
753
+ old_bn = backend_node(old_child)
754
+ raise DOMException::NotFoundError, "node is not a child of this document" unless old_bn && old_bn.parent == @nokogiri_doc
755
+
756
+ new_bn = backend_node(new_child)
757
+ old_bn.add_previous_sibling(new_bn) if new_bn
758
+ old_bn.unlink
759
+ old_child
760
+ end
761
+
762
+ # Called by DocumentType#remove — the doctype is synthesized from the DTD, so
763
+ # remove the internal subset and mark it gone.
764
+ def __internal_remove_doctype__(_doctype)
765
+ @doctype_removed = true
766
+ @nokogiri_doc.internal_subset&.unlink
767
+ nil
768
+ end
769
+
770
+ # Called by DocumentType#before/#after — insert `nodes` before the doctype
771
+ # (at the document start) or after it (just before the document element).
772
+ def __internal_insert_at_doctype__(nodes, after:)
773
+ bns = nodes.filter_map { |n| backend_node(n) }
774
+ if after
775
+ root = @nokogiri_doc.root
776
+ root ? bns.each { |n| root.add_previous_sibling(n) } : bns.each { |n| @nokogiri_doc.add_child(n) }
777
+ else
778
+ first = @nokogiri_doc.children.first
779
+ first ? bns.reverse_each { |n| first.add_previous_sibling(n) } : bns.each { |n| @nokogiri_doc.add_child(n) }
780
+ end
781
+ nil
782
+ end
783
+
784
+ # `document.cloneNode(deep)` → a fresh Document over a (deep) copy of the
785
+ # Nokogiri tree, preserving the content type.
786
+ def clone_node(deep)
787
+ copy = deep ? @nokogiri_doc.dup : Backend.document_class.new
788
+ Document.new(nil, nokogiri_doc: copy).tap { |d| d.content_type = @content_type }
789
+ end
790
+
791
+ def backend_node(node)
792
+ node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
352
793
  end
353
794
 
354
795
  # Delegate to CookieJar
@@ -374,6 +815,10 @@ module Dommy
374
815
  @node_wrapper_cache.get_elements_by_name(name)
375
816
  end
376
817
 
818
+ def get_elements_by_tag_name_ns(namespace, local_name)
819
+ HTMLCollection.elements_by_tag_name_ns(@nokogiri_doc, self, namespace, local_name)
820
+ end
821
+
377
822
  # `document.write(html)` — legacy API. Appends parsed nodes to the
378
823
  # body. Real browsers only re-stream the DOM during initial parse;
379
824
  # this stub is enough for tests that fire write() during teardown.
@@ -382,8 +827,9 @@ module Dommy
382
827
  fragment = Parser.fragment(html, owner_doc: @nokogiri_doc)
383
828
  removed = []
384
829
  added = fragment.children.to_a
385
- added.each { |node| @body.__dommy_backend_node__.add_child(node) }
386
- notify_child_list_mutation(target_node: @body.__dommy_backend_node__, added_nodes: added, removed_nodes: removed)
830
+ body_node = body.__dommy_backend_node__
831
+ added.each { |node| body_node.add_child(node) }
832
+ notify_child_list_mutation(target_node: body_node, added_nodes: added, removed_nodes: removed)
387
833
  nil
388
834
  end
389
835
 
@@ -411,6 +857,10 @@ module Dommy
411
857
  @node_wrapper_cache.create_comment(text)
412
858
  end
413
859
 
860
+ def create_cdata_section(text)
861
+ @node_wrapper_cache.create_cdata_section(text)
862
+ end
863
+
414
864
  def create_document_fragment
415
865
  @node_wrapper_cache.create_document_fragment
416
866
  end
@@ -422,11 +872,13 @@ module Dommy
422
872
  def __js_get__(key)
423
873
  case key
424
874
  when "body"
425
- @body
875
+ body
426
876
  when "head"
427
877
  head
428
878
  when "doctype"
429
879
  doctype
880
+ when "implementation"
881
+ implementation
430
882
  when "defaultView"
431
883
  @default_view
432
884
  when "fullscreenElement"
@@ -436,7 +888,8 @@ module Dommy
436
888
  when "scrollingElement"
437
889
  wrap_node(@nokogiri_doc.at_css("html"))
438
890
  when "documentElement"
439
- wrap_node(@nokogiri_doc.at_css("html"))
891
+ # The document's root element — `<html>` for HTML, the actual root for XML.
892
+ wrap_node(@nokogiri_doc.root)
440
893
  when "title"
441
894
  read_title
442
895
  when "cookie"
@@ -455,6 +908,33 @@ module Dommy
455
908
  origin
456
909
  when "contentType"
457
910
  content_type
911
+ when "location"
912
+ # document.location is the same Location object as window.location.
913
+ @default_view&.__js_get__("location")
914
+ when "characterSet", "charset", "inputEncoding"
915
+ # The DOM is held as Ruby strings (UTF-8); we don't model other encodings.
916
+ "UTF-8"
917
+ when "dir"
918
+ document_element&.get_attribute("dir") || ""
919
+ when "designMode"
920
+ @design_mode || "off"
921
+ when "lastModified"
922
+ @last_modified || "01/01/1970 00:00:00"
923
+ when "readyState"
924
+ # The document is fully parsed by the time scripts run against it (there
925
+ # is no incremental network parse), so it is always "complete". Code that
926
+ # gates on `document.readyState === "loading"` (e.g. Turbo's preloader)
927
+ # therefore takes the already-loaded path.
928
+ "complete"
929
+ when "visibilityState"
930
+ # There's no real viewport/tab; the document is treated as the visible,
931
+ # foreground page (so `nextRepaint`-style code uses requestAnimationFrame,
932
+ # and `=== "visible"` checks pass).
933
+ "visible"
934
+ when "hidden"
935
+ false
936
+ when "compatMode"
937
+ compat_mode
458
938
  when "referrer"
459
939
  referrer
460
940
  when "links"
@@ -465,8 +945,28 @@ module Dommy
465
945
  scripts
466
946
  when "images"
467
947
  images
948
+ when "embeds", "plugins"
949
+ # Both reflect the same list of <embed> elements.
950
+ HTMLCollection.new { @nokogiri_doc.css("embed").map { |n| wrap_node(n) }.compact }
951
+ when "anchors"
952
+ # Historically `<a name>` (with a name attribute), not every link.
953
+ HTMLCollection.new { @nokogiri_doc.css("a[name]").map { |n| wrap_node(n) }.compact }
954
+ when "styleSheets"
955
+ # No CSS engine; expose an empty (but present + iterable) StyleSheetList
956
+ # so `document.styleSheets.length` / iteration don't blow up.
957
+ NodeList.new
468
958
  when "children"
469
959
  children
960
+ when "childNodes"
961
+ child_nodes
962
+ when "firstChild"
963
+ child_nodes.to_a.first
964
+ when "lastChild"
965
+ child_nodes.to_a.last
966
+ when "parentNode", "parentElement", "nextSibling", "previousSibling", "ownerDocument"
967
+ # A document is the tree root: no parent or siblings, and its
968
+ # ownerDocument is null per spec.
969
+ nil
470
970
  when "childElementCount"
471
971
  child_element_count
472
972
  when "firstElementChild"
@@ -486,26 +986,62 @@ module Dommy
486
986
  write_title(value.to_s)
487
987
  when "cookie"
488
988
  self.cookie = value.to_s
989
+ when "dir"
990
+ document_element&.set_attribute("dir", value.to_s)
991
+ when "designMode"
992
+ # Enumerated: only "on"/"off" (case-insensitive), else ignored.
993
+ v = value.to_s.downcase
994
+ @design_mode = v if %w[on off].include?(v)
995
+ when "location"
996
+ # `document.location = url` navigates, same as `location.href = url`.
997
+ loc = @default_view&.__js_get__("location")
998
+ loc&.__js_set__("href", value)
999
+ else
1000
+ return Bridge::UNHANDLED
489
1001
  end
490
1002
 
491
1003
  nil
492
1004
  end
493
1005
 
494
- # Methods routed through __js_call__ (keep in sync with its when-arms).
495
- JS_METHOD_NAMES = %w[
1006
+ include Bridge::Methods
1007
+ js_methods %w[
496
1008
  exitFullscreen startViewTransition createElement createElementNS createTextNode
497
- createComment createDocumentFragment querySelector querySelectorAll getElementById
498
- getElementsByClassName getElementsByTagName getElementsByName createAttribute
499
- createAttributeNS createTreeWalker createNodeIterator createEvent importNode adoptNode
500
- hasFocus getSelection elementFromPoint queryCommandSupported addEventListener
501
- removeEventListener dispatchEvent write open close
502
- ].freeze
503
- def __js_method_names__
504
- JS_METHOD_NAMES
505
- end
506
-
1009
+ createComment createCDATASection createProcessingInstruction createDocumentFragment querySelector querySelectorAll getElementById
1010
+ getElementsByClassName getElementsByTagName getElementsByTagNameNS getElementsByName createAttribute
1011
+ createAttributeNS createTreeWalker createNodeIterator createRange createEvent importNode
1012
+ adoptNode hasFocus getSelection elementFromPoint queryCommandSupported addEventListener
1013
+ removeEventListener dispatchEvent write writeln open close isEqualNode appendChild
1014
+ hasChildNodes contains append prepend replaceChildren removeChild insertBefore replaceChild
1015
+ cloneNode normalize
1016
+ ]
507
1017
  def __js_call__(method, args)
508
1018
  case method
1019
+ when "hasChildNodes"
1020
+ @nokogiri_doc.children.any?
1021
+ when "contains"
1022
+ contains?(args[0])
1023
+ when "isEqualNode"
1024
+ is_equal_node(args[0])
1025
+ when "appendChild"
1026
+ append_child(args[0])
1027
+ when "append"
1028
+ document_insert(args, prepend: false)
1029
+ when "prepend"
1030
+ document_insert(args, prepend: true)
1031
+ when "replaceChildren"
1032
+ document_replace_children(args)
1033
+ when "removeChild"
1034
+ document_remove_child(args[0])
1035
+ when "insertBefore"
1036
+ document_insert_before(args[0], args[1])
1037
+ when "replaceChild"
1038
+ document_replace_child(args[0], args[1])
1039
+ when "cloneNode"
1040
+ clone_node(args[0])
1041
+ when "normalize"
1042
+ nil # the document has no text children to merge
1043
+ when "writeln"
1044
+ write(*(args + ["\n"]))
509
1045
  when "exitFullscreen"
510
1046
  exit_fullscreen
511
1047
  when "startViewTransition"
@@ -528,16 +1064,22 @@ module Dommy
528
1064
  create_text_node(args[0])
529
1065
  when "createComment"
530
1066
  create_comment(args[0])
1067
+ when "createCDATASection"
1068
+ create_cdata_section(args[0])
1069
+ when "createProcessingInstruction"
1070
+ create_processing_instruction(args[0], args[1])
531
1071
  when "createDocumentFragment"
532
1072
  create_document_fragment
533
1073
  when "querySelector"
534
- query_selector(args[0])
1074
+ query_selector(Internal.css_query_arg!(args))
535
1075
  when "querySelectorAll"
536
- query_selector_all(args[0])
1076
+ query_selector_all(Internal.css_query_arg!(args))
537
1077
  when "getElementById"
538
1078
  get_element_by_id(args[0])
539
1079
  when "getElementsByClassName"
540
1080
  get_elements_by_class_name(args[0])
1081
+ when "getElementsByTagNameNS"
1082
+ get_elements_by_tag_name_ns(args[0], args[1])
541
1083
  when "getElementsByTagName"
542
1084
  get_elements_by_tag_name(args[0])
543
1085
  when "getElementsByName"
@@ -547,9 +1089,11 @@ module Dommy
547
1089
  when "createAttributeNS"
548
1090
  create_attribute_ns(args[0], args[1])
549
1091
  when "createTreeWalker"
550
- create_tree_walker(args[0], args[1] || NodeFilter::SHOW_ALL, args[2])
1092
+ create_tree_walker(args[0], coerce_what_to_show(args, 1), normalize_filter(args[2]))
551
1093
  when "createNodeIterator"
552
- create_node_iterator(args[0], args[1] || NodeFilter::SHOW_ALL, args[2])
1094
+ create_node_iterator(args[0], coerce_what_to_show(args, 1), normalize_filter(args[2]))
1095
+ when "createRange"
1096
+ create_range
553
1097
  when "createEvent"
554
1098
  create_event(args[0])
555
1099
  when "importNode"
@@ -567,7 +1111,7 @@ module Dommy
567
1111
  when "addEventListener"
568
1112
  add_event_listener(args[0], args[1], args[2])
569
1113
  when "removeEventListener"
570
- remove_event_listener(args[0], args[1])
1114
+ remove_event_listener(args[0], args[1], args[2])
571
1115
  when "dispatchEvent"
572
1116
  dispatch_event(args[0])
573
1117
  when "write"
@@ -664,11 +1208,43 @@ module Dommy
664
1208
  )
665
1209
  end
666
1210
 
667
- def notify_attribute_mutation(target_node:, attribute_name:, old_value:)
1211
+ # Unlink a backend node from its parent and queue a childList removal record
1212
+ # capturing the node's position (previous/next sibling) BEFORE the unlink, so
1213
+ # the record's previousSibling/nextSibling are correct (the coordinator can't
1214
+ # recover them once the node is detached). Used by every remove path.
1215
+ def remove_node_with_notify(node)
1216
+ parent = node.parent
1217
+ return unless parent
1218
+
1219
+ prev_w = node.previous_sibling && wrap_node(node.previous_sibling)
1220
+ next_w = node.next_sibling && wrap_node(node.next_sibling)
1221
+ run_node_iterator_pre_remove(node)
1222
+ node.unlink
1223
+ notify_child_list_mutation(
1224
+ target_node: parent,
1225
+ added_nodes: [],
1226
+ removed_nodes: [node],
1227
+ previous_sibling: prev_w,
1228
+ next_sibling: next_w
1229
+ )
1230
+ end
1231
+
1232
+ # Run the "NodeIterator pre-removing steps" for every live iterator before
1233
+ # `backend_node` is detached, so referenceNode/pointerBeforeReferenceNode
1234
+ # stay valid. `backend_node` must still be attached (tree intact) here.
1235
+ def run_node_iterator_pre_remove(backend_node)
1236
+ return if @node_iterators.empty?
1237
+
1238
+ removed = wrap_node(backend_node)
1239
+ @node_iterators.each { |iter| iter.pre_remove(removed) }
1240
+ end
1241
+
1242
+ def notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil)
668
1243
  @mutation_coordinator.notify_attribute_mutation(
669
1244
  target_node: target_node,
670
1245
  attribute_name: attribute_name,
671
- old_value: old_value
1246
+ old_value: old_value,
1247
+ namespace: namespace
672
1248
  )
673
1249
  end
674
1250
 
@@ -679,11 +1255,6 @@ module Dommy
679
1255
  )
680
1256
  end
681
1257
 
682
- # Spec-permitted name pattern (XML "Name" production restricted to
683
- # ASCII for practicality). Used by `createElement` and
684
- # `createAttribute` to validate the argument.
685
- NAME_RE = /\A[A-Za-z_][\w\-.:]*\z/.freeze
686
-
687
1258
  # Delegate factory methods to NodeWrapperCache
688
1259
 
689
1260
  def create_element(name)
@@ -736,7 +1307,7 @@ module Dommy
736
1307
  def clone_into_doc(source, deep)
737
1308
  copy = if source.element?
738
1309
  new_el = Backend.create_element(source.name, @nokogiri_doc)
739
- source.attribute_nodes.each { |a| new_el[a.name] = a.value }
1310
+ Backend.attribute_nodes(source).each { |a| new_el[a.name] = a.value }
740
1311
  new_el
741
1312
  elsif source.text?
742
1313
  Backend.create_text(source.content, @nokogiri_doc)
@@ -816,12 +1387,8 @@ module Dommy
816
1387
  end
817
1388
  end
818
1389
 
819
- # Methods routed through __js_call__ (keep in sync with its when-arms).
820
- JS_METHOD_NAMES = %w[skipTransition].freeze
821
- def __js_method_names__
822
- JS_METHOD_NAMES
823
- end
824
-
1390
+ include Bridge::Methods
1391
+ js_methods %w[skipTransition]
825
1392
  def __js_call__(method, _args)
826
1393
  case method
827
1394
  when "skipTransition"