dommy 0.14.0 → 0.16.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -1
  3. data/lib/dommy/attr.rb +56 -32
  4. data/lib/dommy/backend.rb +251 -208
  5. data/lib/dommy/blob.rb +24 -0
  6. data/lib/dommy/bridge/methods.rb +12 -2
  7. data/lib/dommy/bridge/values.rb +29 -2
  8. data/lib/dommy/bridge/wire_tags.rb +8 -4
  9. data/lib/dommy/browser.rb +328 -53
  10. data/lib/dommy/callable_invoker.rb +27 -31
  11. data/lib/dommy/character_data.rb +21 -28
  12. data/lib/dommy/class_list.rb +55 -23
  13. data/lib/dommy/cookie_jar.rb +408 -0
  14. data/lib/dommy/cookie_store.rb +104 -49
  15. data/lib/dommy/css.rb +37 -8
  16. data/lib/dommy/custom_elements.rb +360 -116
  17. data/lib/dommy/data_transfer.rb +22 -2
  18. data/lib/dommy/document.rb +1258 -346
  19. data/lib/dommy/dom_exception.rb +6 -0
  20. data/lib/dommy/dom_parser.rb +46 -14
  21. data/lib/dommy/dom_string_list.rb +36 -0
  22. data/lib/dommy/element.rb +408 -569
  23. data/lib/dommy/element_internals.rb +215 -0
  24. data/lib/dommy/encodings.rb +10 -0
  25. data/lib/dommy/event.rb +382 -148
  26. data/lib/dommy/event_source.rb +8 -34
  27. data/lib/dommy/fetch.rb +8 -12
  28. data/lib/dommy/file_reader.rb +5 -29
  29. data/lib/dommy/form_data.rb +53 -16
  30. data/lib/dommy/form_entry_list.rb +107 -138
  31. data/lib/dommy/fragment.rb +14 -16
  32. data/lib/dommy/history.rb +247 -90
  33. data/lib/dommy/html_canvas_element.rb +15 -35
  34. data/lib/dommy/html_collection.rb +76 -50
  35. data/lib/dommy/html_elements/base.rb +377 -71
  36. data/lib/dommy/html_elements/embedded.rb +350 -106
  37. data/lib/dommy/html_elements/forms/form.rb +424 -383
  38. data/lib/dommy/html_elements/forms/input.rb +351 -189
  39. data/lib/dommy/html_elements/forms/select.rb +97 -81
  40. data/lib/dommy/html_elements/interactive.rb +285 -72
  41. data/lib/dommy/html_elements/media.rb +73 -32
  42. data/lib/dommy/html_elements/metadata.rb +226 -78
  43. data/lib/dommy/html_elements/miscellaneous.rb +58 -14
  44. data/lib/dommy/html_elements/tables.rb +20 -42
  45. data/lib/dommy/html_elements.rb +28 -3
  46. data/lib/dommy/hyperlink_utils.rb +19 -8
  47. data/lib/dommy/interaction/drag_and_drop.rb +187 -0
  48. data/lib/dommy/interaction/event_synthesis.rb +182 -24
  49. data/lib/dommy/interaction/field_interactor.rb +18 -6
  50. data/lib/dommy/interaction/form_submission.rb +12 -6
  51. data/lib/dommy/interaction/key_sender.rb +102 -17
  52. data/lib/dommy/interaction.rb +1 -0
  53. data/lib/dommy/internal/accessibility_tree.rb +19 -7
  54. data/lib/dommy/internal/accessibility_visibility.rb +2 -2
  55. data/lib/dommy/internal/accessible_description.rb +3 -3
  56. data/lib/dommy/internal/accessible_name.rb +33 -45
  57. data/lib/dommy/internal/an_plus_b.rb +125 -0
  58. data/lib/dommy/internal/aria_role.rb +27 -18
  59. data/lib/dommy/internal/aria_state.rb +7 -7
  60. data/lib/dommy/internal/autofill.rb +2 -2
  61. data/lib/dommy/internal/backend_prefilter.rb +21 -18
  62. data/lib/dommy/internal/ce_reactions.rb +285 -0
  63. data/lib/dommy/internal/ce_reactions_table.rb +296 -0
  64. data/lib/dommy/internal/child_list.rb +39 -0
  65. data/lib/dommy/internal/child_navigable.rb +155 -0
  66. data/lib/dommy/internal/child_node.rb +44 -41
  67. data/lib/dommy/internal/close_watcher_manager.rb +238 -0
  68. data/lib/dommy/internal/complex_match.rb +129 -0
  69. data/lib/dommy/internal/constraint_validation.rb +130 -0
  70. data/lib/dommy/internal/css/ancestor_filter.rb +107 -0
  71. data/lib/dommy/internal/css/cascade.rb +7 -7
  72. data/lib/dommy/internal/css/cascaded_declarations.rb +14 -20
  73. data/lib/dommy/internal/css/computed_style_builder.rb +30 -3
  74. data/lib/dommy/internal/css/custom_properties.rb +63 -80
  75. data/lib/dommy/internal/css/parser.rb +53 -63
  76. data/lib/dommy/internal/css/renderability.rb +6 -0
  77. data/lib/dommy/internal/css/rule_index.rb +170 -108
  78. data/lib/dommy/internal/css/rule_index_inputs.rb +135 -0
  79. data/lib/dommy/internal/css/selector_dependencies.rb +8 -2
  80. data/lib/dommy/internal/css/style_cache.rb +9 -6
  81. data/lib/dommy/internal/css/ua_stylesheet.rb +37 -2
  82. data/lib/dommy/internal/css_source.rb +223 -0
  83. data/lib/dommy/internal/css_syntax.rb +153 -0
  84. data/lib/dommy/internal/declarative_shadow_roots.rb +182 -0
  85. data/lib/dommy/internal/directionality.rb +2 -2
  86. data/lib/dommy/internal/document_generations.rb +91 -18
  87. data/lib/dommy/internal/document_interaction_state.rb +293 -9
  88. data/lib/dommy/internal/dom_matching.rb +7 -7
  89. data/lib/dommy/internal/element_aria.rb +65 -23
  90. data/lib/dommy/internal/element_css_inline_style.rb +26 -0
  91. data/lib/dommy/internal/element_editing.rb +41 -0
  92. data/lib/dommy/internal/element_nonce.rb +28 -0
  93. data/lib/dommy/internal/element_popover.rb +310 -0
  94. data/lib/dommy/internal/element_shadow.rb +107 -33
  95. data/lib/dommy/internal/element_state.rb +131 -26
  96. data/lib/dommy/internal/element_top_layer.rb +21 -51
  97. data/lib/dommy/internal/enumerated_keyword_sets.rb +40 -0
  98. data/lib/dommy/internal/event_handler_tables.rb +241 -0
  99. data/lib/dommy/internal/event_handlers.rb +149 -0
  100. data/lib/dommy/internal/exception_report.rb +1 -1
  101. data/lib/dommy/internal/focusability.rb +267 -0
  102. data/lib/dommy/internal/form_associated_custom_elements.rb +127 -0
  103. data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +86 -0
  104. data/lib/dommy/internal/html_serialization.rb +292 -0
  105. data/lib/dommy/internal/idl_reflection.rb +202 -0
  106. data/lib/dommy/internal/idl_reflection_table.rb +589 -0
  107. data/lib/dommy/internal/infra.rb +36 -0
  108. data/lib/dommy/internal/input_type.rb +90 -20
  109. data/lib/dommy/internal/insertion_point.rb +27 -3
  110. data/lib/dommy/internal/js_number.rb +60 -0
  111. data/lib/dommy/internal/light_dismiss.rb +178 -0
  112. data/lib/dommy/internal/literal_lookup.rb +103 -0
  113. data/lib/dommy/internal/mime_type.rb +8 -0
  114. data/lib/dommy/internal/mutation_coordinator.rb +94 -60
  115. data/lib/dommy/internal/node_adopter.rb +165 -97
  116. data/lib/dommy/internal/node_factory.rb +111 -49
  117. data/lib/dommy/internal/node_identity.rb +1 -1
  118. data/lib/dommy/internal/node_traversal.rb +30 -13
  119. data/lib/dommy/internal/node_wrapper_cache.rb +34 -148
  120. data/lib/dommy/internal/observable_callback.rb +2 -5
  121. data/lib/dommy/internal/observer_options.rb +24 -14
  122. data/lib/dommy/internal/origin.rb +86 -0
  123. data/lib/dommy/internal/parent_node.rb +12 -10
  124. data/lib/dommy/internal/popover_invoker_element.rb +67 -0
  125. data/lib/dommy/internal/popover_stack.rb +120 -0
  126. data/lib/dommy/internal/post_insertion_steps.rb +146 -77
  127. data/lib/dommy/internal/reflected_attributes.rb +173 -43
  128. data/lib/dommy/internal/rendered_text/collector.rb +12 -9
  129. data/lib/dommy/internal/rendered_text/fragment.rb +6 -2
  130. data/lib/dommy/internal/selector_arguments.rb +2 -16
  131. data/lib/dommy/internal/selector_index.rb +19 -10
  132. data/lib/dommy/internal/selector_match.rb +190 -0
  133. data/lib/dommy/internal/selector_matcher.rb +91 -256
  134. data/lib/dommy/internal/selector_parser.rb +176 -100
  135. data/lib/dommy/internal/sequential_focus_navigation.rb +210 -0
  136. data/lib/dommy/internal/shadow_root_registry.rb +14 -6
  137. data/lib/dommy/internal/sibling_positions.rb +59 -0
  138. data/lib/dommy/internal/svg_uri_reference.rb +33 -0
  139. data/lib/dommy/internal/template_content_registry.rb +11 -5
  140. data/lib/dommy/internal/text_selection.rb +185 -32
  141. data/lib/dommy/internal/toggle_events.rb +6 -4
  142. data/lib/dommy/internal/unsafe_html.rb +107 -0
  143. data/lib/dommy/internal/url_parser.rb +84 -8
  144. data/lib/dommy/internal/user_activation.rb +192 -0
  145. data/lib/dommy/internal/web_idl.rb +160 -16
  146. data/lib/dommy/internal/window_constructors.rb +34 -14
  147. data/lib/dommy/internal/xml_serialization.rb +9 -45
  148. data/lib/dommy/js/constructor_resolver.rb +18 -0
  149. data/lib/dommy/js/custom_element_bridge.rb +57 -82
  150. data/lib/dommy/js/dom_interfaces.rb +51 -15
  151. data/lib/dommy/js/host_bridge.rb +189 -40
  152. data/lib/dommy/js/host_callbacks.rb +10 -0
  153. data/lib/dommy/js/host_runtime.js +1921 -416
  154. data/lib/dommy/js/marshaller.rb +13 -4
  155. data/lib/dommy/js/module_loader.rb +46 -5
  156. data/lib/dommy/js/module_preload.rb +194 -0
  157. data/lib/dommy/js/runtime.rb +9 -0
  158. data/lib/dommy/js/script_boot.rb +200 -114
  159. data/lib/dommy/js/serialized_record.rb +32 -0
  160. data/lib/dommy/js/webidl_event_handlers.js +45 -0
  161. data/lib/dommy/js/webidl_members.js +196 -0
  162. data/lib/dommy/js/webidl_signatures.js +2055 -0
  163. data/lib/dommy/js/webidl_tables.js +72 -101
  164. data/lib/dommy/js/window_wiring.rb +9 -5
  165. data/lib/dommy/location.rb +129 -76
  166. data/lib/dommy/mathml_elements.rb +13 -0
  167. data/lib/dommy/media_query_list.rb +4 -13
  168. data/lib/dommy/message_channel.rb +137 -64
  169. data/lib/dommy/mutation_observer.rb +3 -7
  170. data/lib/dommy/navigation.rb +61 -11
  171. data/lib/dommy/navigator.rb +132 -10
  172. data/lib/dommy/node.rb +81 -44
  173. data/lib/dommy/notification.rb +10 -6
  174. data/lib/dommy/parser.rb +5 -16
  175. data/lib/dommy/promise.rb +20 -26
  176. data/lib/dommy/range.rb +20 -47
  177. data/lib/dommy/resources.rb +38 -3
  178. data/lib/dommy/scheduler.rb +176 -48
  179. data/lib/dommy/shadow_root.rb +125 -25
  180. data/lib/dommy/storage.rb +135 -25
  181. data/lib/dommy/streams.rb +2 -6
  182. data/lib/dommy/structured_data.rb +45 -0
  183. data/lib/dommy/style_declaration.rb +38 -6
  184. data/lib/dommy/svg_elements.rb +25 -11
  185. data/lib/dommy/text_codec.rb +2 -14
  186. data/lib/dommy/tree_walker.rb +12 -26
  187. data/lib/dommy/url.rb +12 -9
  188. data/lib/dommy/url_pattern.rb +1 -9
  189. data/lib/dommy/validity_state.rb +109 -107
  190. data/lib/dommy/version.rb +1 -1
  191. data/lib/dommy/web_socket.rb +10 -33
  192. data/lib/dommy/window.rb +817 -105
  193. data/lib/dommy/worker.rb +4 -28
  194. data/lib/dommy/xml_http_request.rb +32 -45
  195. data/lib/dommy.rb +43 -10
  196. metadata +52 -5
  197. data/lib/dommy/backend/makiri_adapter.rb +0 -408
  198. data/lib/dommy/internal/cookie_jar.rb +0 -29
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "uri"
4
+ require "time"
4
5
 
5
6
  require_relative "internal/node_wrapper_cache"
6
7
  require_relative "internal/directionality"
7
8
  require_relative "internal/node_factory"
8
9
  require_relative "internal/mutation_coordinator"
9
10
  require_relative "internal/shadow_root_registry"
10
- require_relative "internal/cookie_jar"
11
+ require_relative "cookie_jar"
11
12
  require_relative "internal/node_traversal"
12
13
  require_relative "internal/node_adopter"
13
14
  require_relative "internal/observer_manager"
@@ -35,9 +36,8 @@ module Dommy
35
36
  # insert steps, neither of which the old doctype-specific path did.
36
37
  include Internal::ChildNode
37
38
 
38
- # Mixed into a node-backed doctype only, so a synthetic one does NOT respond
39
- # to `__dommy_backend_node__` — leaving the Node mixin's guards (which key off
40
- # `respond_to?(:__dommy_backend_node__)`) to treat it as disconnected.
39
+ # Mixed into a node-backed doctype only, so a synthetic one keeps Node's nil
40
+ # `__dommy_backend_node__` and is treated as disconnected.
41
41
  module NodeBacked
42
42
  def __dommy_backend_node__ = @__node__
43
43
  end
@@ -178,6 +178,14 @@ module Dommy
178
178
  nil
179
179
  when "isConnected"
180
180
  is_connected?
181
+ when "textContent", "nodeValue"
182
+ nil
183
+ when "baseURI"
184
+ @owner_document&.base_uri
185
+ else
186
+ # A doctype is a Node and a ChildNode only: Element's and ParentNode's
187
+ # getters (tagName, children, ...) are not its members.
188
+ Bridge::ABSENT
181
189
  end
182
190
  end
183
191
 
@@ -322,7 +330,18 @@ module Dommy
322
330
  # with, in tree order, the doctype (when given) then a document element
323
331
  # (namespace, qualifiedName) when qualifiedName is non-empty.
324
332
  def create_document(namespace, qualified_name, doctype = nil)
333
+ # WebIDL converts the interface argument before running the DOM steps
334
+ # (including qualified-name validation).
335
+ unless doctype.nil? || doctype.equal?(Bridge::UNDEFINED) || doctype.is_a?(DocumentType)
336
+ raise Bridge::TypeError, "doctype must be a DocumentType"
337
+ end
338
+
325
339
  doc = Document.new(nil, backend_doc: Backend.empty_xml_document)
340
+ # The result is an XMLDocument (DOM's createDocument), unlike a DOMParser
341
+ # result of the same content type — so the interface is pinned here.
342
+ doc.__internal_xml_document__ = true
343
+ # Its origin is the associated document's (DOM createDocument step 7).
344
+ doc.__internal_set_creator__(@document)
326
345
  # createDocument's content type is keyed off the namespace. None is
327
346
  # "text/html", so tagName keeps its case; xhtml+xml still routes
328
347
  # createElement to the HTML namespace (so an XHTML document isEqualNode
@@ -336,39 +355,24 @@ module Dommy
336
355
  qn = qualified_name.to_s
337
356
  unless qn.empty?
338
357
  el = doc.send(:create_element_ns, namespace, qualified_name)
339
- Backend.set_document_root(doc.backend_doc, el.__dommy_backend_node__)
358
+ doc.backend_doc.add_child(el.__dommy_backend_node__)
340
359
  end
341
360
  adopt_doctype_into(doc, doctype)
342
361
  doc
343
362
  end
344
363
 
345
- private
346
-
347
- # Place `doctype` (a DocumentType passed to createDocument) as `doc`'s first
348
- # child. createDocument appends the doctype node itself (step 5, before
349
- # step 6 appends the element), so it goes through the ordinary insert: the
350
- # adoption re-binds the caller's wrapper, and `xmlDoc.firstChild ===
351
- # doctype`, its parentNode and ownerDocument all follow.
352
- #
353
- # No-op for nil/undefined or a non-DocumentType. A doctype the XML backend
354
- # cannot hold is left unplaced rather than thrown: createDocument itself
355
- # validates none of it — only an XML serialization would.
356
- def adopt_doctype_into(doc, doctype)
357
- return if doctype.nil? || doctype.equal?(Bridge::UNDEFINED)
358
- return unless doctype.is_a?(DocumentType)
359
-
360
- doc.insert_before(doctype, doc.document_element)
361
- rescue DOMException::NotSupportedError
362
- nil
363
- end
364
-
365
- public
366
-
367
364
  # createHTMLDocument(title?) — a fresh HTML document (doctype + html > head,
368
- # body), with an optional <title>.
365
+ # body), with an optional <title>. A given title is a title element in the
366
+ # head holding one Text node of exactly that data, "" included — which
367
+ # the title setter's string-replace-all would leave empty.
369
368
  def create_html_document(title = nil)
370
369
  doc = Document.new(nil, backend_doc: Backend.parse("<!DOCTYPE html><html><head></head><body></body></html>"))
371
- doc.title = title.to_s unless title.nil? || title.equal?(Bridge::UNDEFINED)
370
+ # Its origin is the associated document's (DOM createHTMLDocument step 8).
371
+ doc.__internal_set_creator__(@document)
372
+ unless title.nil? || title.equal?(Bridge::UNDEFINED)
373
+ element = doc.head.append_child(doc.create_element("title"))
374
+ element.append_child(doc.create_text_node(title.to_s))
375
+ end
372
376
  doc
373
377
  end
374
378
 
@@ -381,6 +385,10 @@ module Dommy
381
385
  when "createDocumentType"
382
386
  create_document_type(args[0], args[1], args[2])
383
387
  when "createDocument"
388
+ # namespace and qualifiedName are required (either may be null, but
389
+ # must be present); doctype is optional.
390
+ raise Bridge::TypeError, "createDocument requires 2 arguments." if args.length < 2
391
+
384
392
  create_document(args[0], args[1], args[2])
385
393
  when "createHTMLDocument"
386
394
  # title is an OPTIONAL DOMString: a missing or undefined argument leaves
@@ -394,6 +402,27 @@ module Dommy
394
402
  has_feature
395
403
  end
396
404
  end
405
+
406
+ private
407
+
408
+ # Place `doctype` (a DocumentType passed to createDocument) as `doc`'s first
409
+ # child. createDocument appends the doctype node itself (step 5, before
410
+ # step 6 appends the element), so it goes through the ordinary insert: the
411
+ # adoption re-binds the caller's wrapper, and `xmlDoc.firstChild ===
412
+ # doctype`, its parentNode and ownerDocument all follow.
413
+ #
414
+ # No-op for nil/undefined. Any other non-DocumentType is a TypeError: the
415
+ # IDL converts `doctype` to `DocumentType?`, so `createDocument(ns, name,
416
+ # false)` throws rather than ignoring the value. A doctype the XML backend
417
+ # cannot hold is left unplaced rather than thrown: only an XML
418
+ # serialization would validate its contents.
419
+ def adopt_doctype_into(doc, doctype)
420
+ return if doctype.nil? || doctype.equal?(Bridge::UNDEFINED)
421
+
422
+ doc.insert_before(doctype, doc.document_element)
423
+ rescue DOMException::NotSupportedError
424
+ nil
425
+ end
397
426
  end
398
427
 
399
428
  # `document` — the entry point for DOM construction and querying.
@@ -409,6 +438,7 @@ module Dommy
409
438
  include Internal::DocumentLiveRanges
410
439
  include Internal::DocumentInteractionState
411
440
  include EventTarget
441
+ extend Internal::EventHandlers::AnswersIdlAttributes
412
442
  include Node
413
443
 
414
444
  attr_reader :backend_doc
@@ -435,7 +465,7 @@ module Dommy
435
465
  # edit only when it's inside a <style> or flips emptiness while a sheet
436
466
  # uses `:empty`. The coordinator reports mutations through the
437
467
  # `__internal_note_*` seams below, which decide what to bump.
438
- attr_accessor :__css_style_cache__
468
+ attr_accessor :__internal_css_style_cache__
439
469
 
440
470
 
441
471
 
@@ -463,11 +493,12 @@ module Dommy
463
493
 
464
494
 
465
495
 
466
- # A by-id/class/tag index of the backend element tree, memoized per DOM
467
- # generation, for SelectorMatcher's document-scoped fast path (or nil to tell
468
- # the caller to walk). Rebuilt lazily only after a mutation bumps
469
- # dom_generation, so it costs one tree walk per generation and pays off when
470
- # several queries run before the next mutation.
496
+ # A by-id/class/tag index of the backend element tree, memoized per
497
+ # #__internal_selector_index_generation__, for SelectorMatcher's
498
+ # document-scoped fast path (or nil to tell the caller to walk). Rebuilt
499
+ # lazily only after a mutation that can change it (the tree, or an `id` /
500
+ # `class`), so it costs one tree walk per such mutation and pays off when
501
+ # several queries run before the next one.
471
502
  #
472
503
  # Adaptive bypass: if the index keeps getting invalidated after serving only a
473
504
  # handful of queries (a mutation-between-every-query workload, where building
@@ -479,7 +510,7 @@ module Dommy
479
510
  SELECTOR_INDEX_RETEST_GAP = 64 # generations to wait before re-testing a bypass
480
511
 
481
512
  def __internal_selector_index__
482
- gen = dom_generation
513
+ gen = __internal_selector_index_generation__
483
514
  if @__sel_idx_gen != gen
484
515
  if @__sel_idx
485
516
  if @__sel_idx_served.to_i < SELECTOR_INDEX_MIN_REUSE
@@ -500,7 +531,7 @@ module Dommy
500
531
  end
501
532
  return nil if @__sel_idx_bypass
502
533
 
503
- @__sel_idx ||= Internal::SelectorIndex.build(@backend_doc)
534
+ @__sel_idx ||= Internal::SelectorIndex.build(@backend_doc, quirks: quirks_mode?)
504
535
  @__sel_idx_served += 1
505
536
  @__sel_idx
506
537
  end
@@ -536,7 +567,20 @@ module Dommy
536
567
  end
537
568
  # content_type defaults to "text/html"; settable so an integration layer
538
569
  # can reflect the response Content-Type. Read-only over the JS bridge.
539
- attr_accessor :content_type
570
+ attr_reader :content_type
571
+
572
+ # Whether the document is an HTML one rides on its type, and with it its
573
+ # mode — only an HTML document can be in quirks mode — and how selectors
574
+ # fold id and class case. A layer that sets the type after the parse
575
+ # (dommy-rack does) has the mode worked out again and the selector
576
+ # results retired.
577
+ def content_type=(value)
578
+ return if value == @content_type
579
+
580
+ @content_type = value
581
+ @quirks_mode = nil
582
+ __internal_note_selector_state_change__
583
+ end
540
584
  # A `->(source_text) {}` set by the JS layer to execute a classic <script>'s
541
585
  # body when it's connected (Dommy has no JS engine of its own). nil = inert
542
586
  # scripts (the default for a standalone DOM).
@@ -558,6 +602,25 @@ module Dommy
558
602
  nil
559
603
  end
560
604
 
605
+ # A `->(element, name, body, window_handler) {}` set by the JS layer that
606
+ # compiles an event handler content attribute's body into a function (the
607
+ # element, its form owner and the document in its scope; none for a
608
+ # Window's handler, whose `onerror` takes five arguments), raising the
609
+ # SyntaxError of a body that does not parse. nil = no engine, nothing runs.
610
+ attr_accessor :event_handler_compiler
611
+
612
+ # Activate the event handler content attributes of every element the
613
+ # parser built (it runs no attribute change steps), as though each had
614
+ # just been set. Run at boot, before any script, and safe to repeat.
615
+ def __internal_activate_parsed_event_handlers__
616
+ selector = (Internal::EventHandlers::GLOBAL | Internal::EventHandlers::WINDOW).map { |name| "[#{name}]" }.join(",")
617
+ parsed_css(selector).each do |node|
618
+ element = wrap_node(node)
619
+ element.__internal_wire_inline_handler__(nil) if element.respond_to?(:__internal_wire_inline_handler__)
620
+ end
621
+ nil
622
+ end
623
+
561
624
  def initialize(host = nil, backend_doc: nil, default_view: nil)
562
625
  @host = host
563
626
  @default_view = default_view
@@ -565,7 +628,6 @@ module Dommy
565
628
  @node_factory = Internal::NodeFactory.new(self, @node_wrapper_cache)
566
629
  @observer_manager = Internal::ObserverManager.new
567
630
  @shadow_registry = Internal::ShadowRootRegistry.new
568
- @cookie_jar = Internal::CookieJar.new
569
631
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
570
632
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
571
633
  # Weak, like @live_ranges: a NodeIterator is consulted by every removal for
@@ -585,30 +647,50 @@ module Dommy
585
647
  @__current_script__ = nil
586
648
  end
587
649
 
588
- # Whether this is an "HTML document" in the DOM sense (created by the HTML
589
- # parser / `text/html`), as opposed to an XML document. It drives the
590
- # case-folding rules: `createElement` lowercases names and `Element#tagName`
591
- # uppercases HTML-namespace names only in an HTML document. An XML or XHTML
592
- # document (e.g. an `application/xhtml+xml` / `text/xml` resource) preserves
593
- # case.
650
+ # Whether this is an "HTML document" in the DOM sense (DOM's type "html"),
651
+ # as opposed to an XML document. It drives the case-folding rules:
652
+ # `createElement` lowercases names and `Element#tagName` uppercases
653
+ # HTML-namespace names only in an HTML document. An XML or XHTML document
654
+ # (e.g. an `application/xhtml+xml` / `text/xml` resource) preserves case.
655
+ #
656
+ # The type is not the content type: a text document (a text/plain or
657
+ # text/css response shown in a <pre>) is an HTML document too. Every way of
658
+ # making an XML document gives it an XML MIME type, so that is what decides.
594
659
  def html_document?
595
- @content_type == "text/html"
660
+ !Internal::MimeType.xml?(@content_type)
661
+ end
662
+
663
+ # Whether this document is DOM's XMLDocument — the interface
664
+ # `implementation.createDocument` returns (and a clone of one keeps). A
665
+ # DOMParser result is the base Document even when parsed from XML, so this
666
+ # rides on the instance rather than the content type. Together with
667
+ # #html_document? it decides the document's most-derived interface.
668
+ def xml_document? = @xml_document == true
669
+
670
+ def __internal_xml_document__=(value)
671
+ @xml_document = value
596
672
  end
597
673
 
598
- # `document.compatMode` — "CSS1Compat" in no-quirks mode, "BackCompat" in
599
- # quirks mode. A missing doctype is quirks; a bare `<!DOCTYPE html>` (no
600
- # public/system identifier) is no-quirks. (The full quirks algorithm keys off
601
- # specific legacy public ids; this covers the common cases.)
674
+ # `document.compatMode` — "BackCompat" in quirks mode, "CSS1Compat" in
675
+ # no-quirks and limited-quirks mode alike.
602
676
  def compat_mode
603
- # Only HTML documents can be in quirks mode; an XML document
604
- # (createDocument / DOMParser XML) is always no-quirks.
605
- return "CSS1Compat" unless html_document?
677
+ quirks_mode? ? "BackCompat" : "CSS1Compat"
678
+ end
606
679
 
607
- dt = @backend_doc.internal_subset
608
- return "BackCompat" unless dt
609
- return "CSS1Compat" if dt.name.to_s.downcase == "html" && dt.external_id.nil?
680
+ # Whether the document is in quirks mode (DOM's "mode"). Only the HTML
681
+ # parser puts a document in it, from the doctype it read — a missing one,
682
+ # or one of HTML's legacy public identifiers (§13.2.6.4.1); the backend's
683
+ # parser has already run that algorithm. Every other document is in
684
+ # no-quirks mode, and a clone takes its original's mode. The mode is fixed
685
+ # once known: changing the doctype later does not change it.
686
+ def quirks_mode?
687
+ return @quirks_mode unless @quirks_mode.nil?
688
+
689
+ @quirks_mode = html_document? && Backend.quirks_mode?(@backend_doc)
690
+ end
610
691
 
611
- "BackCompat"
692
+ def __internal_quirks_mode__=(value)
693
+ @quirks_mode = value
612
694
  end
613
695
 
614
696
  # The document's character encoding, as an Encoding Standard name. Dommy
@@ -634,9 +716,35 @@ module Dommy
634
716
  end
635
717
 
636
718
  def dir=(value)
637
- html_element&.set_attribute("dir", value.to_s)
719
+ html_element&.__internal_set_attribute_value__("dir", value.to_s)
720
+ end
721
+
722
+ # HTML §16 (obsolete features), the partial Document interface: fgColor,
723
+ # linkColor, vlinkColor, alinkColor and bgColor reflect the body element's
724
+ # text, link, vlink, alink and bgcolor content attributes — "if the body
725
+ # element is a body element (as opposed to a frameset element). When there
726
+ # is no body element or if it is a frameset element, the attributes must
727
+ # instead return the empty string on getting and do nothing on setting."
728
+ # The setters are [LegacyNullToEmptyString] (null writes "").
729
+ LEGACY_BODY_COLORS = {
730
+ fg_color: "text", link_color: "link", vlink_color: "vlink", alink_color: "alink", bg_color: "bgcolor"
731
+ }.freeze
732
+
733
+ LEGACY_BODY_COLORS.each do |name, attr|
734
+ define_method(name) do
735
+ element = body
736
+ element.is_a?(HTMLBodyElement) ? element.__internal_attribute_value__(attr).to_s : ""
737
+ end
738
+
739
+ define_method(:"#{name}=") do |value|
740
+ element = body
741
+ element.__internal_set_attribute_value__(attr, value.to_s) if element.is_a?(HTMLBodyElement)
742
+ end
638
743
  end
639
744
 
745
+ # Whether designMode is "on", which makes the whole document editable.
746
+ def __internal_design_mode__? = @design_mode == "on"
747
+
640
748
  def title=(value)
641
749
  write_title(value.to_s)
642
750
  end
@@ -654,33 +762,49 @@ module Dommy
654
762
  # answers null: the backend's `root` falls back to the doctype once the
655
763
  # element is gone (`document.removeChild(documentElement)`, or a
656
764
  # replaceChild that swaps it for a comment), and head / body / title
657
- # resolve through this.
658
- root = @backend_doc.root
659
- return nil unless root&.element?
660
-
661
- wrap_node(root)
765
+ # resolve through this (#backend_document_element).
766
+ root = backend_document_element
767
+ root && wrap_node(root)
662
768
  end
663
769
 
770
+ # HTML's "the head element": the first `head` child, in the HTML
771
+ # namespace, of the html element (readonly — assignment is a no-op, see
772
+ # __js_set__).
773
+ #
774
+ # Spec: https://html.spec.whatwg.org/multipage/dom.html#the-head-element-2
664
775
  def head
665
- # The first `head` element child of the document element in the HTML
666
- # namespace (readonly — assignment is a no-op, see __js_set__). Not just
667
- # `at_css("head")`, which searches the whole tree and ignores namespace.
668
- root = document_element
669
- return nil unless root
670
-
671
- root.child_nodes.to_a.find do |c|
672
- c.respond_to?(:local_name) && c.local_name == "head" &&
673
- c.respond_to?(:namespace_uri) && c.namespace_uri == Internal::Namespaces::HTML
674
- end
776
+ wrap_node(html_element_child(%w[head]))
675
777
  end
676
778
 
677
- # Resolve `body` fresh from the tree (not memoized) so it tracks a swapped
678
- # `<body>` — e.g. Turbo's page render does
679
- # `documentElement.replaceChild(newBody, body)`, after which a stale cached
680
- # wrapper would keep returning the detached old body. wrap_node caches by
681
- # node, so identity (`document.body === document.body`) still holds.
779
+ # HTML's "the body element": the first child of the html element — the
780
+ # document element, if it is the HTML namespace's `html` — that is a
781
+ # `body` or a `frameset` in the HTML namespace; nil without one. Resolved
782
+ # fresh from the tree (not memoized) so it tracks a swapped `<body>` — e.g.
783
+ # Turbo's page render does `documentElement.replaceChild(newBody, body)`.
784
+ # wrap_node caches by node, so `document.body === document.body` holds.
785
+ #
786
+ # Spec: https://html.spec.whatwg.org/multipage/dom.html#the-body-element-2
682
787
  def body
683
- wrap_node(@backend_doc.at_css("body"))
788
+ wrap_node(html_element_child(%w[body frameset]))
789
+ end
790
+
791
+ # The body setter: the new value must be a `body` or a `frameset` in the
792
+ # HTML namespace. It replaces the body element there is, or else is
793
+ # appended to the document element; without either, there is nowhere to
794
+ # put it.
795
+ #
796
+ # Spec: https://html.spec.whatwg.org/multipage/dom.html#dom-document-body
797
+ def body=(element)
798
+ raise DOMException::HierarchyRequestError, "body must be a body or frameset element" unless body_or_frameset?(element)
799
+
800
+ current = body
801
+ return if element.equal?(current)
802
+ return current.parent_node.replace_child(element, current) if current
803
+
804
+ root = document_element
805
+ raise DOMException::HierarchyRequestError, "the document has no document element" unless root
806
+
807
+ root.append_child(element)
684
808
  end
685
809
 
686
810
  # The document's accessibility tree (built from <body>; the document itself
@@ -716,7 +840,18 @@ module Dommy
716
840
  # "about:blank", not the empty string.
717
841
  def url
718
842
  view = @default_view
719
- view&.location ? view.location.href : "about:blank"
843
+ return view.location.href if view&.location
844
+
845
+ @creator_url || "about:blank"
846
+ end
847
+
848
+ # A document made by a parsing or creation API (DOMParser,
849
+ # DOMImplementation) takes its origin from `document` — the relevant
850
+ # global object's associated Document — and, for DOMParser, its URL too.
851
+ def __internal_set_creator__(document, url: nil)
852
+ @origin_document = document
853
+ @creator_url = url
854
+ nil
720
855
  end
721
856
 
722
857
  alias document_uri url
@@ -730,7 +865,7 @@ module Dommy
730
865
  base_el = @backend_doc.at_css("base[href]")
731
866
  return doc_url unless base_el
732
867
 
733
- href = base_el["href"].to_s
868
+ href = Backend.no_namespace_attribute_value(base_el, "href").to_s
734
869
  return doc_url if href.empty?
735
870
 
736
871
  # HTML "document base URL": the frozen base URL is the href parsed
@@ -749,32 +884,88 @@ module Dommy
749
884
  nil
750
885
  end
751
886
 
752
- private def creator_base_url
753
- @creator_base_url if @creator_base_url && FALLBACK_BASE_URLS.include?(url)
887
+ # `document.domain` — the effective domain of the document's origin
888
+ # serialized: the domain a `document.domain =` set, or else the origin's
889
+ # host; "" for an opaque origin.
890
+ def domain
891
+ return @domain_override if @domain_override
892
+
893
+ host = Internal::Origin.host_of(origin)
894
+ host.nil? ? "" : host.to_s
754
895
  end
755
896
 
756
- # `document.domain` — host portion of the URL. Real browsers
757
- # restrict cross-origin reads of this; we just return the bare host.
758
- def domain
897
+ # The `document.domain` setter. It needs a browsing context and a tuple
898
+ # origin, and accepts only the current effective domain or a registrable
899
+ # suffix of it (everything else is a SecurityError). The agent cluster is
900
+ # site-keyed (no Origin-Agent-Cluster header), so the domain is then set.
901
+ #
902
+ # Without a public suffix list, a single label (a bare TLD such as "com")
903
+ # stands in for the public suffix.
904
+ def domain=(value)
759
905
  view = @default_view
760
- return "" unless view&.location
906
+ raise DOMException::SecurityError, "The document has no browsing context" unless view&.navigable?
907
+
908
+ current = Internal::Origin.host_of(origin)
909
+ raise DOMException::SecurityError, "The document's origin is opaque" if current.nil?
910
+
911
+ candidate = value.to_s
912
+ unless registrable_domain_suffix_or_equal?(candidate, current.to_s)
913
+ raise DOMException::SecurityError, "'#{candidate}' is not a suffix of '#{current}'"
914
+ end
761
915
 
762
- view.location.__js_get__("hostname").to_s
916
+ @domain_override = Internal::UrlParser.parse("http://#{candidate}/").host.to_s
763
917
  end
764
918
 
765
- # `document.origin` — serialized origin of the document URL, mirroring
766
- # `window.location.origin`. Empty when there is no associated window.
919
+ # `document.origin` — this document's origin, serialized (the same answer as
920
+ # `self.origin`). Empty when there is no associated window.
767
921
  def origin
768
922
  view = @default_view
769
- return "" unless view&.location
923
+ return @origin_document&.origin.to_s unless view&.location
770
924
 
771
925
  view.origin
772
926
  end
773
927
 
774
- # `document.referrer` — Dommy never has a referring page, so this
775
- # is always empty.
928
+ # `document.lastModified` — the source's last modification (the response's
929
+ # Last-Modified header, when the embedder passed one on), else the current
930
+ # time, in local time as "MM/DD/YYYY hh:mm:ss".
931
+ #
932
+ # Spec: https://html.spec.whatwg.org/#dom-document-lastmodified
933
+ def last_modified
934
+ (@last_modified_time || Time.now).getlocal.strftime("%m/%d/%Y %H:%M:%S")
935
+ end
936
+
937
+ # Record the document's source last-modified time: a Time, or an HTTP date
938
+ # string (a Last-Modified header value). One that does not parse leaves it
939
+ # unknown.
940
+ def __internal_set_last_modified__(value)
941
+ @last_modified_time =
942
+ case value
943
+ when Time then value
944
+ when nil then nil
945
+ else
946
+ begin
947
+ Time.httpdate(value.to_s.strip)
948
+ rescue ArgumentError
949
+ nil
950
+ end
951
+ end
952
+ nil
953
+ end
954
+
955
+ # `document.referrer` — the URL of the document that navigated here, as the
956
+ # embedder reports it (`__internal_referrer__=`); empty when there was none.
776
957
  def referrer
777
- ""
958
+ @referrer.to_s
959
+ end
960
+
961
+ def __internal_referrer__=(url)
962
+ @referrer = url
963
+ end
964
+
965
+ # Whether the document has completely finished loading: its `load` event
966
+ # has been fired. A document whose lifecycle no one replays is born loaded.
967
+ def __internal_completely_loaded__?
968
+ !@loading_lifecycle
778
969
  end
779
970
 
780
971
  # Live HTMLCollection helpers — each call re-queries the
@@ -817,7 +1008,7 @@ module Dommy
817
1008
  # in practice the `<html>` root).
818
1009
  def children
819
1010
  @live_children ||= HTMLCollection.new do
820
- root = @backend_doc.root
1011
+ root = backend_document_element
821
1012
  root ? [wrap_node(root)].compact : []
822
1013
  end
823
1014
  end
@@ -835,13 +1026,10 @@ module Dommy
835
1026
  children.size
836
1027
  end
837
1028
 
838
- def first_element_child
839
- wrap_node(@backend_doc.root)
840
- end
1029
+ # A document has at most one element child, the document element.
1030
+ def first_element_child = document_element
841
1031
 
842
- def last_element_child
843
- wrap_node(@backend_doc.root)
844
- end
1032
+ def last_element_child = document_element
845
1033
 
846
1034
 
847
1035
  # `document.contains(node)` — true if `node` is the document itself or any
@@ -849,14 +1037,13 @@ module Dommy
849
1037
  # document expose). Per spec, false for null / a non-Node.
850
1038
  def contains?(other)
851
1039
  return true if other.equal?(self)
852
- return false unless other.respond_to?(:__dommy_backend_node__)
1040
+ return false unless other.is_a?(Node)
853
1041
 
854
- # Walk parents up to the backend document node. (The backend's #ancestors
1042
+ # Whose root the backend document node is. (The backend's #ancestors
855
1043
  # stops below the document, so it can't test document membership; the
856
1044
  # doctype in particular reports an empty ancestor list.)
857
1045
  node = other.__dommy_backend_node__
858
- node = node.parent while node && !node.equal?(@backend_doc)
859
- !node.nil?
1046
+ !node.nil? && Internal::NodeTraversal.root_of(node).equal?(@backend_doc)
860
1047
  end
861
1048
 
862
1049
 
@@ -915,39 +1102,47 @@ module Dommy
915
1102
  # it here with the same qualified name, namespace, prefix and value, owned
916
1103
  # by no element (importNode never attaches the copy to anything).
917
1104
  return import_attribute(node) if node.is_a?(Attr)
918
- return nil unless node.respond_to?(:__dommy_backend_node__)
1105
+ return nil unless node.is_a?(Node) && node.__dommy_backend_node__
1106
+
1107
+ # `(boolean or ImportNodeOptions) options = false`: missing or undefined
1108
+ # is the default, false. Null and an object convert to the dictionary,
1109
+ # whose `selfOnly` negates `deep` (so null clones deep) and whose
1110
+ # `customElementRegistry` (a scoped one, or this document's) is the
1111
+ # registry the copies fall back to. Anything else is a boolean, read
1112
+ # with ToBoolean.
1113
+ registry = nil
1114
+ deep = false if deep.equal?(Bridge::UNDEFINED)
1115
+ if deep.nil? || deep.is_a?(Hash)
1116
+ options = deep || {}
1117
+ deep = !Internal::WebIDL.boolean(options["selfOnly"])
1118
+ given = options.fetch("customElementRegistry", Bridge::UNDEFINED)
1119
+ unless given.equal?(Bridge::UNDEFINED)
1120
+ raise Bridge::TypeError, "customElementRegistry is not a CustomElementRegistry" unless given.is_a?(CustomElementRegistry)
1121
+ if !given.scoped? && !given.equal?(__internal_custom_element_registry__)
1122
+ raise DOMException::NotSupportedError, "a global registry other than the document's"
1123
+ end
919
1124
 
920
- # WebIDL `optional boolean deep = false`: a missing / undefined argument
921
- # is the default (false / shallow), not a truthy sentinel.
922
- deep = false if deep.nil? || deep.equal?(Bridge::UNDEFINED)
1125
+ registry = given
1126
+ end
1127
+ else
1128
+ deep = Internal::WebIDL.boolean(deep)
1129
+ end
1130
+ # "Clone a single node": the node's own registry — a global one standing
1131
+ # for this document's — and the fallback only for a node without one.
1132
+ fallback = registry || __internal_custom_element_registry__
1133
+ source = node.is_a?(Element) ? node.__internal_ce_registry__ : fallback
1134
+ registry = if source.nil? then fallback
1135
+ elsif source.scoped? then source
1136
+ else __internal_custom_element_registry__
1137
+ end
923
1138
  source_document = node.respond_to?(:document) ? node.document : self
924
1139
  copy = clone_into_doc(node.__dommy_backend_node__, deep, source_document)
925
1140
  apply_imported_cloning_steps(node.__dommy_backend_node__, copy, deep, source_document)
1141
+ __internal_enqueue_created_upgrades__(copy, registry) if copy.respond_to?(:element?)
1142
+ __internal_clone_shadow_roots__(node.__dommy_backend_node__, copy, deep, source_document) if copy.respond_to?(:element?)
926
1143
  wrap_node(copy)
927
1144
  end
928
1145
 
929
- # importNode is a clone, so the HTML cloning steps run for it as they do
930
- # for cloneNode (#__internal_apply_cloning_steps__): the live state Dommy
931
- # keeps on a wrapper — an input's dirty value, a script's "already
932
- # started" — is copied onto the new node. The originals' wrappers belong
933
- # to the source document, so they are looked up there.
934
- def apply_imported_cloning_steps(src_root, copy_root, deep, source_document)
935
- return unless source_document.respond_to?(:__internal_peek_wrapper__)
936
-
937
- src_nodes = deep ? Internal::NodeTraversal.subtree_nodes(src_root) : [src_root]
938
- copy_nodes = deep ? Internal::NodeTraversal.subtree_nodes(copy_root) : [copy_root]
939
- return unless src_nodes.length == copy_nodes.length
940
-
941
- src_nodes.zip(copy_nodes).each do |orig, copy|
942
- state = source_document.__internal_peek_wrapper__(orig)&.then { |w| w.respond_to?(:__cloning_state__) && w.__cloning_state__ }
943
- next unless state
944
-
945
- wrapper = wrap_node(copy)
946
- wrapper.__apply_cloning_state__(state) if wrapper.respond_to?(:__apply_cloning_state__)
947
- end
948
- end
949
- private :apply_imported_cloning_steps
950
-
951
1146
  def import_attribute(attr)
952
1147
  Attr.new(
953
1148
  attr.name,
@@ -984,11 +1179,6 @@ module Dommy
984
1179
  node_adopter.adopt_backend_node(node, source_document)
985
1180
  end
986
1181
 
987
- def node_adopter
988
- @node_adopter ||= Internal::NodeAdopter.new(self)
989
- end
990
- private :node_adopter
991
-
992
1182
  # HTML "cloning steps": a cloned node copies interface-specific live state
993
1183
  # that the content attributes don't capture — an input's dirty value and
994
1184
  # checkedness, a textarea's dirty value, etc. Dommy keeps that state on the
@@ -1009,15 +1199,69 @@ module Dommy
1009
1199
  clone_template_content(orig, copy) if deep && @template_content_registry.detached?(orig)
1010
1200
 
1011
1201
  wrapper = @node_wrapper_cache.peek(orig)
1012
- next unless wrapper.respond_to?(:__cloning_state__)
1202
+ next unless wrapper.respond_to?(:__internal_cloning_state__)
1013
1203
 
1014
- state = wrapper.__cloning_state__
1204
+ state = wrapper.__internal_cloning_state__
1015
1205
  next if state.nil?
1016
1206
 
1017
- @node_wrapper_cache.wrap(copy).__apply_cloning_state__(state)
1207
+ @node_wrapper_cache.wrap(copy).__internal_apply_cloning_state__(state)
1018
1208
  end
1019
1209
  end
1020
1210
 
1211
+ # DOM "clone a node" step 6, for `copy_root` — a clone made in this
1212
+ # document of `src_root` (of `source_document`), with or without its
1213
+ # subtree — and, when `deep`, every element of the copy: the clone of a
1214
+ # shadow host whose shadow root is clonable gets a shadow root of its own
1215
+ # with the same mode, delegates focus, serializable, slot assignment,
1216
+ # declarative and keep-custom-element-registry-null, clonable, holding
1217
+ # clones of the shadow root's children. A shallow clone still does this
1218
+ # for the host itself. The backend copied the subtree (and template
1219
+ # contents) in one go, so the two trees are walked in lockstep.
1220
+ def __internal_clone_shadow_roots__(src_root, copy_root, deep, source_document = self)
1221
+ return unless source_document.__internal_any_shadow_roots__?
1222
+
1223
+ clone_shadow_roots_walk(src_root, copy_root, deep, source_document)
1224
+ end
1225
+
1226
+ def clone_shadow_roots_walk(src, copy, deep, source_document)
1227
+ return unless src.element? || src.is_a?(Backend.document_fragment_class)
1228
+
1229
+ if src.element?
1230
+ shadow = source_document.__internal_shadow_root_for_host__(src)
1231
+ clone_shadow_root(shadow, copy, source_document) if shadow&.clonable
1232
+ end
1233
+ return unless deep
1234
+
1235
+ src_children = src.children.to_a
1236
+ copy_children = copy.children.to_a
1237
+ return unless src_children.length == copy_children.length
1238
+
1239
+ src_children.zip(copy_children).each { |s, c| clone_shadow_roots_walk(s, c, true, source_document) }
1240
+ return unless src.element? && @template_content_registry.template_node?(src)
1241
+
1242
+ src_contents = source_document.__internal_template_registry__.existing_contents(src)
1243
+ copy_contents = @template_content_registry.existing_contents(copy)
1244
+ clone_shadow_roots_walk(src_contents, copy_contents, true, source_document) if src_contents && copy_contents
1245
+ end
1246
+
1247
+ def clone_shadow_root(shadow, copy, source_document)
1248
+ host = wrap_node(copy)
1249
+ # The shadow root's registry; a global one stands for this document's.
1250
+ registry = shadow.__internal_registry_set__? ? shadow.__internal_custom_element_registry__ : :document
1251
+ registry = :document if registry && registry != :document && !registry.scoped?
1252
+ root = host.__internal_attach_shadow_root__(
1253
+ mode: shadow.mode, delegates_focus: shadow.delegates_focus, serializable: shadow.serializable,
1254
+ slot_assignment: shadow.slot_assignment, clonable: true, registry: registry
1255
+ )
1256
+ root.__internal_declarative__ = shadow.__internal_declarative__?
1257
+ root.__internal_keep_registry_null__ = shadow.__internal_keep_registry_null__?
1258
+ shadow.child_nodes.to_a.each do |child|
1259
+ child_copy = source_document.equal?(self) ? child.clone_node(true) : import_node(child, true)
1260
+ root.append_child(child_copy)
1261
+ end
1262
+ end
1263
+ private :clone_shadow_roots_walk, :clone_shadow_root
1264
+
1021
1265
  # Legacy `document.createEvent("EventName")` factory. The DOM Standard
1022
1266
  # matches the type ASCII case-insensitively against a fixed alias table, and
1023
1267
  # throws NotSupportedError for anything else — including the plural forms it
@@ -1062,8 +1306,22 @@ module Dommy
1062
1306
  # don't apply to a layout-less DOM. They exist so callers don't
1063
1307
  # hit NoMethodError; semantics are documented as no-op.
1064
1308
 
1309
+ # `document.hasFocus()` — HTML's "has focus steps", with the top-level
1310
+ # page always holding system focus: a document with no browsing context
1311
+ # has no focus, a top-level one has it, and a nested one has it when its
1312
+ # frame is the focused element of a parent document that has it.
1313
+ #
1314
+ # Spec: https://html.spec.whatwg.org/#has-focus-steps
1065
1315
  def has_focus?
1066
- true
1316
+ view = @default_view
1317
+ return false unless view
1318
+
1319
+ frame = view.frame_element
1320
+ return true unless frame
1321
+ return false unless frame.is_connected?
1322
+
1323
+ parent = frame.owner_document
1324
+ parent.has_focus? && parent.__internal_focused_element__.equal?(frame)
1067
1325
  end
1068
1326
 
1069
1327
  alias has_focus has_focus?
@@ -1080,6 +1338,12 @@ module Dommy
1080
1338
  Range.new(self)
1081
1339
  end
1082
1340
 
1341
+ # The showing popovers and the state that orders their showing and
1342
+ # hiding (Internal::PopoverStack).
1343
+ def __internal_popover_stack__
1344
+ @popover_stack ||= Internal::PopoverStack.new
1345
+ end
1346
+
1083
1347
  # Fullscreen API — no actual fullscreen mode, just track which
1084
1348
  # element claimed it. `element.requestFullscreen()` sets it; this
1085
1349
  # is the read side.
@@ -1090,6 +1354,8 @@ module Dommy
1090
1354
  @fullscreen_element = element
1091
1355
  return if previous == element
1092
1356
 
1357
+ # :fullscreen and :modal match the fullscreen element.
1358
+ __internal_note_selector_state_change__
1093
1359
  dispatch_event(Event.new("fullscreenchange"))
1094
1360
  end
1095
1361
 
@@ -1097,6 +1363,7 @@ module Dommy
1097
1363
  return PromiseValue.resolve(@default_view, nil) if @fullscreen_element.nil?
1098
1364
 
1099
1365
  @fullscreen_element = nil
1366
+ __internal_note_selector_state_change__
1100
1367
  dispatch_event(Event.new("fullscreenchange"))
1101
1368
  PromiseValue.resolve(@default_view, nil)
1102
1369
  end
@@ -1215,10 +1482,18 @@ module Dommy
1215
1482
  end
1216
1483
  end
1217
1484
 
1485
+ # Pre-insert refuses an element before a doctype `child` or one that
1486
+ # follows it; replace (`exclude` is the child replaced) only one that
1487
+ # follows it, since the doctype being replaced goes.
1488
+ doctype_in_the_way =
1489
+ if exclude
1490
+ doctype_after_child?(existing, child_bn, inclusive: false)
1491
+ else
1492
+ (child_bn && child_bn.node_type == 10) || doctype_after_child?(existing, child_bn)
1493
+ end
1218
1494
  if elements > 1
1219
1495
  raise DOMException::HierarchyRequestError, "Only one element may be a child of a document."
1220
- elsif elements == 1 &&
1221
- (has_element || (child_bn && child_bn.node_type == 10) || doctype_after_child?(existing, child_bn))
1496
+ elsif elements == 1 && (has_element || doctype_in_the_way)
1222
1497
  raise DOMException::HierarchyRequestError, "An element cannot be inserted here."
1223
1498
  end
1224
1499
 
@@ -1233,7 +1508,7 @@ module Dommy
1233
1508
  # createDocument, which the DOM never lets throw here, catches this and
1234
1509
  # leaves the doctype out.
1235
1510
  def ensure_doctypes_have_nodes!(args)
1236
- return unless args.any? { |a| a.is_a?(Dommy::DocumentType) && !a.respond_to?(:__dommy_backend_node__) }
1511
+ return unless args.any? { |a| a.is_a?(Dommy::DocumentType) && a.__dommy_backend_node__.nil? }
1237
1512
 
1238
1513
  raise DOMException::NotSupportedError, "This doctype cannot be inserted: the backend could not create it."
1239
1514
  end
@@ -1271,19 +1546,20 @@ module Dommy
1271
1546
  existing[0...idx].any? { |c| c.node_type == 1 }
1272
1547
  end
1273
1548
 
1274
- # Whether any doctype child follows `child_bn` in the document's child list.
1275
- def doctype_after_child?(existing, child_bn)
1549
+ # Whether a doctype child is at `child_bn` (when `inclusive`) or follows
1550
+ # it in the document's child list.
1551
+ def doctype_after_child?(existing, child_bn, inclusive: true)
1276
1552
  idx = child_bn && existing.index { |c| c == child_bn }
1277
1553
  return false unless idx
1278
1554
 
1279
- existing[idx..].any? { |c| c.node_type == 10 }
1555
+ existing[(inclusive ? idx : idx + 1)..].any? { |c| c.node_type == 10 }
1280
1556
  end
1281
1557
 
1282
1558
  # Append a node as a child of the document itself (e.g. a comment alongside
1283
1559
  # the document element). Adopts the node into this document.
1284
1560
  def append_child(node)
1285
1561
  ensure_document_insertion_validity!([node], nil)
1286
- return node unless node.respond_to?(:__dommy_backend_node__)
1562
+ return node unless node.is_a?(Node) && node.__dommy_backend_node__
1287
1563
 
1288
1564
  # An append has a null reference child, so insert step 5 shifts nothing.
1289
1565
  nodes = document_insertion_nodes([node])
@@ -1299,6 +1575,8 @@ module Dommy
1299
1575
  # a doctype only ahead of the document element), so each forwards to the
1300
1576
  # `document_*` implementation that carries them.
1301
1577
  def insert_before(node, reference)
1578
+ Internal::WebIDL.node!(node)
1579
+ Internal::WebIDL.nullable_node!(reference)
1302
1580
  document_insert_before(node, reference)
1303
1581
  end
1304
1582
 
@@ -1343,7 +1621,7 @@ module Dommy
1343
1621
  detach_node(bn, moving: true) # steps 10-11, 14
1344
1622
  ref_bn = nil if ref_bn && ref_bn.parent != @backend_doc
1345
1623
  __internal_ranges_will_insert__(@backend_doc, ref_bn, 1) # step 16
1346
- new_previous = ref_bn ? ref_bn.previous_sibling : @backend_doc.children.to_a.last
1624
+ new_previous = ref_bn ? ref_bn.previous_sibling : @backend_doc.last_child
1347
1625
  ref_bn ? ref_bn.add_previous_sibling(bn) : @backend_doc.add_child(bn) # step 18
1348
1626
  if old_parent
1349
1627
  notify_child_list_mutation(
@@ -1358,22 +1636,20 @@ module Dommy
1358
1636
  nil
1359
1637
  end
1360
1638
 
1361
- # The backend node an argument to `moveBefore` stands for. A Dommy::Document
1362
- # has no `__dommy_backend_node__` of its own, but it is a node the algorithm
1363
- # has to see (step 2 rejects it, step 3 measures its parentage).
1639
+ # The backend node an argument to `moveBefore` stands for. A
1640
+ # Dommy::Document's `__dommy_backend_node__` is nil, but it is a node the
1641
+ # algorithm has to see (step 2 rejects it, step 3 measures its parentage).
1364
1642
  def move_backend_node(value)
1365
1643
  return value.backend_doc if value.is_a?(Dommy::Document)
1366
- return nil unless value.respond_to?(:__dommy_backend_node__)
1367
1644
 
1368
- value.__dommy_backend_node__
1645
+ value.__dommy_backend_node__ if value.is_a?(Node)
1369
1646
  end
1370
1647
 
1371
1648
  # "Move" steps 1-6 with a document new parent.
1372
1649
  def ensure_document_move_validity!(node, bn, ref_bn)
1373
1650
  # Step 1 — the same root. A document is its own root, so this says the
1374
1651
  # node must already be somewhere in this document.
1375
- root = bn
1376
- root = root.parent while root.respond_to?(:parent) && root.parent
1652
+ root = bn && Internal::NodeTraversal.root_of(bn)
1377
1653
  # `==` and not `equal?`: a backend may hand back a fresh Ruby object for
1378
1654
  # the same underlying node on every `parent` call.
1379
1655
  unless bn && root == @backend_doc
@@ -1416,11 +1692,12 @@ module Dommy
1416
1692
  # ParentNode / Node mutation on the document's direct children (the doctype
1417
1693
  # and the document element).
1418
1694
  def document_insert(args, prepend:)
1419
- ref_bn = prepend ? @backend_doc.children.first : nil
1695
+ args = Internal::InsertionPoint.convert_nodes_into_a_node(self, args)
1696
+ ref_bn = prepend ? @backend_doc.first_child : nil
1420
1697
  ensure_document_insertion_validity!(args, ref_bn)
1421
1698
  __internal_ranges_will_insert__(@backend_doc, ref_bn, document_insertion_count(args))
1422
1699
  nodes = document_insertion_nodes(args)
1423
- if prepend && (first = @backend_doc.children.first)
1700
+ if prepend && (first = @backend_doc.first_child)
1424
1701
  nodes.reverse_each { |n| first.add_previous_sibling(n) }
1425
1702
  else
1426
1703
  nodes.each { |n| @backend_doc.add_child(n) }
@@ -1430,6 +1707,7 @@ module Dommy
1430
1707
  end
1431
1708
 
1432
1709
  def document_replace_children(args)
1710
+ args = Internal::InsertionPoint.convert_nodes_into_a_node(self, args)
1433
1711
  # replaceChildren removes the current children first, so the validity
1434
1712
  # checks ignore them (whatwg/dom#1045).
1435
1713
  ensure_document_insertion_validity!(args, nil, ignore_existing: true)
@@ -1442,7 +1720,13 @@ module Dommy
1442
1720
  end
1443
1721
 
1444
1722
  def document_remove_child(node)
1445
- return __internal_remove_doctype__(node) if node.is_a?(DocumentType)
1723
+ if node.is_a?(DocumentType)
1724
+ bn = backend_node(node)
1725
+ raise DOMException::NotFoundError, "node is not a child of this document" if bn && bn.parent != @backend_doc
1726
+
1727
+ __internal_remove_doctype__(node)
1728
+ return node
1729
+ end
1446
1730
 
1447
1731
  bn = backend_node(node)
1448
1732
  raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @backend_doc
@@ -1500,8 +1784,7 @@ module Dommy
1500
1784
  # read before the adopt below removes anything.
1501
1785
  record_previous = old_bn.previous && wrap_node(old_bn.previous)
1502
1786
  record_next = ref && wrap_node(ref)
1503
- cross_document = !Backend.moves_nodes_across_documents? &&
1504
- new_child.respond_to?(:document) && !new_child.document.equal?(self)
1787
+ cross_document = new_child.respond_to?(:document) && !new_child.document.equal?(self)
1505
1788
  fragment = new_child.is_a?(Dommy::Fragment)
1506
1789
 
1507
1790
  # Same document: WHATWG replace adopts the incoming node — which removes
@@ -1557,7 +1840,7 @@ module Dommy
1557
1840
  # (at the document start) or after it (just before the document element).
1558
1841
  def __internal_insert_at_doctype__(nodes, after:)
1559
1842
  bns = nodes.filter_map { |n| backend_node(n) }
1560
- anchor = after ? @backend_doc.root : @backend_doc.children.first
1843
+ anchor = after ? backend_document_element : @backend_doc.first_child
1561
1844
  __internal_ranges_will_insert__(@backend_doc, anchor, bns.size)
1562
1845
  if after
1563
1846
  anchor ? bns.each { |n| anchor.add_previous_sibling(n) } : bns.each { |n| @backend_doc.add_child(n) }
@@ -1572,9 +1855,13 @@ module Dommy
1572
1855
  # deep clone then copies the original's children into it — so nothing
1573
1856
  # sprouts an html/head/body of its own, and a document with no children
1574
1857
  # clones to one with no children.
1575
- def clone_node(deep)
1858
+ def clone_node(deep = false)
1859
+ deep = Internal::WebIDL.boolean(deep)
1576
1860
  copy = Document.new(nil, backend_doc: Backend.empty_document_like(@backend_doc))
1577
1861
  copy.content_type = @content_type
1862
+ copy.__internal_xml_document__ = @xml_document
1863
+ copy.__internal_quirks_mode__ = quirks_mode?
1864
+ copy.__internal_allow_declarative_shadow_roots__ = __internal_allow_declarative_shadow_roots__?
1578
1865
  return copy unless deep
1579
1866
 
1580
1867
  child_nodes.each { |child| copy.append_child(copy.import_node(child, true)) }
@@ -1582,7 +1869,7 @@ module Dommy
1582
1869
  end
1583
1870
 
1584
1871
  def backend_node(node)
1585
- node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
1872
+ node.__dommy_backend_node__ if node.is_a?(Node)
1586
1873
  end
1587
1874
 
1588
1875
  # Like `backend_node`, but first adopts a node that belongs to another
@@ -1624,9 +1911,9 @@ module Dommy
1624
1911
  def document_insertion_count(args) = Internal::InsertionPoint.count(args)
1625
1912
 
1626
1913
  def adopted_backend_node(node)
1627
- return nil unless node.respond_to?(:__dommy_backend_node__)
1914
+ return nil unless node.is_a?(Node) && node.__dommy_backend_node__
1628
1915
 
1629
- if !Backend.moves_nodes_across_documents? && node.respond_to?(:document) && !node.document.equal?(self)
1916
+ if node.respond_to?(:document) && !node.document.equal?(self)
1630
1917
  return adopt_node(node)&.__dommy_backend_node__
1631
1918
  end
1632
1919
 
@@ -1635,19 +1922,42 @@ module Dommy
1635
1922
  bn
1636
1923
  end
1637
1924
 
1638
- # Delegate to CookieJar
1639
-
1925
+ # `document.cookie` (HTML §3.1.5): the cookie-string of the window's
1926
+ # cookie jar for this document's URL, as a "non-HTTP" API sees it (no
1927
+ # HttpOnly cookies). A cookie-averse document — no browsing context, or a
1928
+ # URL that is not http(s) — reads "" and ignores writes; one with an
1929
+ # opaque origin throws SecurityError.
1640
1930
  def cookie
1641
- @cookie_jar.to_cookie_string
1931
+ return "" if __internal_cookie_averse__?
1932
+
1933
+ raise_if_opaque_for_cookies!
1934
+ @default_view.cookie_jar.cookie_string(url, http: false)
1642
1935
  end
1643
1936
 
1644
1937
  def cookie=(value)
1645
- @cookie_jar.set_cookie(value)
1938
+ return nil if __internal_cookie_averse__?
1939
+
1940
+ raise_if_opaque_for_cookies!
1941
+ @default_view.cookie_jar.store(value.to_s, url, http: false)
1646
1942
  nil
1647
1943
  end
1648
1944
 
1649
- def create_element_ns(namespace_uri, qualified_name)
1650
- @node_factory.create_element_ns(namespace_uri, qualified_name)
1945
+ # HTML "cookie-averse Document object".
1946
+ def __internal_cookie_averse__?
1947
+ return true unless @default_view.respond_to?(:cookie_jar)
1948
+
1949
+ !url.to_s.match?(/\Ahttps?:/i)
1950
+ end
1951
+
1952
+ def raise_if_opaque_for_cookies!
1953
+ return unless @default_view.origin == "null"
1954
+
1955
+ raise DOMException::SecurityError, "the document's origin is opaque"
1956
+ end
1957
+ private :raise_if_opaque_for_cookies!
1958
+
1959
+ def create_element_ns(namespace_uri, qualified_name, options = nil)
1960
+ @node_factory.create_element_ns(namespace_uri, qualified_name, options)
1651
1961
  end
1652
1962
 
1653
1963
  def get_elements_by_tag_name(name)
@@ -1662,27 +1972,68 @@ module Dommy
1662
1972
  HTMLCollection.elements_by_tag_name_ns(@backend_doc, self, namespace, local_name)
1663
1973
  end
1664
1974
 
1665
- # `document.write(html)` — legacy API. Appends parsed nodes to the
1666
- # body. Real browsers only re-stream the DOM during initial parse;
1667
- # this stub is enough for tests that fire write() during teardown.
1975
+ # ----- Dynamic markup insertion (document.open / write / close) -----
1976
+ #
1977
+ # Dommy has no incremental parser, so this models the spec's input stream
1978
+ # without one:
1979
+ #
1980
+ # - While the page's own scripts boot (readyState "loading" and a script
1981
+ # running) the parser counts as active with a script nesting level above
1982
+ # 0: `open()` is a no-op that returns the document, and `write()` inserts
1983
+ # the parsed markup right after the running script (where the insertion
1984
+ # point would be), or at the end of the body when that script is not in
1985
+ # the body.
1986
+ # - Otherwise `write()` with no script-created parser runs the document open
1987
+ # steps first (the page is gone), and every write re-parses the whole
1988
+ # input written since `open()` as a fresh document, whose children replace
1989
+ # the document's. Earlier writes' nodes are therefore not the same objects
1990
+ # after a later write, and scripts in written markup do not run.
1991
+ #
1992
+ # Spec: https://html.spec.whatwg.org/#dynamic-markup-insertion
1993
+
1994
+ # `document.write(...text)` — the document write steps with lineFeed false.
1668
1995
  def write(*args)
1669
- html = args.join
1670
- fragment = Parser.fragment(html, owner_doc: @backend_doc)
1671
- removed = []
1672
- added = fragment.children.to_a
1673
- body_node = body.__dommy_backend_node__
1674
- added.each { |node| body_node.add_child(node) }
1675
- notify_child_list_mutation(target_node: body_node, added_nodes: added, removed_nodes: removed)
1676
- nil
1996
+ document_write(args, line_feed: false)
1677
1997
  end
1678
1998
 
1679
- # No-ops — real browsers reset the DOM on `open()` and flush
1680
- # pending writes on `close()`. We don't model the parse pipeline.
1681
- def open
1682
- nil
1999
+ # `document.writeln(...text)` — the document write steps with lineFeed true.
2000
+ def writeln(*args)
2001
+ document_write(args, line_feed: true)
1683
2002
  end
1684
2003
 
2004
+ # `document.open()` — the document open steps; returns the document. With
2005
+ # three arguments it is `window.open(url, name, features)` instead.
2006
+ #
2007
+ # Spec: https://html.spec.whatwg.org/#dom-document-open
2008
+ def open(*args)
2009
+ return open_window(args) if args.length >= 3
2010
+
2011
+ document_open_steps
2012
+ end
2013
+
2014
+ # `document.close()` — closes the input stream a script-created parser
2015
+ # reads: the parser reaches EOF and "stops parsing", so the document goes
2016
+ # "interactive" (DOMContentLoaded) then "complete" (load at the window,
2017
+ # when there is one). Dispatched synchronously rather than as queued tasks.
2018
+ #
2019
+ # Spec: https://html.spec.whatwg.org/#dom-document-close
1685
2020
  def close
2021
+ raise DOMException::InvalidStateError, "close() is not supported on an XML document" unless html_document?
2022
+ raise_if_markup_insertion_forbidden!("close")
2023
+ return nil if @script_created_input.nil?
2024
+
2025
+ # The parser reaches EOF. With nothing written it has seen no doctype
2026
+ # (quirks mode) and, into an empty document, builds html/head/body.
2027
+ if @script_created_input.empty?
2028
+ if @backend_doc.children.to_a.empty?
2029
+ reparse_script_created_input
2030
+ else
2031
+ @quirks_mode = true
2032
+ end
2033
+ end
2034
+ @script_created_input = nil
2035
+ __internal_set_ready_state__("interactive")
2036
+ __internal_set_ready_state__("complete")
1686
2037
  nil
1687
2038
  end
1688
2039
 
@@ -1722,12 +2073,11 @@ module Dommy
1722
2073
  end
1723
2074
 
1724
2075
  def __js_get__(key)
1725
- if key.start_with?("on") && key.length > 2
1726
- # An event handler IDL attribute (GlobalEventHandlers /
1727
- # DocumentAndElementEventHandlers, plus onreadystatechange and
1728
- # onvisibilitychange): the registered handler, or null when unset —
1729
- # matching Element's on* getter.
1730
- return @on_handlers&.[](event_name_from_on(key))
2076
+ if Internal::EventHandlers.idl_attribute?(self, key)
2077
+ # An event handler IDL attribute Document declares (GlobalEventHandlers,
2078
+ # plus onreadystatechange, onvisibilitychange, onfullscreenchange, …):
2079
+ # the registered handler, or null when unset — matching Element's.
2080
+ return on_handler(event_name_from_on(key))
1731
2081
  end
1732
2082
 
1733
2083
  case key
@@ -1753,10 +2103,17 @@ module Dommy
1753
2103
  read_title
1754
2104
  when "dir"
1755
2105
  dir
2106
+ when "fgColor" then fg_color
2107
+ when "linkColor" then link_color
2108
+ when "vlinkColor" then vlink_color
2109
+ when "alinkColor" then alink_color
2110
+ when "bgColor" then bg_color
1756
2111
  when "cookie"
1757
2112
  cookie
1758
2113
  when "nodeType"
1759
2114
  9
2115
+ when "customElementRegistry"
2116
+ __internal_custom_element_registry__
1760
2117
  when "isConnected"
1761
2118
  # A document is its own shadow-including root, so it is always connected.
1762
2119
  true
@@ -1784,7 +2141,7 @@ module Dommy
1784
2141
  when "designMode"
1785
2142
  @design_mode || "off"
1786
2143
  when "lastModified"
1787
- @last_modified || "01/01/1970 00:00:00"
2144
+ last_modified
1788
2145
  when "readyState"
1789
2146
  # "complete" by default (the document is fully parsed before scripts
1790
2147
  # run); an embedder can replay "loading" → "interactive" → "complete"
@@ -1854,20 +2211,36 @@ module Dommy
1854
2211
  end
1855
2212
  end
1856
2213
 
2214
+ # The elements of this document tree that can give its Window a named
2215
+ # property (HTML §7.2.2.3 "Named access on the Window object"), in tree
2216
+ # order: navigable containers, elements with an id, and embed / form /
2217
+ # img / object elements with a name. Window#__js_named_props__ applies the
2218
+ # spec's rules to them.
2219
+ def __internal_window_named_candidates__
2220
+ @backend_doc.css("[id], [name], iframe, frame").filter_map { |node| wrap_node(node) }
2221
+ end
2222
+
1857
2223
  # The document's supported property names (for `"name" in document`): for
1858
2224
  # each exposed element in tree order, its id when it is a named element with
1859
2225
  # that name, then its name.
1860
2226
  def __js_named_props__
1861
2227
  names = []
1862
2228
  named_getter_nodes.each do |node|
1863
- id = node["id"].to_s
2229
+ id = Backend.no_namespace_attribute_value(node, "id").to_s
1864
2230
  names << id if named_element?(node, id)
1865
- name = node["name"].to_s
2231
+ name = Backend.no_namespace_attribute_value(node, "name").to_s
1866
2232
  names << name if named_element?(node, name)
1867
2233
  end
1868
2234
  names.uniq
1869
2235
  end
1870
2236
 
2237
+ # The named getter on its own, for the bridge to read a supported name
2238
+ # BEFORE the builtins ([LegacyOverrideBuiltIns]: `<form name=body>` makes
2239
+ # `document.body` that form).
2240
+ def __js_named_get__(name)
2241
+ document_named_property(name)
2242
+ end
2243
+
1871
2244
  # Resolve a document named-getter property: nil when unsupported, a single
1872
2245
  # element (a named iframe yields its content window), or an HTMLCollection
1873
2246
  # when several elements share the name.
@@ -1884,39 +2257,8 @@ module Dommy
1884
2257
  el.local_name.to_s.casecmp?("iframe") && el.respond_to?(:content_window) ? (el.content_window || el) : el
1885
2258
  end
1886
2259
 
1887
- private
1888
-
1889
- # "The html element": the document element when it is an HTML <html>.
1890
- def html_element
1891
- root = document_element
1892
- root if root.is_a?(HTMLElement) && root.local_name == "html"
1893
- end
1894
-
1895
- # An element is a named element with the name `name` when it is one of the
1896
- # exposed kinds and either carries that `name`, or is an object with that
1897
- # `id`, or is an img whose id it is and which also has a non-empty name.
1898
- def named_element?(node, name)
1899
- return false if name.empty?
1900
- return false unless %w[embed form iframe img object].include?(node.name.to_s.downcase)
1901
- return true if node["name"] == name
1902
- return true if node.name.to_s.casecmp?("object") && node["id"] == name
1903
-
1904
- node.name.to_s.casecmp?("img") && node["id"] == name && !node["name"].to_s.empty?
1905
- end
1906
-
1907
- # Elements the document's named getter exposes, in tree order.
1908
- def named_getter_nodes
1909
- @backend_doc.css("embed, form, iframe, img, object")
1910
- end
1911
-
1912
- def document_named_property_nodes(name)
1913
- named_getter_nodes.select { |node| named_element?(node, name) }.map { |node| wrap_node(node) }.compact
1914
- end
1915
-
1916
- public
1917
-
1918
2260
  def __js_set__(key, value)
1919
- if key.start_with?("on") && key.length > 2
2261
+ if Internal::EventHandlers.idl_attribute?(self, key)
1920
2262
  # `document.onXxx = fn` registers fn as a single named handler; nil
1921
2263
  # removes it. Without this the assignment became a plain JS expando and
1922
2264
  # the handler never fired (e.g. `document.onreadystatechange`).
@@ -1931,14 +2273,27 @@ module Dommy
1931
2273
  self.cookie = value.to_s
1932
2274
  when "dir"
1933
2275
  self.dir = value
2276
+ when "fgColor" then self.fg_color = value
2277
+ when "linkColor" then self.link_color = value
2278
+ when "vlinkColor" then self.vlink_color = value
2279
+ when "alinkColor" then self.alink_color = value
2280
+ when "bgColor" then self.bg_color = value
1934
2281
  when "designMode"
1935
2282
  # Enumerated: only "on"/"off" (case-insensitive), else ignored.
1936
2283
  v = value.to_s.downcase
1937
2284
  @design_mode = v if %w[on off].include?(v)
2285
+ when "domain"
2286
+ self.domain = value.to_s
1938
2287
  when "location"
1939
2288
  # `document.location = url` navigates, same as `location.href = url`.
1940
2289
  loc = @default_view&.__js_get__("location")
1941
2290
  loc&.__js_set__("href", value)
2291
+ when "body"
2292
+ # WebIDL `attribute HTMLElement? body`: anything but an HTMLElement or
2293
+ # null fails the conversion before the setter's own checks run.
2294
+ raise Bridge::TypeError, "document.body must be an HTMLElement or null" unless value.nil? || value.is_a?(HTMLElement)
2295
+
2296
+ self.body = value
1942
2297
  when "head", "documentElement"
1943
2298
  # Readonly attributes: assignment is a silent no-op. Handle it here so the
1944
2299
  # bridge doesn't store a JS-side expando that would shadow the getter.
@@ -1998,9 +2353,8 @@ module Dommy
1998
2353
  document_remove_child(args[0])
1999
2354
  when "insertBefore"
2000
2355
  raise Bridge::TypeError, "insertBefore requires 2 arguments." if args.length < 2
2001
- Internal::WebIDL.nullable_node!(args[1])
2002
2356
 
2003
- document_insert_before(args[0], args[1])
2357
+ insert_before(args[0], args[1])
2004
2358
  when "replaceChild"
2005
2359
  document_replace_child(args[0], args[1])
2006
2360
  when "moveBefore"
@@ -2013,7 +2367,8 @@ module Dommy
2013
2367
  when "normalize"
2014
2368
  normalize
2015
2369
  when "writeln"
2016
- write(*(args + ["\n"]))
2370
+ writeln(*args)
2371
+ Bridge::UNDEFINED
2017
2372
  when "exitFullscreen"
2018
2373
  exit_fullscreen
2019
2374
  when "startViewTransition"
@@ -2021,17 +2376,13 @@ module Dommy
2021
2376
  # synchronously; return a ViewTransition with already-resolved
2022
2377
  # `finished` / `ready` / `updateCallbackDone` promises.
2023
2378
  callback = args[0]
2024
- if callback.respond_to?(:__js_call__)
2025
- callback.__js_call__("call", [])
2026
- elsif callback.respond_to?(:call)
2027
- callback.call
2028
- end
2379
+ CallableInvoker.invoke(callback)
2029
2380
 
2030
2381
  ViewTransition.new(@default_view)
2031
2382
  when "createElement"
2032
- create_element(args[0])
2383
+ create_element(args[0], args[1])
2033
2384
  when "createElementNS"
2034
- create_element_ns(args[0], args[1])
2385
+ create_element_ns(args[0], args[1], args[2])
2035
2386
  when "createTextNode"
2036
2387
  create_text_node(args[0])
2037
2388
  when "createComment"
@@ -2069,7 +2420,8 @@ module Dommy
2069
2420
  when "createEvent"
2070
2421
  create_event(args[0])
2071
2422
  when "importNode"
2072
- import_node(args[0], args[1])
2423
+ # A missing options argument is undefined (the default), not null.
2424
+ import_node(args[0], args.fetch(1, Bridge::UNDEFINED))
2073
2425
  when "adoptNode"
2074
2426
  adopt_node(args[0])
2075
2427
  when "hasFocus"
@@ -2088,10 +2440,12 @@ module Dommy
2088
2440
  dispatch_event(args[0])
2089
2441
  when "write"
2090
2442
  write(*args)
2443
+ Bridge::UNDEFINED
2091
2444
  when "open"
2092
- open
2445
+ open(*args)
2093
2446
  when "close"
2094
2447
  close
2448
+ Bridge::UNDEFINED
2095
2449
  else
2096
2450
  nil
2097
2451
  end
@@ -2112,16 +2466,113 @@ module Dommy
2112
2466
  return if @ready_state == state
2113
2467
 
2114
2468
  @ready_state = state
2115
- dispatch_event(Event.new("readystatechange"))
2469
+ @loading_lifecycle = true if state == "loading"
2470
+ __internal_fire_event__("readystatechange")
2116
2471
  case state
2117
2472
  when "interactive"
2118
- dispatch_event(Event.new("DOMContentLoaded", "bubbles" => true))
2473
+ __internal_fire_event__("DOMContentLoaded", {"bubbles" => true})
2119
2474
  when "complete"
2120
- @default_view&.dispatch_event(Event.new("load"))
2475
+ fire_load_and_pageshow
2121
2476
  end
2122
2477
  nil
2123
2478
  end
2124
2479
 
2480
+ # The document's current readiness ("loading" / "interactive" /
2481
+ # "complete").
2482
+ def __internal_ready_state__ = @ready_state
2483
+
2484
+ # HTML "update the current document readiness" alone: readyState becomes
2485
+ # `state` and `readystatechange` fires — without the milestone event
2486
+ # __internal_set_ready_state__ adds. The parser's "the end" fires
2487
+ # DOMContentLoaded and load from tasks of their own (see ScriptBoot).
2488
+ def __internal_update_readiness__(state)
2489
+ state = state.to_s
2490
+ return nil if @ready_state == state
2491
+
2492
+ @ready_state = state
2493
+ @loading_lifecycle = true if state == "loading"
2494
+ __internal_fire_event__("readystatechange")
2495
+ nil
2496
+ end
2497
+
2498
+ # "The end", its DOMContentLoaded task: fire a trusted, bubbling
2499
+ # `DOMContentLoaded` at the document.
2500
+ def __internal_fire_dom_content_loaded__
2501
+ __internal_fire_event__("DOMContentLoaded", {"bubbles" => true})
2502
+ nil
2503
+ end
2504
+
2505
+ # "The end", its load task: readiness becomes "complete", then `load` at
2506
+ # the window and `pageshow`, and the document is completely loaded.
2507
+ def __internal_finish_loading__
2508
+ return nil unless @loading_lifecycle
2509
+
2510
+ __internal_update_readiness__("complete")
2511
+ fire_load_and_pageshow
2512
+ nil
2513
+ end
2514
+
2515
+ # Whether something delays this document's load event: a child navigable
2516
+ # still navigating (HTML "potentially delays the load event", iframe).
2517
+ def __internal_load_delayed__?
2518
+ @backend_doc.css("iframe").any? do |node|
2519
+ frame = wrap_node(node)
2520
+ frame.is_a?(HTMLIFrameElement) && frame.__internal_navigation_pending__?
2521
+ end
2522
+ end
2523
+
2524
+ # The end of loading, from "update the current document readiness to
2525
+ # complete": fire a trusted `load` at the window with the legacy target
2526
+ # override (so `event.target` is this document), then `pageshow` (persisted
2527
+ # false), and mark the document completely loaded — ready for post-load
2528
+ # tasks such as a print() requested while it loaded.
2529
+ def fire_load_and_pageshow
2530
+ view = @default_view
2531
+ if view
2532
+ load = Event.new("load")
2533
+ load.__internal_set_target__(self)
2534
+ view.dispatch_event(load.__internal_mark_trusted__)
2535
+ view.dispatch_event(__internal_page_transition_event__("pageshow"))
2536
+ end
2537
+ @loading_lifecycle = false
2538
+ view.__internal_ready_for_post_load_tasks__ if view.respond_to?(:__internal_ready_for_post_load_tasks__)
2539
+ run_after_load_tasks
2540
+ end
2541
+
2542
+ # Queue `block` as a task once this document has completely loaded (at
2543
+ # once when it already has).
2544
+ def __internal_after_load__(&block)
2545
+ if __internal_completely_loaded__?
2546
+ queue_after_load_task(block)
2547
+ else
2548
+ (@after_load_tasks ||= []) << block
2549
+ end
2550
+ nil
2551
+ end
2552
+
2553
+ def run_after_load_tasks
2554
+ tasks = @after_load_tasks || []
2555
+ @after_load_tasks = nil
2556
+ tasks.each { |task| queue_after_load_task(task) }
2557
+ end
2558
+ private :run_after_load_tasks
2559
+
2560
+ def queue_after_load_task(block)
2561
+ scheduler = __internal_scheduler__
2562
+ scheduler ? scheduler.set_timeout(block, 0) : block.call
2563
+ end
2564
+ private :queue_after_load_task
2565
+ private :fire_load_and_pageshow
2566
+
2567
+ # A trusted page transition event (`pageshow` / `pagehide`) for this
2568
+ # document's window: bubbles, cancelable, `persisted` false (there is no
2569
+ # back/forward cache), targeted at the document (legacy target override).
2570
+ def __internal_page_transition_event__(type, persisted: false)
2571
+ event = PageTransitionEvent.new(type, "persisted" => persisted, "bubbles" => true, "cancelable" => true)
2572
+ event.__internal_set_target__(self)
2573
+ event.__internal_mark_trusted__
2574
+ end
2575
+
2125
2576
  # Set `document.currentScript` to the <script> element being executed (and
2126
2577
  # back to nil afterward). The host (script boot) brackets each classic
2127
2578
  # script run with this so code reading `document.currentScript` sees its own
@@ -2131,15 +2582,26 @@ module Dommy
2131
2582
  nil
2132
2583
  end
2133
2584
 
2585
+ # HTML "execute the script element", for a classic script: currentScript
2586
+ # is the element while its script runs (null when the element's root is a
2587
+ # shadow root — not "in a document tree", since a script removed before it
2588
+ # runs still points at itself), then goes back to whatever it was before,
2589
+ # so a script inserted and run from inside another script leaves the outer
2590
+ # one current again. A caller reports the script's exception from inside
2591
+ # the block: the report is part of the run, while currentScript is set.
2592
+ def __internal_with_current_script__(element)
2593
+ old = @__current_script__
2594
+ @__current_script__ = element.root_node.is_a?(ShadowRoot) ? nil : element
2595
+ yield
2596
+ ensure
2597
+ @__current_script__ = old
2598
+ end
2599
+
2134
2600
  # Delegate node wrapping to NodeWrapperCache
2135
2601
  def wrap_node(node)
2136
2602
  @node_wrapper_cache.wrap(node)
2137
2603
  end
2138
2604
 
2139
- def wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
2140
- @node_factory.wrap_cloned_element_ns(node, namespace, prefix, local, qualified_name)
2141
- end
2142
-
2143
2605
  # The task scheduler this document's own tasks run on: its browsing context's
2144
2606
  # when it has one, otherwise the one handed to it by whatever built it (a
2145
2607
  # DOMParser document has no defaultView but still queues tasks on the window
@@ -2159,25 +2621,59 @@ module Dommy
2159
2621
  # cleared (HTML §4.12.1.1: the HTML/XML parser clears it on every element it
2160
2622
  # inserts, so a plain parsed `<script>` reports `.async === false`).
2161
2623
  def __internal_run_parsed_insertion_steps__
2162
- return nil unless @backend_doc.respond_to?(:css)
2163
-
2164
2624
  # HTML-namespace only: a `css` query matches on local name, so a
2165
2625
  # `<details>` the parser put inside `<svg>` answers it too, as an
2166
2626
  # SVGElement that has none of these steps.
2167
- elements = @backend_doc.css("details").filter_map { |node| __internal_html_element_wrapper__(node) }
2627
+ elements = parsed_css("details").filter_map { |node| __internal_html_element_wrapper__(node) }
2168
2628
  HTMLDetailsElement.run_insertion_steps(elements) unless elements.empty?
2169
- @backend_doc.css("select").each { |node| __internal_html_element_wrapper__(node)&.__internal_settle_selectedness_once__ }
2170
- @backend_doc.css("script").each { |node| __internal_html_element_wrapper__(node)&.__internal_mark_parser_inserted__ }
2629
+ parsed_css("select").each { |node| __internal_html_element_wrapper__(node)&.__internal_settle_selectedness_once__ }
2630
+ parsed_css("script").each do |node|
2631
+ script = __internal_html_element_wrapper__(node)
2632
+ next unless script
2633
+
2634
+ script.__internal_mark_parser_inserted__
2635
+ script.__internal_mark_parser_document__
2636
+ end
2637
+ parsed_css("meta[http-equiv]").each { |node| __internal_html_element_wrapper__(node)&.__internal_run_pragma__ }
2638
+ # An open dialog the parser inserted runs its dialog setup steps.
2639
+ parsed_css("dialog[open]").each do |node|
2640
+ dialog = __internal_html_element_wrapper__(node)
2641
+ dialog.__internal_dialog_inserted__ if dialog.respond_to?(:__internal_dialog_inserted__)
2642
+ end
2643
+ # Each element the parser inserted with an autofocus attribute is an
2644
+ # autofocus candidate (only in a document with a browsing context).
2645
+ parsed_css("[autofocus]").each do |node|
2646
+ element = wrap_node(node)
2647
+ __internal_autofocus_inserted__(element) if element.respond_to?(:autofocus) && element.is_connected?
2648
+ end
2649
+ nil
2650
+ end
2651
+
2652
+ # Script boot replays the parser's insertion of each `<iframe>` (in tree
2653
+ # order): its post-connection steps create its child navigable and process
2654
+ # its attributes, so a srcless one fires `load` and one with a `src` starts
2655
+ # navigating. Only a document with a browsing context has child navigables.
2656
+ def __internal_process_parsed_iframes__
2657
+ return nil unless @default_view
2658
+
2659
+ parsed_css("iframe").each do |node|
2660
+ frame = __internal_html_element_wrapper__(node)
2661
+ frame.__internal_parser_inserted__ if frame.is_a?(HTMLIFrameElement)
2662
+ end
2171
2663
  nil
2172
2664
  end
2173
2665
 
2666
+ # HTML's pragma-set default language (`<meta http-equiv=content-language
2667
+ # content=…>`): nil until such a pragma has been processed.
2668
+ attr_accessor :__internal_pragma_default_language__
2669
+
2174
2670
  # DOMParser parses with scripting disabled (HTML) or XML scripting support
2175
2671
  # disabled, so every script it makes is "already started": moved or cloned
2176
2672
  # into a document that runs scripts, it still does not run. Found by local
2177
2673
  # name, so an XML document's prefixed `h:script` counts too.
2178
- def __internal_mark_scripts_already_started__
2179
- Internal::NodeTraversal.subtree_nodes(@backend_doc).each do |node|
2180
- next unless node.respond_to?(:element?) && node.element?
2674
+ def __internal_mark_scripts_already_started__(root = @backend_doc)
2675
+ Internal::NodeTraversal.subtree_nodes(root).each do |node|
2676
+ next unless node.element?
2181
2677
  next unless node.local_name == "script"
2182
2678
 
2183
2679
  wrapper = wrap_node(node)
@@ -2217,26 +2713,6 @@ module Dommy
2217
2713
  wrapper
2218
2714
  end
2219
2715
 
2220
- # The wrapper already cached for a backend node, or nil — never builds one.
2221
- def __internal_cached_wrapper__(node)
2222
- @node_wrapper_cache.cached_wrapper(node)
2223
- end
2224
-
2225
- # Recorded when an element is created outside the HTML namespace or with a
2226
- # prefix. Deep cloning only has to carry that metadata across for a document
2227
- # that has some — which the overwhelming majority never do, so the ordinary
2228
- # `body.cloneNode(true)` keeps walking nothing.
2229
- def __internal_note_namespaced_element__(namespace, prefix)
2230
- return if namespace == Element::HTML_NAMESPACE && prefix.nil?
2231
-
2232
- @namespaced_elements = true
2233
- nil
2234
- end
2235
-
2236
- def __internal_namespaced_elements__?
2237
- @namespaced_elements == true
2238
- end
2239
-
2240
2716
  # Clear the cached wrapper so the next `wrap_node` creates a new
2241
2717
  # one. Used by `customElements.define` to upgrade nodes that were
2242
2718
  # constructed before the registration landed.
@@ -2266,6 +2742,69 @@ module Dommy
2266
2742
  @shadow_registry.find_for_host(host_node)
2267
2743
  end
2268
2744
 
2745
+ # DOM "allow declarative shadow roots": whether the document's own parser
2746
+ # (the page parse, document.write) attaches declarative shadow roots. On
2747
+ # for a browsing context's documents and parseHTMLUnsafe()'s; off for
2748
+ # createHTMLDocument(), DOMParser and `new Document()`; a clone keeps it.
2749
+ attr_writer :__internal_allow_declarative_shadow_roots__
2750
+
2751
+ def __internal_allow_declarative_shadow_roots__? = @__internal_allow_declarative_shadow_roots__ ? true : false
2752
+
2753
+ # The document's parser has built `root_bn`'s subtree: attach its
2754
+ # declarative shadow roots, when the document allows them. The shadow
2755
+ # roots are the document's parsed roots from then on (their scripts run
2756
+ # at boot, their elements get the parser's insertion steps). Returns them.
2757
+ def __internal_attach_declarative_shadow_roots__(root_bn, top_host: nil)
2758
+ return [] unless __internal_allow_declarative_shadow_roots__? && Backend.html_backed?(@backend_doc)
2759
+
2760
+ roots = Internal::DeclarativeShadowRoots.attach(self, root_bn, top_host: top_host)
2761
+ (@parser_shadow_roots ||= []).concat(roots)
2762
+ roots
2763
+ end
2764
+
2765
+ # The roots of what the document's parser built: the document, and the
2766
+ # declarative shadow roots it attached.
2767
+ def __internal_parsed_roots__
2768
+ return [@backend_doc] unless @parser_shadow_roots
2769
+
2770
+ [@backend_doc, *@parser_shadow_roots.map(&:__dommy_backend_node__)]
2771
+ end
2772
+
2773
+ # The document's parser-inserted scripts, in the order the parser met
2774
+ # them: tree order, where a declarative shadow tree's contents come at
2775
+ # the place its template was. With no declarative shadow roots, that is
2776
+ # `document.scripts`.
2777
+ def __internal_parser_scripts__
2778
+ return scripts.to_a unless @parser_shadow_roots
2779
+
2780
+ parsed_shadows = @parser_shadow_roots.to_h { |root| [Backend.identity_key(root.host.__dommy_backend_node__), root] }
2781
+ list = []
2782
+ collect_parser_scripts(@backend_doc, parsed_shadows, list)
2783
+ list
2784
+ end
2785
+
2786
+ # Whether any shadow root was ever attached in this document — the HTML
2787
+ # serializer's fast path asks before looking for shadow hosts.
2788
+ def __internal_any_shadow_roots__? = @shadow_registry.any?
2789
+
2790
+ # Whether an element of this document has an is value it does not carry
2791
+ # as an `is` attribute (createElement's `{is}`, a customized built-in's
2792
+ # constructor, a clone of either) — the serializer's other reason to
2793
+ # leave its fast path. The elements given an is value are held weakly.
2794
+ def __internal_any_is_values__?
2795
+ return false unless @is_value_elements
2796
+
2797
+ @is_value_elements.keys.any? do |element|
2798
+ node = element.__dommy_backend_node__
2799
+ current = @node_wrapper_cache.peek(node) || element
2800
+ current.__internal_is_value__ && !Backend.has_attribute_ns?(node, nil, "is")
2801
+ end
2802
+ end
2803
+
2804
+ def __internal_note_is_value__(element)
2805
+ (@is_value_elements ||= ObjectSpace::WeakMap.new)[element] = true
2806
+ end
2807
+
2269
2808
  # Every element among `root`'s shadow-including inclusive descendants, as
2270
2809
  # backend nodes, in shadow-including tree order: an element, then the shadow
2271
2810
  # tree it hosts, then its children. The whole list, shadow trees included, is
@@ -2277,18 +2816,6 @@ module Dommy
2277
2816
  nil
2278
2817
  end
2279
2818
 
2280
- def shadow_including_elements(root, list = [])
2281
- elements = root.respond_to?(:element?) && root.element? ? [root] : []
2282
- elements.concat(root.css("*").to_a) if root.respond_to?(:css)
2283
- elements.each do |element|
2284
- list << element
2285
- shadow = @shadow_registry.find_for_host(element)
2286
- shadow_including_elements(shadow.__dommy_backend_node__, list) if shadow
2287
- end
2288
- list
2289
- end
2290
- private :shadow_including_elements
2291
-
2292
2819
  def __internal_shadow_root_containing__(node)
2293
2820
  @shadow_registry.find_enclosing(node)
2294
2821
  end
@@ -2326,8 +2853,67 @@ module Dommy
2326
2853
  @mutation_coordinator.notify_disconnected_subtree(nk)
2327
2854
  end
2328
2855
 
2329
- def __internal_notify_attribute_changed__(element, name, old_value, new_value)
2330
- @mutation_coordinator.notify_attribute_changed(element, name, old_value, new_value)
2856
+ # DOM "custom element registry" of the document: the one initialize()
2857
+ # set, else its window's (none without a browsing context).
2858
+ attr_writer :__internal_custom_element_registry__
2859
+
2860
+ def __internal_custom_element_registry__
2861
+ @__internal_custom_element_registry__ || (@default_view.custom_elements if @default_view.respond_to?(:custom_elements))
2862
+ end
2863
+
2864
+ # The element the HTML element constructor makes for `new MyElement()`.
2865
+ def __internal_create_custom_element__(definition)
2866
+ @node_factory.create_custom_element(definition)
2867
+ end
2868
+
2869
+ # HTML "try to upgrade an element": enqueue an upgrade reaction when the
2870
+ # registry `element` looks definitions up in has one for it.
2871
+ def __internal_try_to_upgrade__(element)
2872
+ registry = element.__internal_ce_registry__
2873
+ return unless registry&.any_definitions?
2874
+
2875
+ definition = CustomElementRegistry.lookup(registry, element.namespace_uri, element.local_name,
2876
+ element.__internal_is_value__)
2877
+ Internal::CEReactions.enqueue_upgrade(element, definition) if definition
2878
+ end
2879
+
2880
+ # The elements a parser or a clone just created in this document without
2881
+ # the synchronous custom elements flag (DOM "create an element" step 6.3),
2882
+ # with `registry` (the parser's intended parent's, the original's): each
2883
+ # one gets the registry when it is a scoped one, and each one a definition
2884
+ # applies to an upgrade reaction. `nodes` are backend nodes, each walked
2885
+ # in tree order (a template's contents are not its children, and belong
2886
+ # to a document with no registry).
2887
+ def __internal_enqueue_created_upgrades__(nodes, registry = CustomElementRegistry.for_document(self))
2888
+ explicit = !registry.equal?(CustomElementRegistry.effective_global_for(self))
2889
+ return unless explicit || registry&.any_definitions?
2890
+
2891
+ (nodes.is_a?(Array) ? nodes : [nodes]).each do |root|
2892
+ Internal::NodeTraversal.subtree_nodes(root).each do |node|
2893
+ next unless node.element?
2894
+
2895
+ known = __internal_peek_wrapper__(node)
2896
+ if explicit
2897
+ known ||= wrap_node(node)
2898
+ known.__internal_ce_data__.registry = registry
2899
+ end
2900
+ next if registry.nil?
2901
+ next unless Backend.namespace_uri(node) == Element::HTML_NAMESPACE
2902
+
2903
+ # The is value a wrapper was made with (a clone's), else the one
2904
+ # the parser gives it: its `is` attribute.
2905
+ is_value = known ? known.__internal_is_value__ : Backend.get_attribute_ns(node, nil, "is")
2906
+ definition = registry.lookup_definition(node.name, is_value)
2907
+ next unless definition
2908
+
2909
+ element = known || wrap_node(node)
2910
+ Internal::CEReactions.enqueue_upgrade(element, definition) if element
2911
+ end
2912
+ end
2913
+ end
2914
+
2915
+ def __internal_notify_attribute_changed__(element, name, old_value, new_value, namespace = nil)
2916
+ @mutation_coordinator.notify_attribute_changed(element, name, old_value, new_value, namespace)
2331
2917
  end
2332
2918
 
2333
2919
  def register_observer(observer)
@@ -2446,6 +3032,184 @@ module Dommy
2446
3032
  end
2447
3033
  end
2448
3034
 
3035
+ # The document write steps (without Trusted Types).
3036
+ #
3037
+ # Spec: https://html.spec.whatwg.org/#document-write-steps
3038
+ def document_write(args, line_feed:)
3039
+ string = args.map { |a| a.nil? ? "null" : a.to_s }.join
3040
+ string += "\n" if line_feed
3041
+ raise DOMException::InvalidStateError, "write() is not supported on an XML document" unless html_document?
3042
+ raise_if_markup_insertion_forbidden!("write")
3043
+
3044
+ return boot_script_write(string) if @script_created_input.nil? && boot_script_running?
3045
+
3046
+ document_open_steps if @script_created_input.nil?
3047
+ # The open steps return early (no script-created parser) only when the
3048
+ # parser is active, which the branch above already took.
3049
+ return nil if @script_created_input.nil?
3050
+
3051
+ @script_created_input << string
3052
+ reparse_script_created_input
3053
+ nil
3054
+ end
3055
+
3056
+ # Whether a page script is running while the page boots: the closest Dommy
3057
+ # has to "an active parser whose script nesting level is greater than 0".
3058
+ def boot_script_running?
3059
+ @ready_state == "loading" && !@__current_script__.nil?
3060
+ end
3061
+
3062
+ # The document open steps, minus what Dommy does not model (the origin
3063
+ # check against the entry document, unload counters, stopping a
3064
+ # navigation, and the URL and history update steps).
3065
+ #
3066
+ # Spec: https://html.spec.whatwg.org/#document-open-steps
3067
+ def document_open_steps
3068
+ raise DOMException::InvalidStateError, "open() is not supported on an XML document" unless html_document?
3069
+ raise_if_markup_insertion_forbidden!("open")
3070
+ return self if boot_script_running?
3071
+ # A second open() while a script-created parser is open keeps it (the
3072
+ # spec's steps re-run, but the written input so far is already gone with
3073
+ # the children they replace).
3074
+ erase_all_event_listeners_and_handlers
3075
+ document_replace_children([])
3076
+ @quirks_mode = false
3077
+ @script_created_input = +""
3078
+ unless @ready_state == "loading"
3079
+ @ready_state = "loading"
3080
+ __internal_fire_event__("readystatechange")
3081
+ end
3082
+ self
3083
+ end
3084
+
3085
+ # Insert what a boot-time script wrote at the insertion point: right after
3086
+ # the running script when it sits in the body, else at the end of the body
3087
+ # (or of the document element when there is no body).
3088
+ def boot_script_write(string)
3089
+ script_bn = @__current_script__&.__dommy_backend_node__
3090
+ parent_bn = script_bn&.parent
3091
+ in_body = parent_bn && body && parent_bn != @backend_doc &&
3092
+ Internal::NodeTraversal.subtree_nodes(body.__dommy_backend_node__).include?(parent_bn)
3093
+ target_bn = in_body ? parent_bn : (body || document_element)&.__dommy_backend_node__
3094
+ return nil unless target_bn
3095
+
3096
+ context = target_bn.element? ? target_bn : nil
3097
+ fragment = Parser.fragment(string, owner_doc: @backend_doc, context: context)
3098
+ # The document's parser reads the markup: a top-level declarative
3099
+ # template's adjusted current node is the element it is written into.
3100
+ __internal_attach_declarative_shadow_roots__(fragment, top_host: target_bn)
3101
+ added = fragment.children.to_a
3102
+ return nil if added.empty?
3103
+
3104
+ # The document's parser creates a defined element by running its
3105
+ # constructor; Dommy's parsed the markup already, so each one is
3106
+ # upgraded instead, before it is inserted.
3107
+ __internal_enqueue_created_upgrades__(added)
3108
+
3109
+ if in_body
3110
+ reference = script_bn
3111
+ added.each do |node|
3112
+ reference.add_next_sibling(node)
3113
+ reference = node
3114
+ end
3115
+ else
3116
+ added.each { |node| target_bn.add_child(node) }
3117
+ end
3118
+ @__document_writing = true
3119
+ begin
3120
+ notify_child_list_mutation(target_node: target_bn, added_nodes: added, removed_nodes: [])
3121
+ ensure
3122
+ @__document_writing = false
3123
+ end
3124
+ nil
3125
+ end
3126
+
3127
+ # Whether the insertion under way comes from document.write: an external
3128
+ # script it writes is the parser's pending parsing-blocking script, which
3129
+ # runs as soon as the writing script returns — before the parser goes on.
3130
+ def __internal_document_writing__ = @__document_writing == true
3131
+
3132
+ # Re-parse everything written since open() as a whole document and make
3133
+ # its children the document's (see "Dynamic markup insertion" above).
3134
+ # HTML's throw-on-dynamic-markup-insertion counter: while the parser runs
3135
+ # custom element constructors and reactions (see #parser_runs_scripts),
3136
+ # open(), write() and close() are an InvalidStateError.
3137
+ def raise_if_markup_insertion_forbidden!(operation)
3138
+ return unless @throw_on_dynamic_markup_insertion_counter.to_i.positive?
3139
+
3140
+ raise DOMException::InvalidStateError, "#{operation}() is not allowed while the parser constructs a custom element"
3141
+ end
3142
+
3143
+ # The parser's custom element work for markup it inserts: the
3144
+ # constructors ("create an element for a token" with willExecuteScript)
3145
+ # and the reactions they enqueue run before it continues, with the
3146
+ # throw-on-dynamic-markup-insertion counter raised.
3147
+ def parser_runs_scripts
3148
+ @throw_on_dynamic_markup_insertion_counter = @throw_on_dynamic_markup_insertion_counter.to_i + 1
3149
+ Internal::CEReactions.scope { yield }
3150
+ ensure
3151
+ @throw_on_dynamic_markup_insertion_counter -= 1
3152
+ end
3153
+
3154
+ def reparse_script_created_input
3155
+ parser_runs_scripts { reparse_script_created_input_now }
3156
+ end
3157
+
3158
+ def reparse_script_created_input_now
3159
+ parsed = Document.new(nil, backend_doc: Backend.parse(@script_created_input))
3160
+ @quirks_mode = parsed.quirks_mode?
3161
+ parsed.__internal_mark_scripts_already_started__
3162
+ # What the parser builds, not what a script inserts: a doctype and an
3163
+ # element go in together, which replaceChildren's checks would refuse.
3164
+ removed = @backend_doc.children.to_a
3165
+ removed.each { |child| detach_node(child) }
3166
+ added = document_insertion_nodes(parsed.child_nodes.to_a)
3167
+ added.each { |n| @backend_doc.add_child(n) }
3168
+ # The written markup is this document's parser's: its declarative
3169
+ # shadow roots, when the document allows them, replace the last parse's.
3170
+ @parser_shadow_roots = nil
3171
+ __internal_attach_declarative_shadow_roots__(@backend_doc).each do |root|
3172
+ __internal_mark_scripts_already_started__(root.__dommy_backend_node__)
3173
+ end
3174
+ notify_document_child_list(added: added, removed: removed)
3175
+ end
3176
+
3177
+ # "Erase all event listeners and handlers" for the document's
3178
+ # shadow-including inclusive descendants and, for a window's document, the
3179
+ # window. Only nodes that have a wrapper can hold listeners.
3180
+ def erase_all_event_listeners_and_handlers
3181
+ targets = [self]
3182
+ collect_listener_targets(@backend_doc, targets)
3183
+ targets << @default_view if @default_view
3184
+ targets.each { |t| t.__internal_erase_event_listeners_and_handlers__ if t.respond_to?(:__internal_erase_event_listeners_and_handlers__) }
3185
+ end
3186
+
3187
+ def collect_listener_targets(root_bn, targets)
3188
+ Internal::NodeTraversal.subtree_nodes(root_bn).each do |bn|
3189
+ wrapper = @node_wrapper_cache.peek(bn)
3190
+ next unless wrapper
3191
+
3192
+ targets << wrapper unless wrapper.equal?(self)
3193
+ shadow = wrapper.respond_to?(:__internal_shadow_root__) ? wrapper.__internal_shadow_root__ : nil
3194
+ next unless shadow
3195
+
3196
+ targets << shadow
3197
+ collect_listener_targets(shadow.__dommy_backend_node__, targets) if shadow.__dommy_backend_node__
3198
+ end
3199
+ end
3200
+
3201
+ # document.open(url, name, features) is window.open; it needs a fully
3202
+ # active document (one with a window, here).
3203
+ def open_window(args)
3204
+ raise DOMException::InvalidAccessError, "the document is not fully active" unless @default_view
3205
+
3206
+ @default_view.__js_call__("open", args)
3207
+ end
3208
+
3209
+ private :document_write, :boot_script_running?, :document_open_steps, :boot_script_write,
3210
+ :reparse_script_created_input, :erase_all_event_listeners_and_handlers,
3211
+ :collect_listener_targets, :open_window
3212
+
2449
3213
  # Unlink a backend node from its parent and queue a childList removal record
2450
3214
  # capturing the node's position (previous/next sibling) BEFORE the unlink, so
2451
3215
  # the record's previousSibling/nextSibling are correct (the coordinator can't
@@ -2524,7 +3288,7 @@ module Dommy
2524
3288
  # https://github.com/takahashim/dommy/issues/24
2525
3289
  def __internal_normalize__(root)
2526
3290
  text_nodes = []
2527
- root.traverse { |node| text_nodes << node if node.respond_to?(:text?) && node.text? }
3291
+ root.traverse { |node| text_nodes << node if node.text? }
2528
3292
 
2529
3293
  text_nodes.each do |node|
2530
3294
  next unless node.parent # already removed as part of an earlier run
@@ -2535,7 +3299,7 @@ module Dommy
2535
3299
  end
2536
3300
 
2537
3301
  sib = node.next
2538
- while sib.respond_to?(:text?) && sib.text?
3302
+ while sib&.text?
2539
3303
  following = sib.next
2540
3304
  data = sib.content.to_s
2541
3305
  # The offset the sibling's data lands at inside the survivor — the
@@ -2600,8 +3364,8 @@ module Dommy
2600
3364
 
2601
3365
  # Delegate factory methods to NodeWrapperCache
2602
3366
 
2603
- def create_element(name)
2604
- @node_factory.create_element(name)
3367
+ def create_element(name, options = nil)
3368
+ @node_factory.create_element(name, options)
2605
3369
  end
2606
3370
 
2607
3371
  def create_text_node(text)
@@ -2659,6 +3423,123 @@ module Dommy
2659
3423
 
2660
3424
  private
2661
3425
 
3426
+ # HTML "is a registrable domain suffix of or is equal to" (a single label
3427
+ # stands in for the public suffix; see #domain=).
3428
+ def registrable_domain_suffix_or_equal?(suffix_string, original_host)
3429
+ return false if suffix_string.empty?
3430
+
3431
+ suffix = Internal::UrlParser.parse("http://#{suffix_string}/").host.to_s
3432
+ return true if suffix == original_host
3433
+ return false if ip_host?(suffix) || ip_host?(original_host)
3434
+ return false unless original_host.end_with?(".#{suffix}")
3435
+ # The suffix may not itself be a public suffix.
3436
+ return false unless suffix.include?(".")
3437
+
3438
+ true
3439
+ rescue Internal::UrlParser::Failure
3440
+ false
3441
+ end
3442
+
3443
+ def ip_host?(host)
3444
+ host.start_with?("[") || host.match?(/\A\d+\.\d+\.\d+\.\d+\z/)
3445
+ end
3446
+
3447
+
3448
+ def creator_base_url
3449
+ @creator_base_url if @creator_base_url && FALLBACK_BASE_URLS.include?(url)
3450
+ end
3451
+
3452
+ # importNode is a clone, so the HTML cloning steps run for it as they do
3453
+ # for cloneNode (#__internal_apply_cloning_steps__): the live state Dommy
3454
+ # keeps on a wrapper — an input's dirty value, a script's "already
3455
+ # started" — is copied onto the new node. The originals' wrappers belong
3456
+ # to the source document, so they are looked up there.
3457
+ def apply_imported_cloning_steps(src_root, copy_root, deep, source_document)
3458
+ return unless source_document.respond_to?(:__internal_peek_wrapper__)
3459
+
3460
+ src_nodes = deep ? Internal::NodeTraversal.subtree_nodes(src_root) : [src_root]
3461
+ copy_nodes = deep ? Internal::NodeTraversal.subtree_nodes(copy_root) : [copy_root]
3462
+ return unless src_nodes.length == copy_nodes.length
3463
+
3464
+ src_nodes.zip(copy_nodes).each do |orig, copy|
3465
+ state = source_document.__internal_peek_wrapper__(orig)&.then { |w| w.respond_to?(:__internal_cloning_state__) && w.__internal_cloning_state__ }
3466
+ next unless state
3467
+
3468
+ wrapper = wrap_node(copy)
3469
+ wrapper.__internal_apply_cloning_state__(state) if wrapper.respond_to?(:__internal_apply_cloning_state__)
3470
+ end
3471
+ end
3472
+
3473
+ def node_adopter
3474
+ @node_adopter ||= Internal::NodeAdopter.new(self)
3475
+ end
3476
+
3477
+ def collect_parser_scripts(node, parsed_shadows, list)
3478
+ shadow = node.element? ? parsed_shadows[Backend.identity_key(node)] : nil
3479
+ shadow = nil unless shadow&.host&.__dommy_backend_node__ == node
3480
+ pending = shadow
3481
+ node.children.each do |child|
3482
+ if pending && (before = pending.__internal_parsed_before__) && Backend.identity_key(child) == Backend.identity_key(before)
3483
+ collect_parser_scripts(pending.__dommy_backend_node__, parsed_shadows, list)
3484
+ pending = nil
3485
+ end
3486
+ next unless child.element?
3487
+
3488
+ if child.local_name == "script" && (script = __internal_html_element_wrapper__(child))
3489
+ list << script
3490
+ end
3491
+ collect_parser_scripts(child, parsed_shadows, list)
3492
+ end
3493
+ collect_parser_scripts(pending.__dommy_backend_node__, parsed_shadows, list) if pending
3494
+ end
3495
+
3496
+ # A `css` query over every root the parser built.
3497
+ def parsed_css(selector)
3498
+ roots = __internal_parsed_roots__
3499
+ return @backend_doc.css(selector) if roots.size == 1
3500
+
3501
+ roots.flat_map { |root| root.css(selector).to_a }
3502
+ end
3503
+
3504
+ def shadow_including_elements(root, list = [])
3505
+ elements = root.element? ? [root] : []
3506
+ elements.concat(root.css("*").to_a)
3507
+ elements.each do |element|
3508
+ list << element
3509
+ shadow = @shadow_registry.find_for_host(element)
3510
+ shadow_including_elements(shadow.__dommy_backend_node__, list) if shadow
3511
+ end
3512
+ list
3513
+ end
3514
+
3515
+ # "The html element": the document element when it is an HTML <html>.
3516
+ def html_element
3517
+ root = document_element
3518
+ root if root.is_a?(HTMLElement) && root.local_name == "html"
3519
+ end
3520
+
3521
+ # An element is a named element with the name `name` when it is one of the
3522
+ # exposed kinds and either carries that `name`, or is an object with that
3523
+ # `id`, or is an img whose id it is and which also has a non-empty name.
3524
+ def named_element?(node, name)
3525
+ return false if name.empty?
3526
+ return false unless %w[embed form iframe img object].include?(node.name.to_s.downcase)
3527
+ own_name = Backend.no_namespace_attribute_value(node, "name")
3528
+ return true if own_name == name
3529
+ return true if node.name.to_s.casecmp?("object") && Backend.no_namespace_attribute_value(node, "id") == name
3530
+
3531
+ node.name.to_s.casecmp?("img") && Backend.no_namespace_attribute_value(node, "id") == name && !own_name.to_s.empty?
3532
+ end
3533
+
3534
+ # Elements the document's named getter exposes, in tree order.
3535
+ def named_getter_nodes
3536
+ @backend_doc.css("embed, form, iframe, img, object")
3537
+ end
3538
+
3539
+ def document_named_property_nodes(name)
3540
+ named_getter_nodes.select { |node| named_element?(node, name) }.map { |node| wrap_node(node) }.compact
3541
+ end
3542
+
2662
3543
  # Build a Nokogiri copy of the given node inside our @backend_doc.
2663
3544
  # `deep: true` recurses into children. Used by importNode and
2664
3545
  # adoptNode for cross-document transfer.
@@ -2670,11 +3551,9 @@ module Dommy
2670
3551
  # A <template>'s contents live in a separate content fragment, not its
2671
3552
  # child list, so the pass over `children` misses them. It still runs: an
2672
3553
  # XML document's <template> keeps its children in the child list.
2673
- clone_template_content(source, copy, source_document) if source.element? && source.name == "template"
2674
- if source.respond_to?(:children)
2675
- source.children.each do |child|
2676
- copy.add_child(clone_into_doc(child, true, source_document))
2677
- end
3554
+ clone_template_content(source, copy, source_document) if @template_content_registry.template_node?(source)
3555
+ source.children.each do |child|
3556
+ copy.add_child(clone_into_doc(child, true, source_document))
2678
3557
  end
2679
3558
 
2680
3559
  copy
@@ -2682,12 +3561,13 @@ module Dommy
2682
3561
 
2683
3562
  # "Clone a single node" (§4.4): the copy implements the SAME interface as
2684
3563
  # the original — a ProcessingInstruction clones to one, not to the comment
2685
- # its serialization looks like — and carries the same data. Elements are
2686
- # cloned by #clone_element_into_doc; children are the caller's business.
3564
+ # its serialization looks like — and carries the same data. An element's
3565
+ # copy keeps its name, namespace, prefix and attributes as the backend
3566
+ # holds them; children are the caller's business.
2687
3567
  def clone_single_node_into_doc(source, source_document)
2688
3568
  if source.element?
2689
- clone_element_into_doc(source, source_document)
2690
- elsif (cdata = Backend.cdata_class) && source.is_a?(cdata)
3569
+ Backend.import_element(source, @backend_doc)
3570
+ elsif source.is_a?(Backend.cdata_class)
2691
3571
  # CDATA is a Text subtype in the backend, so ask about it first.
2692
3572
  Backend.create_cdata(source.content, @backend_doc)
2693
3573
  elsif source.text?
@@ -2705,7 +3585,7 @@ module Dommy
2705
3585
  # `document_fragment_class.new`, so it works on backends whose fragment
2706
3586
  # class isn't directly instantiable (Makiri).
2707
3587
  Parser.fragment("", owner_doc: @backend_doc)
2708
- elsif (doctype = Backend.document_type_class) && source.is_a?(doctype)
3588
+ elsif source.is_a?(Backend.document_type_class)
2709
3589
  wrapper = source_document.wrap_node(source)
2710
3590
  Backend.create_document_type(wrapper.name, wrapper.public_id, wrapper.system_id, @backend_doc)
2711
3591
  else
@@ -2718,36 +3598,6 @@ module Dommy
2718
3598
  # element stays `A:B`, where the DOM's setAttribute would lower-case it).
2719
3599
  # Only a createElementNS name the backend node does not carry lives on the
2720
3600
  # original's wrapper, so the copy's wrapper is given the same metadata.
2721
- def clone_element_into_doc(source, source_document)
2722
- wrapper = source_document.wrap_node(source)
2723
- namespace, prefix, local, qualified = clone_name_parts(wrapper, source)
2724
- copy = Backend.import_element(source, @backend_doc)
2725
- note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
2726
- copy
2727
- end
2728
-
2729
- # The four parts of the original's name — namespace, prefix, local name and
2730
- # qualified name. The wrapper's own metadata has them whenever there is
2731
- # anything to carry (createElementNS's case and prefix, an XML document's,
2732
- # the HTML parser's SVG names); otherwise the element is an unprefixed one
2733
- # whose namespace the backend knows.
2734
- def clone_name_parts(wrapper, source)
2735
- meta = wrapper.__internal_namespace_metadata__ if wrapper.respond_to?(:__internal_namespace_metadata__)
2736
- return meta if meta
2737
-
2738
- local = wrapper ? wrapper.local_name.to_s : source.name
2739
- [wrapper&.namespace_uri, nil, local, local]
2740
- end
2741
-
2742
- # Give the copy's wrapper the namespace metadata whenever the backend node
2743
- # alone would report something else (see #clone_element_into_doc).
2744
- def note_cloned_element_namespace(copy, namespace, prefix, local, qualified)
2745
- derived = Backend.namespace_uri(copy)
2746
- return if derived == namespace && prefix.nil? && local == copy.name
2747
-
2748
- wrap_cloned_element_ns(copy, namespace, prefix, local, qualified)
2749
- end
2750
-
2751
3601
  # HTML's cloning steps for a <template>: a deep copy of each of the
2752
3602
  # source's contents, appended to the copy's contents.
2753
3603
  def clone_template_content(source, copy, source_document = self)
@@ -2758,29 +3608,91 @@ module Dommy
2758
3608
  content_nodes.each { |n| frag.add_child(clone_into_doc(n, true, source_document)) }
2759
3609
  end
2760
3610
 
3611
+ # The document's element child, as a backend node, or nil. The backend's
3612
+ # `root` is that element in the ordinary case, but once the element it
3613
+ # held is gone it answers the doctype, even when another element has been
3614
+ # appended since — so look past it.
3615
+ def backend_document_element
3616
+ root = @backend_doc.root
3617
+ root&.element? ? root : @backend_doc.children.find(&:element?)
3618
+ end
3619
+
3620
+ # The first child of the html element — the document element, when it is
3621
+ # `html` in the HTML namespace — that is an HTML element named one of
3622
+ # `names`, as a backend node; HTML defines both the head and the body
3623
+ # element this way. Only the match is wrapped.
3624
+ def html_element_child(names)
3625
+ root = backend_document_element
3626
+ return nil unless root && root.local_name == "html" && Backend.namespace_uri(root) == Internal::Namespaces::HTML
3627
+
3628
+ root.element_children.find { |c| names.include?(c.local_name) && Backend.namespace_uri(c) == Internal::Namespaces::HTML }
3629
+ end
3630
+
3631
+ def body_or_frameset?(node)
3632
+ node.is_a?(Element) && %w[body frameset].include?(node.local_name) &&
3633
+ node.namespace_uri == Internal::Namespaces::HTML
3634
+ end
3635
+
3636
+ # document.title's getter (HTML §3.1.3): the child text content of the
3637
+ # SVG `title` child of an SVG `svg` document element, else of the title
3638
+ # element, stripped and collapsed of ASCII whitespace. ASCII whitespace is
3639
+ # exactly tab/LF/FF/CR/space — NOT Ruby's String#strip set, which also
3640
+ # removes U+000B (vertical tab) and must be left intact.
2761
3641
  def read_title
2762
- # The first title element in tree order (usually the head's), with its
2763
- # child text content stripped and collapsed of ASCII whitespace per WHATWG.
2764
- # ASCII whitespace is exactly tab/LF/FF/CR/space — NOT Ruby's String#strip
2765
- # set, which also removes U+000B (vertical tab) and must be left intact.
2766
- title = @backend_doc.at_css("title")
3642
+ root = backend_document_element
3643
+ title = svg_root?(root) ? svg_title_child(root) : html_title_element
2767
3644
  return "" unless title
2768
3645
 
2769
- title.text.gsub(/[\t\n\f\r ]+/, " ").gsub(/\A[\t\n\f\r ]+|[\t\n\f\r ]+\z/, "")
3646
+ Backend.child_text_content(title).gsub(/[\t\n\f\r ]+/, " ").gsub(/\A[\t\n\f\r ]+|[\t\n\f\r ]+\z/, "")
2770
3647
  end
2771
3648
 
3649
+ # document.title's setter: string-replace-all in the SVG title (made the
3650
+ # document element's first child when missing), or in the title element
3651
+ # (appended to the head element when missing, unless there is no head
3652
+ # either). A document element in any other namespace takes no title.
2772
3653
  def write_title(value)
2773
- head = @backend_doc.at_css("head")
2774
- return unless head
3654
+ root = backend_document_element
3655
+ if svg_root?(root)
3656
+ title = svg_title_child(root)
3657
+ title = insert_title(root, Internal::Namespaces::SVG, before: root.first_child) unless title
3658
+ elsif root && Backend.namespace_uri(root) == Internal::Namespaces::HTML
3659
+ title = html_title_element
3660
+ unless title
3661
+ head = html_element_child(%w[head])
3662
+ return unless head
3663
+
3664
+ title = insert_title(head, Internal::Namespaces::HTML)
3665
+ end
3666
+ else
3667
+ return
3668
+ end
3669
+ wrap_node(title).text_content = value
3670
+ end
2775
3671
 
2776
- title = head.at_css("title")
2777
- unless title
2778
- title = Backend.create_element("title", @backend_doc)
2779
- head.add_child(title)
3672
+ # The title element: the first HTML `title` in the document, in tree order.
3673
+ # Matched by namespace and local name: an XML document's `css("title")`
3674
+ # does not find an HTML-namespace title, so there the tree is walked.
3675
+ def html_title_element
3676
+ candidates = html_document? ? @backend_doc.css("title") : Internal::NodeTraversal.subtree_nodes(@backend_doc)
3677
+ candidates.find do |node|
3678
+ node.element? && node.local_name == "title" && Backend.namespace_uri(node) == Internal::Namespaces::HTML
2780
3679
  end
3680
+ end
3681
+
3682
+ def svg_root?(root)
3683
+ root && root.local_name == "svg" && Backend.namespace_uri(root) == Internal::Namespaces::SVG
3684
+ end
3685
+
3686
+ def svg_title_child(root)
3687
+ root.element_children.find { |c| c.local_name == "title" && Backend.namespace_uri(c) == Internal::Namespaces::SVG }
3688
+ end
2781
3689
 
2782
- title.children.to_a.each { |child| detach_node(child) }
2783
- title.add_child(Backend.create_text(value, @backend_doc))
3690
+ # A new `title` in `namespace`, inserted into `parent` before `before`
3691
+ # (appended when nil), as a backend node.
3692
+ def insert_title(parent, namespace, before: nil)
3693
+ title = create_element_ns(namespace, "title")
3694
+ wrap_node(parent).insert_before(title, before && wrap_node(before))
3695
+ title.__dommy_backend_node__
2784
3696
  end
2785
3697
 
2786
3698
  end