Class: Dommy::Document

Inherits:
Object
  • Object
show all
Includes:
Bridge::Methods, EventTarget, Node
Defined in:
lib/dommy/document.rb

Overview

‘document` — the entry point for DOM construction and querying. Wrapper caching keeps DOM identity stable across repeated traversals (`body.children.parentElement`).

Constant Summary

Constants included from Node

Node::ATTRIBUTE_NODE, Node::CDATA_SECTION_NODE, Node::COMMENT_NODE, Node::DOCUMENT_FRAGMENT_NODE, Node::DOCUMENT_NODE, Node::DOCUMENT_POSITION_CONTAINED_BY, Node::DOCUMENT_POSITION_CONTAINS, Node::DOCUMENT_POSITION_DISCONNECTED, Node::DOCUMENT_POSITION_FOLLOWING, Node::DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC, Node::DOCUMENT_POSITION_PRECEDING, Node::DOCUMENT_TYPE_NODE, Node::ELEMENT_NODE, Node::HTML_NAMESPACE, Node::PROCESSING_INSTRUCTION_NODE, Node::TEXT_NODE

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Bridge::Methods

included

Methods included from Node

#compare_document_position, #get_root_node, #is_default_namespace, #is_equal_node, #is_same_node, #lookup_namespace_uri, #lookup_prefix

Methods included from EventTarget

#__internal_deliver_event__, #add_event_listener, capture_flag, #deliver_at, #dispatch_event, js_truthy?, #remove_event_listener

Constructor Details

#initialize(host = nil, nokogiri_doc: nil, default_view: nil) ⇒ Document

Returns a new instance of Document.



313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/dommy/document.rb', line 313

def initialize(host = nil, nokogiri_doc: nil, default_view: nil)
  @host = host
  @default_view = default_view
  @node_wrapper_cache = Internal::NodeWrapperCache.new(self)
  @observer_manager = Internal::ObserverManager.new
  @shadow_registry = Internal::ShadowRootRegistry.new
  @cookie_jar = Internal::CookieJar.new
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
  @node_iterators = []
  @nokogiri_doc = nokogiri_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
  @content_type = "text/html"
end

Instance Attribute Details

#content_typeObject

content_type defaults to “text/html”; settable so an integration layer can reflect the response Content-Type. Read-only over the JS bridge.



311
312
313
# File 'lib/dommy/document.rb', line 311

def content_type
  @content_type
end

#default_viewObject

Returns the value of attribute default_view.



308
309
310
# File 'lib/dommy/document.rb', line 308

def default_view
  @default_view
end

#fullscreen_elementObject (readonly)

Fullscreen API — no actual fullscreen mode, just track which element claimed it. ‘element.requestFullscreen()` sets it; this is the read side.



642
643
644
# File 'lib/dommy/document.rb', line 642

def fullscreen_element
  @fullscreen_element
end

#nokogiri_docObject (readonly)

Returns the value of attribute nokogiri_doc.



307
308
309
# File 'lib/dommy/document.rb', line 307

def nokogiri_doc
  @nokogiri_doc
end

Instance Method Details

#[](key) ⇒ Object



846
847
848
# File 'lib/dommy/document.rb', line 846

def [](key)
  __js_get__(key.to_s)
end

#[]=(key, value) ⇒ Object



850
851
852
# File 'lib/dommy/document.rb', line 850

def []=(key, value)
  __js_set__(key.to_s, value)
end

#__internal_event_parent__Object



1128
1129
1130
# File 'lib/dommy/document.rb', line 1128

def __internal_event_parent__
  @default_view
end

#__internal_insert_at_doctype__(nodes, after:) ⇒ Object

Called by DocumentType#before/#after — insert ‘nodes` before the doctype (at the document start) or after it (just before the document element).



772
773
774
775
776
777
778
779
780
781
782
# File 'lib/dommy/document.rb', line 772

def __internal_insert_at_doctype__(nodes, after:)
  bns = nodes.filter_map { |n| backend_node(n) }
  if after
    root = @nokogiri_doc.root
    root ? bns.each { |n| root.add_previous_sibling(n) } : bns.each { |n| @nokogiri_doc.add_child(n) }
  else
    first = @nokogiri_doc.children.first
    first ? bns.reverse_each { |n| first.add_previous_sibling(n) } : bns.each { |n| @nokogiri_doc.add_child(n) }
  end
  nil
end

#__internal_notify_attribute_changed__(element, name, old_value, new_value) ⇒ Object



1183
1184
1185
# File 'lib/dommy/document.rb', line 1183

def __internal_notify_attribute_changed__(element, name, old_value, new_value)
  @mutation_coordinator.notify_attribute_changed(element, name, old_value, new_value)
end

#__internal_notify_connected__(element) ⇒ Object

Lifecycle callback dispatchers. Errors raised inside user callbacks are swallowed so a single buggy custom element can’t break the whole mutation pipeline. Delegate to MutationCoordinator



1167
1168
1169
# File 'lib/dommy/document.rb', line 1167

def __internal_notify_connected__(element)
  @mutation_coordinator.notify_connected(element)
end

#__internal_notify_connected_subtree__(nk) ⇒ Object



1175
1176
1177
# File 'lib/dommy/document.rb', line 1175

def __internal_notify_connected_subtree__(nk)
  @mutation_coordinator.notify_connected_subtree(nk)
end

#__internal_notify_disconnected__(element) ⇒ Object



1171
1172
1173
# File 'lib/dommy/document.rb', line 1171

def __internal_notify_disconnected__(element)
  @mutation_coordinator.notify_disconnected(element)
end

#__internal_notify_disconnected_subtree__(nk) ⇒ Object



1179
1180
1181
# File 'lib/dommy/document.rb', line 1179

def __internal_notify_disconnected_subtree__(nk)
  @mutation_coordinator.notify_disconnected_subtree(nk)
end

#__internal_register_shadow_fragment__(fragment_node, shadow_root) ⇒ Object

ShadowRoot identity registry: map a Nokogiri DocumentFragment (the shadow tree’s backing node) to the wrapping ShadowRoot so slot assignment and event composition can walk from any inner node back to its shadow boundary. Delegate to ShadowRootRegistry



1150
1151
1152
# File 'lib/dommy/document.rb', line 1150

def __internal_register_shadow_fragment__(fragment_node, shadow_root)
  @shadow_registry.register(fragment_node, shadow_root)
end

#__internal_remove_doctype__(_doctype) ⇒ Object

Called by DocumentType#remove — the doctype is synthesized from the DTD, so remove the internal subset and mark it gone.



764
765
766
767
768
# File 'lib/dommy/document.rb', line 764

def __internal_remove_doctype__(_doctype)
  @doctype_removed = true
  @nokogiri_doc.internal_subset&.unlink
  nil
end

#__internal_reset_wrapper__(nokogiri_node) ⇒ Object

Clear the cached wrapper so the next ‘wrap_node` creates a new one. Used by `customElements.define` to upgrade nodes that were constructed before the registration landed.



1140
1141
1142
# File 'lib/dommy/document.rb', line 1140

def __internal_reset_wrapper__(nokogiri_node)
  @node_wrapper_cache.reset_wrapper(nokogiri_node)
end

#__internal_set_active_element__(el) ⇒ Object



517
518
519
# File 'lib/dommy/document.rb', line 517

def __internal_set_active_element__(el)
  @active_element = el
end

#__internal_set_fullscreen_element__(element) ⇒ Object



644
645
646
647
648
649
650
# File 'lib/dommy/document.rb', line 644

def __internal_set_fullscreen_element__(element)
  previous = @fullscreen_element
  @fullscreen_element = element
  return if previous == element

  dispatch_event(Event.new("fullscreenchange"))
end

#__internal_shadow_root_containing__(node) ⇒ Object



1158
1159
1160
# File 'lib/dommy/document.rb', line 1158

def __internal_shadow_root_containing__(node)
  @shadow_registry.find_enclosing(node)
end

#__internal_shadow_root_for_fragment__(fragment_node) ⇒ Object



1154
1155
1156
# File 'lib/dommy/document.rb', line 1154

def __internal_shadow_root_for_fragment__(fragment_node)
  @shadow_registry.find_for_fragment(fragment_node)
end

#__js_call__(method, args) ⇒ Object



1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
# File 'lib/dommy/document.rb', line 1017

def __js_call__(method, args)
  case method
  when "hasChildNodes"
    @nokogiri_doc.children.any?
  when "contains"
    contains?(args[0])
  when "isEqualNode"
    is_equal_node(args[0])
  when "appendChild"
    append_child(args[0])
  when "append"
    document_insert(args, prepend: false)
  when "prepend"
    document_insert(args, prepend: true)
  when "replaceChildren"
    document_replace_children(args)
  when "removeChild"
    document_remove_child(args[0])
  when "insertBefore"
    document_insert_before(args[0], args[1])
  when "replaceChild"
    document_replace_child(args[0], args[1])
  when "cloneNode"
    clone_node(args[0])
  when "normalize"
    nil # the document has no text children to merge
  when "writeln"
    write(*(args + ["\n"]))
  when "exitFullscreen"
    exit_fullscreen
  when "startViewTransition"
    # View Transitions API stub. Spec: invoke the callback
    # synchronously; return a ViewTransition with already-resolved
    # `finished` / `ready` / `updateCallbackDone` promises.
    callback = args[0]
    if callback.respond_to?(:__js_call__)
      callback.__js_call__("call", [])
    elsif callback.respond_to?(:call)
      callback.call
    end

    ViewTransition.new(@default_view)
  when "createElement"
    create_element(args[0])
  when "createElementNS"
    create_element_ns(args[0], args[1])
  when "createTextNode"
    create_text_node(args[0])
  when "createComment"
    create_comment(args[0])
  when "createCDATASection"
    create_cdata_section(args[0])
  when "createProcessingInstruction"
    create_processing_instruction(args[0], args[1])
  when "createDocumentFragment"
    create_document_fragment
  when "querySelector"
    query_selector(Internal.css_query_arg!(args))
  when "querySelectorAll"
    query_selector_all(Internal.css_query_arg!(args))
  when "getElementById"
    get_element_by_id(args[0])
  when "getElementsByClassName"
    get_elements_by_class_name(args[0])
  when "getElementsByTagNameNS"
    get_elements_by_tag_name_ns(args[0], args[1])
  when "getElementsByTagName"
    get_elements_by_tag_name(args[0])
  when "getElementsByName"
    get_elements_by_name(args[0])
  when "createAttribute"
    create_attribute(args[0])
  when "createAttributeNS"
    create_attribute_ns(args[0], args[1])
  when "createTreeWalker"
    create_tree_walker(args[0], coerce_what_to_show(args, 1), normalize_filter(args[2]))
  when "createNodeIterator"
    create_node_iterator(args[0], coerce_what_to_show(args, 1), normalize_filter(args[2]))
  when "createRange"
    create_range
  when "createEvent"
    create_event(args[0])
  when "importNode"
    import_node(args[0], args[1])
  when "adoptNode"
    adopt_node(args[0])
  when "hasFocus"
    has_focus?
  when "getSelection"
    get_selection
  when "elementFromPoint"
    element_from_point(args[0], args[1])
  when "queryCommandSupported"
    query_command_supported(args[0])
  when "addEventListener"
    add_event_listener(args[0], args[1], args[2])
  when "removeEventListener"
    remove_event_listener(args[0], args[1], args[2])
  when "dispatchEvent"
    dispatch_event(args[0])
  when "write"
    write(*args)
  when "open"
    open
  when "close"
    close
  else
    nil
  end
end

#__js_get__(key) ⇒ Object



872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
# File 'lib/dommy/document.rb', line 872

def __js_get__(key)
  case key
  when "body"
    body
  when "head"
    head
  when "doctype"
    doctype
  when "implementation"
    implementation
  when "defaultView"
    @default_view
  when "fullscreenElement"
    @fullscreen_element
  when "fullscreenEnabled"
    true
  when "scrollingElement"
    wrap_node(@nokogiri_doc.at_css("html"))
  when "documentElement"
    # The document's root element — `<html>` for HTML, the actual root for XML.
    wrap_node(@nokogiri_doc.root)
  when "title"
    read_title
  when "cookie"
    cookie
  when "nodeType"
    9
  when "activeElement"
    active_element
  when "URL", "documentURI"
    url
  when "baseURI"
    base_uri
  when "domain"
    domain
  when "origin"
    origin
  when "contentType"
    content_type
  when "location"
    # document.location is the same Location object as window.location.
    @default_view&.__js_get__("location")
  when "characterSet", "charset", "inputEncoding"
    # The DOM is held as Ruby strings (UTF-8); we don't model other encodings.
    "UTF-8"
  when "dir"
    document_element&.get_attribute("dir") || ""
  when "designMode"
    @design_mode || "off"
  when "lastModified"
    @last_modified || "01/01/1970 00:00:00"
  when "readyState"
    # The document is fully parsed by the time scripts run against it (there
    # is no incremental network parse), so it is always "complete". Code that
    # gates on `document.readyState === "loading"` (e.g. Turbo's preloader)
    # therefore takes the already-loaded path.
    "complete"
  when "visibilityState"
    # There's no real viewport/tab; the document is treated as the visible,
    # foreground page (so `nextRepaint`-style code uses requestAnimationFrame,
    # and `=== "visible"` checks pass).
    "visible"
  when "hidden"
    false
  when "compatMode"
    compat_mode
  when "referrer"
    referrer
  when "links"
    links
  when "forms"
    forms
  when "scripts"
    scripts
  when "images"
    images
  when "embeds", "plugins"
    # Both reflect the same list of <embed> elements.
    HTMLCollection.new { @nokogiri_doc.css("embed").map { |n| wrap_node(n) }.compact }
  when "anchors"
    # Historically `<a name>` (with a name attribute), not every link.
    HTMLCollection.new { @nokogiri_doc.css("a[name]").map { |n| wrap_node(n) }.compact }
  when "styleSheets"
    # No CSS engine; expose an empty (but present + iterable) StyleSheetList
    # so `document.styleSheets.length` / iteration don't blow up.
    NodeList.new
  when "children"
    children
  when "childNodes"
    child_nodes
  when "firstChild"
    child_nodes.to_a.first
  when "lastChild"
    child_nodes.to_a.last
  when "parentNode", "parentElement", "nextSibling", "previousSibling", "ownerDocument"
    # A document is the tree root: no parent or siblings, and its
    # ownerDocument is null per spec.
    nil
  when "childElementCount"
    child_element_count
  when "firstElementChild"
    first_element_child
  when "lastElementChild"
    last_element_child
  when "nodeName"
    "#document"
  else
    nil
  end
end

#__js_set__(key, value) ⇒ Object



983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
# File 'lib/dommy/document.rb', line 983

def __js_set__(key, value)
  case key
  when "title"
    write_title(value.to_s)
  when "cookie"
    self.cookie = value.to_s
  when "dir"
    document_element&.set_attribute("dir", value.to_s)
  when "designMode"
    # Enumerated: only "on"/"off" (case-insensitive), else ignored.
    v = value.to_s.downcase
    @design_mode = v if %w[on off].include?(v)
  when "location"
    # `document.location = url` navigates, same as `location.href = url`.
    loc = @default_view&.__js_get__("location")
    loc&.__js_set__("href", value)
  else
    return Bridge::UNHANDLED
  end

  nil
end

#active_elementObject

Currently-focused element (or body if none). Updated via ‘el.focus()` / `el.blur()`.



502
503
504
# File 'lib/dommy/document.rb', line 502

def active_element
  @active_element || body
end

#adopt_node(node) ⇒ Object

Move a node from another document into this one. The source node is detached from its previous owner and its ownerDocument becomes this. Returns the (possibly re-wrapped) node.



572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
# File 'lib/dommy/document.rb', line 572

def adopt_node(node)
  return nil unless node.respond_to?(:__dommy_backend_node__)

  src = node.__dommy_backend_node__
  src.unlink if src.parent

  # Same document: just return the wrapper after the detach above.
  return wrap_node(src) if src.document == @nokogiri_doc

  # Cross-document: Nokogiri reassigns `src.document` when src is
  # added under a node owned by another document. We transiently
  # attach to our root, then unlink so src ends up free-floating
  # but now belongs to @nokogiri_doc. The underlying Ruby object
  # identity is preserved.
  src_doc_wrapper = node.instance_variable_get(:@document)
  @nokogiri_doc.root.add_child(src)
  src.unlink

  # Move the caller's Dommy wrapper from the source document's
  # wrapper cache into ours, and re-point its @document. This
  # keeps `adopt_node(x).equal?(x)` true across documents.
  node.instance_variable_set(:@document, self)
  if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
    src_doc_wrapper.__internal_reset_wrapper__(src)
  end
  @node_wrapper_cache.register(src, node)
  node
end

#append_child(node) ⇒ Object

Append a node as a child of the document itself (e.g. a comment alongside the document element). Adopts the node into this document.



700
701
702
703
704
705
# File 'lib/dommy/document.rb', line 700

def append_child(node)
  return node unless node.respond_to?(:__dommy_backend_node__)

  @nokogiri_doc.add_child(node.__dommy_backend_node__)
  node
end

#at_xpath(expression) ⇒ Object

XPath queries returning wrapped nodes (Element / TextNode / etc).



383
384
385
386
# File 'lib/dommy/document.rb', line 383

def at_xpath(expression)
  node = @nokogiri_doc.at_xpath(expression)
  node && wrap_node(node)
end

#attach_template_content(template_element, html) ⇒ Object

—– template content helpers (called from Element) —–



1282
1283
1284
# File 'lib/dommy/document.rb', line 1282

def attach_template_content(template_element, html)
  @template_content_registry.attach(template_element, html)
end

#backend_node(node) ⇒ Object



791
792
793
# File 'lib/dommy/document.rb', line 791

def backend_node(node)
  node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__ : nil
end

#base_uriObject

‘document.baseURI` — resolves the first `<base href>` (if any) relative to the document URL; otherwise just the document URL. When `<base href>` is itself absolute, that wins. Browsers also ignore subsequent <base> elements; we mirror that.



405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/dommy/document.rb', line 405

def base_uri
  doc_url = url
  base_el = @nokogiri_doc.at_css("base[href]")
  return doc_url unless base_el

  href = base_el["href"].to_s
  return doc_url if href.empty?

  begin
    URI.join(doc_url.to_s.empty? ? "about:blank" : doc_url, href).to_s
  rescue URI::InvalidURIError
    doc_url
  end
end

#bodyObject

Resolve ‘body` fresh from the tree (not memoized) so it tracks a swapped `<body>` — e.g. Turbo’s page render does ‘documentElement.replaceChild(newBody, body)`, after which a stale cached wrapper would keep returning the detached old body. wrap_node caches by node, so identity (`document.body === document.body`) still holds.



373
374
375
# File 'lib/dommy/document.rb', line 373

def body
  wrap_node(@nokogiri_doc.at_css("body"))
end

#child_element_countObject



488
489
490
# File 'lib/dommy/document.rb', line 488

def child_element_count
  children.size
end

#child_nodesObject

All child nodes of the document (doctype + document element, …), as a live, cached NodeList — unlike ‘children`, which is element-only. Cached so `document.childNodes === document.childNodes` and mutations are reflected.



482
483
484
485
486
# File 'lib/dommy/document.rb', line 482

def child_nodes
  @live_child_nodes ||= LiveNodeList.new do
    @nokogiri_doc.children.map { |n| wrap_node(n) }.compact
  end
end

#childrenObject

ParentNode mixin (operates on the document’s element children —in practice the ‘<html>` root).



472
473
474
475
476
477
# File 'lib/dommy/document.rb', line 472

def children
  HTMLCollection.new do
    root = @nokogiri_doc.root
    root ? [wrap_node(root)].compact : []
  end
end

#clone_node(deep) ⇒ Object

‘document.cloneNode(deep)` → a fresh Document over a (deep) copy of the Nokogiri tree, preserving the content type.



786
787
788
789
# File 'lib/dommy/document.rb', line 786

def clone_node(deep)
  copy = deep ? @nokogiri_doc.dup : Backend.document_class.new
  Document.new(nil, nokogiri_doc: copy).tap { |d| d.content_type = @content_type }
end

#closeObject



842
843
844
# File 'lib/dommy/document.rb', line 842

def close
  nil
end

#coerce_what_to_show(args, index) ⇒ Object

WebIDL ‘unsigned long whatToShow = 0xFFFFFFFF`: an omitted or `undefined` argument uses the default; `null` coerces to 0; otherwise ToUint32.



542
543
544
545
546
547
548
549
# File 'lib/dommy/document.rb', line 542

def coerce_what_to_show(args, index)
  return NodeFilter::SHOW_ALL if args.length <= index
  value = args[index]
  return NodeFilter::SHOW_ALL if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
  return 0 if value.nil?

  value.to_i % (2**32)
end

#compat_modeObject

‘document.compatMode` — “CSS1Compat” in no-quirks mode, “BackCompat” in quirks mode. A missing doctype is quirks; a bare `<!DOCTYPE html>` (no public/system identifier) is no-quirks. (The full quirks algorithm keys off specific legacy public ids; this covers the common cases.)



341
342
343
344
345
346
347
# File 'lib/dommy/document.rb', line 341

def compat_mode
  dt = @nokogiri_doc.internal_subset
  return "BackCompat" unless dt
  return "CSS1Compat" if dt.name.to_s.downcase == "html" && dt.external_id.nil?

  "BackCompat"
end

#contains?(other) ⇒ Boolean

‘document.contains(node)` — true if `node` is the document itself or any node attached to its tree (per Node.contains, which all nodes including the document expose). Per spec, false for null / a non-Node.

Returns:

  • (Boolean)


509
510
511
512
513
514
515
# File 'lib/dommy/document.rb', line 509

def contains?(other)
  return true if other.equal?(self)
  return false unless other.respond_to?(:__dommy_backend_node__)

  node = other.__dommy_backend_node__
  node.document == @nokogiri_doc && node.ancestors.include?(@nokogiri_doc)
end

Delegate to CookieJar



797
798
799
# File 'lib/dommy/document.rb', line 797

def cookie
  @cookie_jar.to_cookie_string
end

#cookie=(value) ⇒ Object



801
802
803
804
# File 'lib/dommy/document.rb', line 801

def cookie=(value)
  @cookie_jar.set_cookie(value)
  nil
end

#create_attribute(name) ⇒ Object

Create a detached Attr. ‘setAttributeNode` attaches it to an element. Per spec, name must match the XML Name production —invalid names throw InvalidCharacterError.



524
525
526
# File 'lib/dommy/document.rb', line 524

def create_attribute(name)
  @node_wrapper_cache.create_attribute(name)
end

#create_attribute_ns(namespace_uri, qualified_name) ⇒ Object



528
529
530
# File 'lib/dommy/document.rb', line 528

def create_attribute_ns(namespace_uri, qualified_name)
  @node_wrapper_cache.create_attribute_ns(namespace_uri, qualified_name)
end

#create_cdata_section(text) ⇒ Object



860
861
862
# File 'lib/dommy/document.rb', line 860

def create_cdata_section(text)
  @node_wrapper_cache.create_cdata_section(text)
end

#create_comment(text) ⇒ Object

Create a Comment node. Wraps the Nokogiri comment so it flows through the same wrap_node identity machinery as Element / TextNode.



856
857
858
# File 'lib/dommy/document.rb', line 856

def create_comment(text)
  @node_wrapper_cache.create_comment(text)
end

#create_document_fragmentObject



864
865
866
# File 'lib/dommy/document.rb', line 864

def create_document_fragment
  @node_wrapper_cache.create_document_fragment
end

#create_element(name) ⇒ Object

Delegate factory methods to NodeWrapperCache



1260
1261
1262
# File 'lib/dommy/document.rb', line 1260

def create_element(name)
  @node_wrapper_cache.create_element(name)
end

#create_element_ns(namespace_uri, qualified_name) ⇒ Object



806
807
808
# File 'lib/dommy/document.rb', line 806

def create_element_ns(namespace_uri, qualified_name)
  @node_wrapper_cache.create_element_ns(namespace_uri, qualified_name)
end

#create_event(type_name) ⇒ Object

Legacy ‘document.createEvent(“EventName”)` factory. Returns an Event subclass instance whose init still has to be called (`event.initEvent(type, bubbles, cancelable)`). Matches the mapping happy-dom and linkedom use.



605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
# File 'lib/dommy/document.rb', line 605

def create_event(type_name)
  name = type_name.to_s
  case name
  when "Event", "Events", "HTMLEvents"
    Event.new("")
  when "CustomEvent"
    CustomEvent.new("")
  when "MouseEvent", "MouseEvents"
    MouseEvent.new("")
  when "KeyboardEvent", "KeyboardEvents"
    KeyboardEvent.new("")
  else
    Event.new("")
  end
end

#create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil) ⇒ Object

‘document.createNodeIterator(root, whatToShow?, filter?)` —flat depth-first iteration.



672
673
674
675
676
677
678
# File 'lib/dommy/document.rb', line 672

def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
  iterator = NodeIterator.new(root, what_to_show, filter)
  # Track live iterators so node removal can run the "NodeIterator
  # pre-removing steps" (adjusting referenceNode) before a node detaches.
  @node_iterators << iterator
  iterator
end

#create_processing_instruction(target, data) ⇒ Object



694
695
696
# File 'lib/dommy/document.rb', line 694

def create_processing_instruction(target, data)
  ProcessingInstruction.new(target, data)
end

#create_rangeObject



635
636
637
# File 'lib/dommy/document.rb', line 635

def create_range
  Range.new(self)
end

#create_text_node(text) ⇒ Object



1264
1265
1266
# File 'lib/dommy/document.rb', line 1264

def create_text_node(text)
  @node_wrapper_cache.create_text_node(text)
end

#create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil) ⇒ Object

‘document.createTreeWalker(root, whatToShow?, filter?)` — stateful tree traversal with sibling/parent navigation. `filter` may be a Ruby Proc, a JS-bridge callable, or an object with `accept_node` / `acceptNode`.



536
537
538
# File 'lib/dommy/document.rb', line 536

def create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
  TreeWalker.new(root, what_to_show, filter)
end

#doctypeObject

Minimal DocumentType — represents the ‘<!doctype html>` line. Always present in HTML5 documents we parse, so we synthesize a stub object whose only useful field is `name`. Tests just need `nodeType == 10`.



684
685
686
687
688
# File 'lib/dommy/document.rb', line 684

def doctype
  return nil if @doctype_removed

  @doctype ||= DocumentType.new("html", owner_document: self)
end

#document_elementObject



359
360
361
362
# File 'lib/dommy/document.rb', line 359

def document_element
  # The document's root element — `<html>` for HTML, the actual root for XML.
  wrap_node(@nokogiri_doc.root)
end

#document_insert(args, prepend:) ⇒ Object

ParentNode / Node mutation on the document’s direct children (the doctype and the document element). Operate on the Nokogiri document node; string arguments (which would need a text child the document can’t hold) are ignored rather than raising.



711
712
713
714
715
716
717
718
719
# File 'lib/dommy/document.rb', line 711

def document_insert(args, prepend:)
  nodes = args.filter_map { |a| backend_node(a) }
  if prepend && (first = @nokogiri_doc.children.first)
    nodes.reverse_each { |n| first.add_previous_sibling(n) }
  else
    nodes.each { |n| @nokogiri_doc.add_child(n) }
  end
  nil
end

#document_insert_before(node, ref) ⇒ Object



739
740
741
742
743
744
745
746
747
748
749
750
# File 'lib/dommy/document.rb', line 739

def document_insert_before(node, ref)
  bn = backend_node(node)
  return node unless bn

  ref_node = ref && backend_node(ref)
  if ref_node && ref_node.parent == @nokogiri_doc
    ref_node.add_previous_sibling(bn)
  else
    @nokogiri_doc.add_child(bn)
  end
  node
end

#document_remove_child(node) ⇒ Object



727
728
729
730
731
732
733
734
735
736
737
# File 'lib/dommy/document.rb', line 727

def document_remove_child(node)
  # The doctype is synthesized from the Nokogiri DTD rather than wrapped as a
  # tree node, so remove the internal subset directly.
  return __internal_remove_doctype__(node) if node.is_a?(DocumentType)

  bn = backend_node(node)
  raise DOMException::NotFoundError, "node is not a child of this document" unless bn && bn.parent == @nokogiri_doc

  bn.unlink
  node
end

#document_replace_child(new_child, old_child) ⇒ Object



752
753
754
755
756
757
758
759
760
# File 'lib/dommy/document.rb', line 752

def document_replace_child(new_child, old_child)
  old_bn = backend_node(old_child)
  raise DOMException::NotFoundError, "node is not a child of this document" unless old_bn && old_bn.parent == @nokogiri_doc

  new_bn = backend_node(new_child)
  old_bn.add_previous_sibling(new_bn) if new_bn
  old_bn.unlink
  old_child
end

#document_replace_children(args) ⇒ Object



721
722
723
724
725
# File 'lib/dommy/document.rb', line 721

def document_replace_children(args)
  @nokogiri_doc.children.each(&:unlink)
  args.filter_map { |a| backend_node(a) }.each { |n| @nokogiri_doc.add_child(n) }
  nil
end

#domainObject

‘document.domain` — host portion of the URL. Real browsers restrict cross-origin reads of this; we just return the bare host.



422
423
424
425
426
427
# File 'lib/dommy/document.rb', line 422

def domain
  view = @default_view
  return "" unless view&.location

  view.location.__js_get__("hostname").to_s
end

#element_from_point(_x, _y) ⇒ Object



662
663
664
# File 'lib/dommy/document.rb', line 662

def element_from_point(_x, _y)
  nil
end

#exit_fullscreenObject Also known as: exitFullscreen



652
653
654
655
656
657
658
# File 'lib/dommy/document.rb', line 652

def exit_fullscreen
  return PromiseValue.resolve(@default_view, nil) if @fullscreen_element.nil?

  @fullscreen_element = nil
  dispatch_event(Event.new("fullscreenchange"))
  PromiseValue.resolve(@default_view, nil)
end

#first_element_childObject



492
493
494
# File 'lib/dommy/document.rb', line 492

def first_element_child
  wrap_node(@nokogiri_doc.root)
end

#formsObject



452
453
454
455
456
# File 'lib/dommy/document.rb', line 452

def forms
  HTMLCollection.new do
    @nokogiri_doc.css("form").map { |n| wrap_node(n) }.compact
  end
end

#get_element_by_id(id) ⇒ Object



1276
1277
1278
# File 'lib/dommy/document.rb', line 1276

def get_element_by_id(id)
  @node_wrapper_cache.get_element_by_id(id)
end

#get_elements_by_class_name(name) ⇒ Object



868
869
870
# File 'lib/dommy/document.rb', line 868

def get_elements_by_class_name(name)
  @node_wrapper_cache.get_elements_by_class_name(name)
end

#get_elements_by_name(name) ⇒ Object



814
815
816
# File 'lib/dommy/document.rb', line 814

def get_elements_by_name(name)
  @node_wrapper_cache.get_elements_by_name(name)
end

#get_elements_by_tag_name(name) ⇒ Object



810
811
812
# File 'lib/dommy/document.rb', line 810

def get_elements_by_tag_name(name)
  @node_wrapper_cache.get_elements_by_tag_name(name)
end

#get_elements_by_tag_name_ns(namespace, local_name) ⇒ Object



818
819
820
# File 'lib/dommy/document.rb', line 818

def get_elements_by_tag_name_ns(namespace, local_name)
  HTMLCollection.elements_by_tag_name_ns(@nokogiri_doc, self, namespace, local_name)
end

#get_selectionObject



631
632
633
# File 'lib/dommy/document.rb', line 631

def get_selection
  @__selection ||= Selection.new(self)
end

#has_focus?Boolean Also known as: has_focus

Stubs for layout / focus / selection / execCommand APIs that don’t apply to a layout-less DOM. They exist so callers don’t hit NoMethodError; semantics are documented as no-op.

Returns:

  • (Boolean)


625
626
627
# File 'lib/dommy/document.rb', line 625

def has_focus?
  true
end

#has_template_content?(nokogiri_node) ⇒ Boolean

Returns:

  • (Boolean)


1298
1299
1300
# File 'lib/dommy/document.rb', line 1298

def has_template_content?(nokogiri_node)
  @template_content_registry.has_content?(nokogiri_node)
end

#headObject



364
365
366
# File 'lib/dommy/document.rb', line 364

def head
  wrap_node(@nokogiri_doc.at_css("head"))
end

#html_document?Boolean

Whether this is an “HTML document” in the DOM sense (created by the HTML parser / ‘text/html`), as opposed to an XML document. It drives the case-folding rules: `createElement` lowercases names and `Element#tagName` uppercases HTML-namespace names only in an HTML document. An XML or XHTML document (e.g. an `application/xhtml+xml` / `text/xml` resource) preserves case.

Returns:

  • (Boolean)


333
334
335
# File 'lib/dommy/document.rb', line 333

def html_document?
  @content_type == "text/html"
end

#imagesObject



464
465
466
467
468
# File 'lib/dommy/document.rb', line 464

def images
  HTMLCollection.new do
    @nokogiri_doc.css("img").map { |n| wrap_node(n) }.compact
  end
end

#implementationObject



690
691
692
# File 'lib/dommy/document.rb', line 690

def implementation
  @implementation ||= DOMImplementation.new(self)
end

#import_node(node, deep = false) ⇒ Object

Copy a node from another document into this one. The returned wrapper is owned by ‘this`. Per spec, the source node is left in place. `deep: true` copies the entire subtree.



562
563
564
565
566
567
# File 'lib/dommy/document.rb', line 562

def import_node(node, deep = false)
  return nil unless node.respond_to?(:__dommy_backend_node__)

  copy = clone_into_doc(node.__dommy_backend_node__, deep)
  wrap_node(copy)
end

#last_element_childObject



496
497
498
# File 'lib/dommy/document.rb', line 496

def last_element_child
  wrap_node(@nokogiri_doc.root)
end

Live HTMLCollection helpers — each call re-queries the document so post-mutation reads reflect the current state.



446
447
448
449
450
# File 'lib/dommy/document.rb', line 446

def links
  HTMLCollection.new do
    @nokogiri_doc.css("a[href], area[href]").map { |n| wrap_node(n) }.compact
  end
end

#migrate_template_descendants(root) ⇒ Object



1294
1295
1296
# File 'lib/dommy/document.rb', line 1294

def migrate_template_descendants(root)
  @template_content_registry.migrate_descendants(root)
end

#normalize_filter(value) ⇒ Object

A ‘null`/`undefined` filter argument means “no filter”.



552
553
554
555
556
557
# File 'lib/dommy/document.rb', line 552

def normalize_filter(value)
  return nil if value.nil?
  return nil if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)

  value
end

#notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil) ⇒ Object



1242
1243
1244
1245
1246
1247
1248
1249
# File 'lib/dommy/document.rb', line 1242

def notify_attribute_mutation(target_node:, attribute_name:, old_value:, namespace: nil)
  @mutation_coordinator.notify_attribute_mutation(
    target_node: target_node,
    attribute_name: attribute_name,
    old_value: old_value,
    namespace: namespace
  )
end

#notify_character_data_mutation(target_node:, old_value:) ⇒ Object



1251
1252
1253
1254
1255
1256
# File 'lib/dommy/document.rb', line 1251

def notify_character_data_mutation(target_node:, old_value:)
  @mutation_coordinator.notify_character_data_mutation(
    target_node: target_node,
    old_value: old_value
  )
end

#notify_child_list_mutation(target_node:, added_nodes:, removed_nodes:, previous_sibling: nil, next_sibling: nil) ⇒ Object



1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
# File 'lib/dommy/document.rb', line 1195

def notify_child_list_mutation(
  target_node:,
  added_nodes:,
  removed_nodes:,
  previous_sibling: nil,
  next_sibling: nil
)
  @mutation_coordinator.notify_child_list_mutation(
    target_node: target_node,
    added_nodes: added_nodes,
    removed_nodes: removed_nodes,
    previous_sibling: previous_sibling,
    next_sibling: next_sibling
  )
end

#openObject

No-ops — real browsers reset the DOM on ‘open()` and flush pending writes on `close()`. We don’t model the parse pipeline.



838
839
840
# File 'lib/dommy/document.rb', line 838

def open
  nil
end

#originObject

‘document.origin` — serialized origin of the document URL, mirroring `window.location.origin`. Empty when there is no associated window.



431
432
433
434
435
436
# File 'lib/dommy/document.rb', line 431

def origin
  view = @default_view
  return "" unless view&.location

  view.location.__js_get__("origin").to_s
end

#query_command_supported(_command) ⇒ Object



666
667
668
# File 'lib/dommy/document.rb', line 666

def query_command_supported(_command)
  false
end

#query_selector(selector) ⇒ Object



1268
1269
1270
# File 'lib/dommy/document.rb', line 1268

def query_selector(selector)
  @node_wrapper_cache.query_selector(selector)
end

#query_selector_all(selector) ⇒ Object



1272
1273
1274
# File 'lib/dommy/document.rb', line 1272

def query_selector_all(selector)
  @node_wrapper_cache.query_selector_all(selector)
end

#referrerObject

‘document.referrer` — Dommy never has a referring page, so this is always empty.



440
441
442
# File 'lib/dommy/document.rb', line 440

def referrer
  ""
end

#register_observer(observer) ⇒ Object



1187
1188
1189
# File 'lib/dommy/document.rb', line 1187

def register_observer(observer)
  @mutation_coordinator.register_observer(observer)
end

#remove_node_with_notify(node) ⇒ Object

Unlink a backend node from its parent and queue a childList removal record capturing the node’s position (previous/next sibling) BEFORE the unlink, so the record’s previousSibling/nextSibling are correct (the coordinator can’t recover them once the node is detached). Used by every remove path.



1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
# File 'lib/dommy/document.rb', line 1215

def remove_node_with_notify(node)
  parent = node.parent
  return unless parent

  prev_w = node.previous_sibling && wrap_node(node.previous_sibling)
  next_w = node.next_sibling && wrap_node(node.next_sibling)
  run_node_iterator_pre_remove(node)
  node.unlink
  notify_child_list_mutation(
    target_node: parent,
    added_nodes: [],
    removed_nodes: [node],
    previous_sibling: prev_w,
    next_sibling: next_w
  )
end

#run_node_iterator_pre_remove(backend_node) ⇒ Object

Run the “NodeIterator pre-removing steps” for every live iterator before ‘backend_node` is detached, so referenceNode/pointerBeforeReferenceNode stay valid. `backend_node` must still be attached (tree intact) here.



1235
1236
1237
1238
1239
1240
# File 'lib/dommy/document.rb', line 1235

def run_node_iterator_pre_remove(backend_node)
  return if @node_iterators.empty?

  removed = wrap_node(backend_node)
  @node_iterators.each { |iter| iter.pre_remove(removed) }
end

#scriptsObject



458
459
460
461
462
# File 'lib/dommy/document.rb', line 458

def scripts
  HTMLCollection.new do
    @nokogiri_doc.css("script").map { |n| wrap_node(n) }.compact
  end
end

#template_content_fragment(template_element) ⇒ Object



1286
1287
1288
# File 'lib/dommy/document.rb', line 1286

def template_content_fragment(template_element)
  @template_content_registry.fragment_for(template_element)
end

#template_content_inner_html(template_element) ⇒ Object



1290
1291
1292
# File 'lib/dommy/document.rb', line 1290

def template_content_inner_html(template_element)
  @template_content_registry.inner_html_of(template_element)
end

#titleObject

—– Public Ruby API (snake_case) —–



351
352
353
# File 'lib/dommy/document.rb', line 351

def title
  read_title
end

#title=(value) ⇒ Object



355
356
357
# File 'lib/dommy/document.rb', line 355

def title=(value)
  write_title(value.to_s)
end

#to_htmlObject

Serialize the whole document to HTML (including the doctype).



378
379
380
# File 'lib/dommy/document.rb', line 378

def to_html
  @nokogiri_doc.to_html
end

#unregister_observer(observer) ⇒ Object



1191
1192
1193
# File 'lib/dommy/document.rb', line 1191

def unregister_observer(observer)
  @mutation_coordinator.unregister_observer(observer)
end

#urlObject Also known as: document_uri

‘document.URL` / `documentURI` — both return location.href in real browsers (legacy aliases of the same field).



394
395
396
397
# File 'lib/dommy/document.rb', line 394

def url
  view = @default_view
  view&.location ? view.location.href : ""
end

#wrap_node(node) ⇒ Object

Delegate node wrapping to NodeWrapperCache



1133
1134
1135
# File 'lib/dommy/document.rb', line 1133

def wrap_node(node)
  @node_wrapper_cache.wrap(node)
end

#write(*args) ⇒ Object

‘document.write(html)` — legacy API. Appends parsed nodes to the body. Real browsers only re-stream the DOM during initial parse; this stub is enough for tests that fire write() during teardown.



825
826
827
828
829
830
831
832
833
834
# File 'lib/dommy/document.rb', line 825

def write(*args)
  html = args.join
  fragment = Parser.fragment(html, owner_doc: @nokogiri_doc)
  removed = []
  added = fragment.children.to_a
  body_node = body.__dommy_backend_node__
  added.each { |node| body_node.add_child(node) }
  notify_child_list_mutation(target_node: body_node, added_nodes: added, removed_nodes: removed)
  nil
end

#xpath(expression) ⇒ Object



388
389
390
# File 'lib/dommy/document.rb', line 388

def xpath(expression)
  @nokogiri_doc.xpath(expression).map { |node| wrap_node(node) }
end