dommy 0.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +10 -2
  4. data/lib/dommy/attr.rb +197 -32
  5. data/lib/dommy/backend/nokogiri_adapter.rb +127 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +117 -0
  7. data/lib/dommy/backend.rb +175 -0
  8. data/lib/dommy/blob.rb +30 -11
  9. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  10. data/lib/dommy/bridge/methods.rb +57 -0
  11. data/lib/dommy/bridge.rb +97 -0
  12. data/lib/dommy/callable_invoker.rb +36 -0
  13. data/lib/dommy/compression_streams.rb +4 -4
  14. data/lib/dommy/cookie_store.rb +4 -2
  15. data/lib/dommy/crypto.rb +16 -9
  16. data/lib/dommy/css.rb +53 -7
  17. data/lib/dommy/custom_elements.rb +33 -9
  18. data/lib/dommy/data_transfer.rb +4 -0
  19. data/lib/dommy/document.rb +693 -60
  20. data/lib/dommy/dom_parser.rb +29 -15
  21. data/lib/dommy/element.rb +1147 -438
  22. data/lib/dommy/event.rb +279 -79
  23. data/lib/dommy/event_source.rb +14 -10
  24. data/lib/dommy/fetch.rb +509 -39
  25. data/lib/dommy/file_reader.rb +14 -6
  26. data/lib/dommy/form_data.rb +3 -3
  27. data/lib/dommy/history.rb +46 -8
  28. data/lib/dommy/html_collection.rb +59 -6
  29. data/lib/dommy/html_elements.rb +153 -1502
  30. data/lib/dommy/internal/css_pseudo_handlers.rb +137 -0
  31. data/lib/dommy/internal/dom_matching.rb +3 -3
  32. data/lib/dommy/internal/global_functions.rb +26 -0
  33. data/lib/dommy/internal/idna.rb +16 -7
  34. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  35. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  36. data/lib/dommy/internal/namespaces.rb +70 -0
  37. data/lib/dommy/internal/node_equality.rb +86 -0
  38. data/lib/dommy/internal/node_traversal.rb +1 -1
  39. data/lib/dommy/internal/node_wrapper_cache.rb +77 -31
  40. data/lib/dommy/internal/observable_callback.rb +1 -5
  41. data/lib/dommy/internal/parent_node.rb +126 -0
  42. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  43. data/lib/dommy/internal/selector_parser.rb +664 -0
  44. data/lib/dommy/internal/template_content_registry.rb +6 -6
  45. data/lib/dommy/internal/url_parser.rb +677 -0
  46. data/lib/dommy/intersection_observer.rb +4 -2
  47. data/lib/dommy/location.rb +10 -4
  48. data/lib/dommy/media_query_list.rb +10 -4
  49. data/lib/dommy/message_channel.rb +41 -11
  50. data/lib/dommy/mutation_observer.rb +76 -23
  51. data/lib/dommy/navigator.rb +38 -24
  52. data/lib/dommy/node.rb +158 -16
  53. data/lib/dommy/notification.rb +6 -4
  54. data/lib/dommy/parser.rb +13 -13
  55. data/lib/dommy/performance.rb +4 -0
  56. data/lib/dommy/performance_observer.rb +4 -2
  57. data/lib/dommy/promise.rb +14 -14
  58. data/lib/dommy/range.rb +74 -5
  59. data/lib/dommy/resize_observer.rb +4 -2
  60. data/lib/dommy/scheduler.rb +34 -13
  61. data/lib/dommy/shadow_root.rb +31 -60
  62. data/lib/dommy/storage.rb +2 -0
  63. data/lib/dommy/streams.rb +40 -49
  64. data/lib/dommy/svg_elements.rb +204 -3606
  65. data/lib/dommy/text_codec.rb +178 -25
  66. data/lib/dommy/tree_walker.rb +270 -81
  67. data/lib/dommy/url.rb +305 -450
  68. data/lib/dommy/url_pattern.rb +2 -0
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/web_socket.rb +49 -19
  71. data/lib/dommy/window.rb +205 -203
  72. data/lib/dommy/worker.rb +12 -12
  73. data/lib/dommy/xml_http_request.rb +32 -7
  74. data/lib/dommy.rb +19 -2
  75. metadata +22 -27
@@ -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,8 +304,11 @@ 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
309
+ # content_type defaults to "text/html"; settable so an integration layer
310
+ # can reflect the response Content-Type. Read-only over the JS bridge.
311
+ attr_accessor :content_type
49
312
 
50
313
  def initialize(host = nil, nokogiri_doc: nil, default_view: nil)
51
314
  @host = host
@@ -56,9 +319,31 @@ module Dommy
56
319
  @cookie_jar = Internal::CookieJar.new
57
320
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
58
321
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
59
- @nokogiri_doc = nokogiri_doc || Nokogiri::HTML5("<!doctype html><html><head></head><body></body></html>")
60
- body_node = @nokogiri_doc.at_css("body")
61
- @body = wrap_node(body_node) if body_node
322
+ @node_iterators = []
323
+ @nokogiri_doc = nokogiri_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
324
+ @content_type = "text/html"
325
+ end
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"
62
347
  end
63
348
 
64
349
  # ----- Public Ruby API (snake_case) -----
@@ -72,13 +357,38 @@ module Dommy
72
357
  end
73
358
 
74
359
  def document_element
75
- 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)
76
362
  end
77
363
 
78
364
  def head
79
365
  wrap_node(@nokogiri_doc.at_css("head"))
80
366
  end
81
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
+
377
+ # Serialize the whole document to HTML (including the doctype).
378
+ def to_html
379
+ @nokogiri_doc.to_html
380
+ end
381
+
382
+ # XPath queries returning wrapped nodes (Element / TextNode / etc).
383
+ def at_xpath(expression)
384
+ node = @nokogiri_doc.at_xpath(expression)
385
+ node && wrap_node(node)
386
+ end
387
+
388
+ def xpath(expression)
389
+ @nokogiri_doc.xpath(expression).map { |node| wrap_node(node) }
390
+ end
391
+
82
392
  # `document.URL` / `documentURI` — both return location.href in
83
393
  # real browsers (legacy aliases of the same field).
84
394
  def url
@@ -116,6 +426,15 @@ module Dommy
116
426
  view.location.__js_get__("hostname").to_s
117
427
  end
118
428
 
429
+ # `document.origin` — serialized origin of the document URL, mirroring
430
+ # `window.location.origin`. Empty when there is no associated window.
431
+ def origin
432
+ view = @default_view
433
+ return "" unless view&.location
434
+
435
+ view.location.__js_get__("origin").to_s
436
+ end
437
+
119
438
  # `document.referrer` — Dommy never has a referring page, so this
120
439
  # is always empty.
121
440
  def referrer
@@ -157,6 +476,15 @@ module Dommy
157
476
  end
158
477
  end
159
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
+
160
488
  def child_element_count
161
489
  children.size
162
490
  end
@@ -172,10 +500,21 @@ module Dommy
172
500
  # Currently-focused element (or body if none). Updated via
173
501
  # `el.focus()` / `el.blur()`.
174
502
  def active_element
175
- @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)
176
515
  end
177
516
 
178
- def __set_active_element__(el)
517
+ def __internal_set_active_element__(el)
179
518
  @active_element = el
180
519
  end
181
520
 
@@ -198,13 +537,32 @@ module Dommy
198
537
  TreeWalker.new(root, what_to_show, filter)
199
538
  end
200
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
+
201
559
  # Copy a node from another document into this one. The returned
202
560
  # wrapper is owned by `this`. Per spec, the source node is left
203
561
  # in place. `deep: true` copies the entire subtree.
204
562
  def import_node(node, deep = false)
205
- return nil unless node.respond_to?(:__node__)
563
+ return nil unless node.respond_to?(:__dommy_backend_node__)
206
564
 
207
- copy = clone_into_doc(node.__node__, deep)
565
+ copy = clone_into_doc(node.__dommy_backend_node__, deep)
208
566
  wrap_node(copy)
209
567
  end
210
568
 
@@ -212,17 +570,32 @@ module Dommy
212
570
  # node is detached from its previous owner and its ownerDocument
213
571
  # becomes this. Returns the (possibly re-wrapped) node.
214
572
  def adopt_node(node)
215
- return nil unless node.respond_to?(:__node__)
573
+ return nil unless node.respond_to?(:__dommy_backend_node__)
216
574
 
217
- src = node.__node__
575
+ src = node.__dommy_backend_node__
218
576
  src.unlink if src.parent
219
- moved = if src.document == @nokogiri_doc
220
- src
221
- else
222
- clone_into_doc(src, true)
223
- end
224
577
 
225
- wrap_node(moved)
578
+ # Same document: just return the wrapper after the detach above.
579
+ return wrap_node(src) if src.document == @nokogiri_doc
580
+
581
+ # Cross-document: Nokogiri reassigns `src.document` when src is
582
+ # added under a node owned by another document. We transiently
583
+ # attach to our root, then unlink so src ends up free-floating
584
+ # but now belongs to @nokogiri_doc. The underlying Ruby object
585
+ # identity is preserved.
586
+ src_doc_wrapper = node.instance_variable_get(:@document)
587
+ @nokogiri_doc.root.add_child(src)
588
+ src.unlink
589
+
590
+ # Move the caller's Dommy wrapper from the source document's
591
+ # wrapper cache into ours, and re-point its @document. This
592
+ # keeps `adopt_node(x).equal?(x)` true across documents.
593
+ node.instance_variable_set(:@document, self)
594
+ if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
595
+ src_doc_wrapper.__internal_reset_wrapper__(src)
596
+ end
597
+ @node_wrapper_cache.register(src, node)
598
+ node
226
599
  end
227
600
 
228
601
  # Legacy `document.createEvent("EventName")` factory. Returns an
@@ -268,7 +641,7 @@ module Dommy
268
641
  # is the read side.
269
642
  attr_reader :fullscreen_element
270
643
 
271
- def __set_fullscreen_element__(element)
644
+ def __internal_set_fullscreen_element__(element)
272
645
  previous = @fullscreen_element
273
646
  @fullscreen_element = element
274
647
  return if previous == element
@@ -297,7 +670,11 @@ module Dommy
297
670
  # `document.createNodeIterator(root, whatToShow?, filter?)` —
298
671
  # flat depth-first iteration.
299
672
  def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
300
- 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
301
678
  end
302
679
 
303
680
  # Minimal DocumentType — represents the `<!doctype html>` line.
@@ -305,7 +682,114 @@ module Dommy
305
682
  # stub object whose only useful field is `name`. Tests just need
306
683
  # `nodeType == 10`.
307
684
  def doctype
308
- @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
309
793
  end
310
794
 
311
795
  # Delegate to CookieJar
@@ -331,6 +815,10 @@ module Dommy
331
815
  @node_wrapper_cache.get_elements_by_name(name)
332
816
  end
333
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
+
334
822
  # `document.write(html)` — legacy API. Appends parsed nodes to the
335
823
  # body. Real browsers only re-stream the DOM during initial parse;
336
824
  # this stub is enough for tests that fire write() during teardown.
@@ -339,8 +827,9 @@ module Dommy
339
827
  fragment = Parser.fragment(html, owner_doc: @nokogiri_doc)
340
828
  removed = []
341
829
  added = fragment.children.to_a
342
- added.each { |node| @body.__node__.add_child(node) }
343
- notify_child_list_mutation(target_node: @body.__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)
344
833
  nil
345
834
  end
346
835
 
@@ -368,6 +857,10 @@ module Dommy
368
857
  @node_wrapper_cache.create_comment(text)
369
858
  end
370
859
 
860
+ def create_cdata_section(text)
861
+ @node_wrapper_cache.create_cdata_section(text)
862
+ end
863
+
371
864
  def create_document_fragment
372
865
  @node_wrapper_cache.create_document_fragment
373
866
  end
@@ -379,11 +872,13 @@ module Dommy
379
872
  def __js_get__(key)
380
873
  case key
381
874
  when "body"
382
- @body
875
+ body
383
876
  when "head"
384
877
  head
385
878
  when "doctype"
386
879
  doctype
880
+ when "implementation"
881
+ implementation
387
882
  when "defaultView"
388
883
  @default_view
389
884
  when "fullscreenElement"
@@ -393,7 +888,8 @@ module Dommy
393
888
  when "scrollingElement"
394
889
  wrap_node(@nokogiri_doc.at_css("html"))
395
890
  when "documentElement"
396
- 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)
397
893
  when "title"
398
894
  read_title
399
895
  when "cookie"
@@ -408,6 +904,37 @@ module Dommy
408
904
  base_uri
409
905
  when "domain"
410
906
  domain
907
+ when "origin"
908
+ origin
909
+ when "contentType"
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
411
938
  when "referrer"
412
939
  referrer
413
940
  when "links"
@@ -418,8 +945,28 @@ module Dommy
418
945
  scripts
419
946
  when "images"
420
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
421
958
  when "children"
422
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
423
970
  when "childElementCount"
424
971
  child_element_count
425
972
  when "firstElementChild"
@@ -439,13 +986,62 @@ module Dommy
439
986
  write_title(value.to_s)
440
987
  when "cookie"
441
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
442
1001
  end
443
1002
 
444
1003
  nil
445
1004
  end
446
1005
 
1006
+ include Bridge::Methods
1007
+ js_methods %w[
1008
+ exitFullscreen startViewTransition createElement createElementNS createTextNode
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
+ ]
447
1017
  def __js_call__(method, args)
448
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"]))
449
1045
  when "exitFullscreen"
450
1046
  exit_fullscreen
451
1047
  when "startViewTransition"
@@ -468,16 +1064,22 @@ module Dommy
468
1064
  create_text_node(args[0])
469
1065
  when "createComment"
470
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])
471
1071
  when "createDocumentFragment"
472
1072
  create_document_fragment
473
1073
  when "querySelector"
474
- query_selector(args[0])
1074
+ query_selector(Internal.css_query_arg!(args))
475
1075
  when "querySelectorAll"
476
- query_selector_all(args[0])
1076
+ query_selector_all(Internal.css_query_arg!(args))
477
1077
  when "getElementById"
478
1078
  get_element_by_id(args[0])
479
1079
  when "getElementsByClassName"
480
1080
  get_elements_by_class_name(args[0])
1081
+ when "getElementsByTagNameNS"
1082
+ get_elements_by_tag_name_ns(args[0], args[1])
481
1083
  when "getElementsByTagName"
482
1084
  get_elements_by_tag_name(args[0])
483
1085
  when "getElementsByName"
@@ -487,9 +1089,11 @@ module Dommy
487
1089
  when "createAttributeNS"
488
1090
  create_attribute_ns(args[0], args[1])
489
1091
  when "createTreeWalker"
490
- 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]))
491
1093
  when "createNodeIterator"
492
- 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
493
1097
  when "createEvent"
494
1098
  create_event(args[0])
495
1099
  when "importNode"
@@ -507,7 +1111,7 @@ module Dommy
507
1111
  when "addEventListener"
508
1112
  add_event_listener(args[0], args[1], args[2])
509
1113
  when "removeEventListener"
510
- remove_event_listener(args[0], args[1])
1114
+ remove_event_listener(args[0], args[1], args[2])
511
1115
  when "dispatchEvent"
512
1116
  dispatch_event(args[0])
513
1117
  when "write"
@@ -521,7 +1125,7 @@ module Dommy
521
1125
  end
522
1126
  end
523
1127
 
524
- def __event_parent__
1128
+ def __internal_event_parent__
525
1129
  @default_view
526
1130
  end
527
1131
 
@@ -533,7 +1137,7 @@ module Dommy
533
1137
  # Clear the cached wrapper so the next `wrap_node` creates a new
534
1138
  # one. Used by `customElements.define` to upgrade nodes that were
535
1139
  # constructed before the registration landed.
536
- def __reset_wrapper__(nokogiri_node)
1140
+ def __internal_reset_wrapper__(nokogiri_node)
537
1141
  @node_wrapper_cache.reset_wrapper(nokogiri_node)
538
1142
  end
539
1143
 
@@ -543,15 +1147,15 @@ module Dommy
543
1147
  # node back to its shadow boundary.
544
1148
  # Delegate to ShadowRootRegistry
545
1149
 
546
- def __register_shadow_fragment__(fragment_node, shadow_root)
1150
+ def __internal_register_shadow_fragment__(fragment_node, shadow_root)
547
1151
  @shadow_registry.register(fragment_node, shadow_root)
548
1152
  end
549
1153
 
550
- def __shadow_root_for_fragment__(fragment_node)
1154
+ def __internal_shadow_root_for_fragment__(fragment_node)
551
1155
  @shadow_registry.find_for_fragment(fragment_node)
552
1156
  end
553
1157
 
554
- def __shadow_root_containing__(node)
1158
+ def __internal_shadow_root_containing__(node)
555
1159
  @shadow_registry.find_enclosing(node)
556
1160
  end
557
1161
 
@@ -560,23 +1164,23 @@ module Dommy
560
1164
  # break the whole mutation pipeline.
561
1165
  # Delegate to MutationCoordinator
562
1166
 
563
- def __notify_connected__(element)
1167
+ def __internal_notify_connected__(element)
564
1168
  @mutation_coordinator.notify_connected(element)
565
1169
  end
566
1170
 
567
- def __notify_disconnected__(element)
1171
+ def __internal_notify_disconnected__(element)
568
1172
  @mutation_coordinator.notify_disconnected(element)
569
1173
  end
570
1174
 
571
- def __notify_connected_subtree__(nk)
1175
+ def __internal_notify_connected_subtree__(nk)
572
1176
  @mutation_coordinator.notify_connected_subtree(nk)
573
1177
  end
574
1178
 
575
- def __notify_disconnected_subtree__(nk)
1179
+ def __internal_notify_disconnected_subtree__(nk)
576
1180
  @mutation_coordinator.notify_disconnected_subtree(nk)
577
1181
  end
578
1182
 
579
- def __notify_attribute_changed__(element, name, old_value, new_value)
1183
+ def __internal_notify_attribute_changed__(element, name, old_value, new_value)
580
1184
  @mutation_coordinator.notify_attribute_changed(element, name, old_value, new_value)
581
1185
  end
582
1186
 
@@ -604,11 +1208,43 @@ module Dommy
604
1208
  )
605
1209
  end
606
1210
 
607
- 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)
608
1243
  @mutation_coordinator.notify_attribute_mutation(
609
1244
  target_node: target_node,
610
1245
  attribute_name: attribute_name,
611
- old_value: old_value
1246
+ old_value: old_value,
1247
+ namespace: namespace
612
1248
  )
613
1249
  end
614
1250
 
@@ -619,11 +1255,6 @@ module Dommy
619
1255
  )
620
1256
  end
621
1257
 
622
- # Spec-permitted name pattern (XML "Name" production restricted to
623
- # ASCII for practicality). Used by `createElement` and
624
- # `createAttribute` to validate the argument.
625
- NAME_RE = /\A[A-Za-z_][\w\-.:]*\z/.freeze
626
-
627
1258
  # Delegate factory methods to NodeWrapperCache
628
1259
 
629
1260
  def create_element(name)
@@ -675,17 +1306,17 @@ module Dommy
675
1306
  # adoptNode for cross-document transfer.
676
1307
  def clone_into_doc(source, deep)
677
1308
  copy = if source.element?
678
- new_el = Nokogiri::XML::Node.new(source.name, @nokogiri_doc)
679
- source.attribute_nodes.each { |a| new_el[a.name] = a.value }
1309
+ new_el = Backend.create_element(source.name, @nokogiri_doc)
1310
+ Backend.attribute_nodes(source).each { |a| new_el[a.name] = a.value }
680
1311
  new_el
681
1312
  elsif source.text?
682
- Nokogiri::XML::Text.new(source.content, @nokogiri_doc)
683
- elsif source.is_a?(Nokogiri::XML::Comment)
684
- Nokogiri::XML::Comment.new(@nokogiri_doc, source.content)
1313
+ Backend.create_text(source.content, @nokogiri_doc)
1314
+ elsif source.is_a?(Backend.comment_class)
1315
+ Backend.create_comment(source.content, @nokogiri_doc)
685
1316
  else
686
1317
  # Fallback: serialize + reparse via fragment for unusual types.
687
1318
  fragment = Parser.fragment(source.to_html, owner_doc: @nokogiri_doc)
688
- fragment.children.first || Nokogiri::XML::Text.new("", @nokogiri_doc)
1319
+ fragment.children.first || Backend.create_text("", @nokogiri_doc)
689
1320
  end
690
1321
 
691
1322
  if deep && source.respond_to?(:children)
@@ -709,12 +1340,12 @@ module Dommy
709
1340
 
710
1341
  title = head.at_css("title")
711
1342
  unless title
712
- title = Nokogiri::XML::Node.new("title", @nokogiri_doc)
1343
+ title = Backend.create_element("title", @nokogiri_doc)
713
1344
  head.add_child(title)
714
1345
  end
715
1346
 
716
1347
  title.children.each(&:unlink)
717
- title.add_child(Nokogiri::XML::Text.new(value, @nokogiri_doc))
1348
+ title.add_child(Backend.create_text(value, @nokogiri_doc))
718
1349
  end
719
1350
 
720
1351
  end
@@ -756,6 +1387,8 @@ module Dommy
756
1387
  end
757
1388
  end
758
1389
 
1390
+ include Bridge::Methods
1391
+ js_methods %w[skipTransition]
759
1392
  def __js_call__(method, _args)
760
1393
  case method
761
1394
  when "skipTransition"