dommy 0.14.0 → 0.15.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 (123) 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 +255 -213
  5. data/lib/dommy/blob.rb +7 -0
  6. data/lib/dommy/bridge/methods.rb +12 -2
  7. data/lib/dommy/browser.rb +4 -4
  8. data/lib/dommy/callable_invoker.rb +27 -31
  9. data/lib/dommy/character_data.rb +1 -1
  10. data/lib/dommy/class_list.rb +31 -14
  11. data/lib/dommy/css.rb +3 -0
  12. data/lib/dommy/custom_elements.rb +11 -15
  13. data/lib/dommy/document.rb +199 -158
  14. data/lib/dommy/element.rb +223 -499
  15. data/lib/dommy/event.rb +7 -29
  16. data/lib/dommy/event_source.rb +4 -4
  17. data/lib/dommy/fetch.rb +8 -12
  18. data/lib/dommy/form_data.rb +13 -10
  19. data/lib/dommy/form_entry_list.rb +5 -5
  20. data/lib/dommy/fragment.rb +3 -6
  21. data/lib/dommy/html_canvas_element.rb +1 -1
  22. data/lib/dommy/html_collection.rb +27 -20
  23. data/lib/dommy/html_elements/base.rb +279 -15
  24. data/lib/dommy/html_elements/embedded.rb +2 -2
  25. data/lib/dommy/html_elements/forms/form.rb +25 -23
  26. data/lib/dommy/html_elements/forms/input.rb +21 -18
  27. data/lib/dommy/html_elements/forms/select.rb +7 -7
  28. data/lib/dommy/html_elements/interactive.rb +11 -8
  29. data/lib/dommy/html_elements/media.rb +2 -2
  30. data/lib/dommy/html_elements/metadata.rb +14 -10
  31. data/lib/dommy/html_elements/tables.rb +13 -33
  32. data/lib/dommy/html_elements.rb +5 -2
  33. data/lib/dommy/hyperlink_utils.rb +4 -4
  34. data/lib/dommy/interaction/field_interactor.rb +1 -1
  35. data/lib/dommy/interaction/form_submission.rb +2 -2
  36. data/lib/dommy/interaction/key_sender.rb +1 -1
  37. data/lib/dommy/internal/accessibility_tree.rb +4 -4
  38. data/lib/dommy/internal/accessibility_visibility.rb +2 -2
  39. data/lib/dommy/internal/accessible_description.rb +2 -2
  40. data/lib/dommy/internal/accessible_name.rb +23 -28
  41. data/lib/dommy/internal/an_plus_b.rb +125 -0
  42. data/lib/dommy/internal/aria_role.rb +26 -17
  43. data/lib/dommy/internal/aria_state.rb +7 -7
  44. data/lib/dommy/internal/backend_prefilter.rb +20 -13
  45. data/lib/dommy/internal/child_list.rb +75 -0
  46. data/lib/dommy/internal/complex_match.rb +127 -0
  47. data/lib/dommy/internal/css/cascaded_declarations.rb +10 -19
  48. data/lib/dommy/internal/css/computed_style_builder.rb +22 -1
  49. data/lib/dommy/internal/css/custom_properties.rb +22 -51
  50. data/lib/dommy/internal/css/parser.rb +53 -41
  51. data/lib/dommy/internal/css/rule_index.rb +23 -12
  52. data/lib/dommy/internal/css/style_cache.rb +2 -2
  53. data/lib/dommy/internal/css/ua_stylesheet.rb +3 -2
  54. data/lib/dommy/internal/css_source.rb +223 -0
  55. data/lib/dommy/internal/css_syntax.rb +153 -0
  56. data/lib/dommy/internal/directionality.rb +2 -2
  57. data/lib/dommy/internal/document_generations.rb +38 -18
  58. data/lib/dommy/internal/dom_matching.rb +5 -5
  59. data/lib/dommy/internal/element_aria.rb +59 -23
  60. data/lib/dommy/internal/element_css_inline_style.rb +26 -0
  61. data/lib/dommy/internal/element_editing.rb +41 -0
  62. data/lib/dommy/internal/element_nonce.rb +28 -0
  63. data/lib/dommy/internal/element_popover.rb +109 -0
  64. data/lib/dommy/internal/element_shadow.rb +13 -10
  65. data/lib/dommy/internal/element_state.rb +35 -23
  66. data/lib/dommy/internal/element_top_layer.rb +6 -50
  67. data/lib/dommy/internal/html_or_svg_or_mathml_element.rb +75 -0
  68. data/lib/dommy/internal/infra.rb +27 -0
  69. data/lib/dommy/internal/input_type.rb +16 -4
  70. data/lib/dommy/internal/literal_lookup.rb +103 -0
  71. data/lib/dommy/internal/mutation_coordinator.rb +3 -1
  72. data/lib/dommy/internal/node_adopter.rb +113 -93
  73. data/lib/dommy/internal/node_factory.rb +7 -49
  74. data/lib/dommy/internal/node_traversal.rb +12 -13
  75. data/lib/dommy/internal/node_wrapper_cache.rb +29 -111
  76. data/lib/dommy/internal/observable_callback.rb +2 -5
  77. data/lib/dommy/internal/parent_node.rb +2 -2
  78. data/lib/dommy/internal/post_insertion_steps.rb +7 -7
  79. data/lib/dommy/internal/reflected_attributes.rb +45 -25
  80. data/lib/dommy/internal/rendered_text/collector.rb +11 -4
  81. data/lib/dommy/internal/selector_arguments.rb +0 -15
  82. data/lib/dommy/internal/selector_index.rb +19 -10
  83. data/lib/dommy/internal/selector_match.rb +190 -0
  84. data/lib/dommy/internal/selector_matcher.rb +80 -250
  85. data/lib/dommy/internal/selector_parser.rb +162 -99
  86. data/lib/dommy/internal/shadow_root_registry.rb +7 -6
  87. data/lib/dommy/internal/sibling_positions.rb +59 -0
  88. data/lib/dommy/internal/svg_uri_reference.rb +33 -0
  89. data/lib/dommy/internal/template_content_registry.rb +4 -4
  90. data/lib/dommy/internal/web_idl.rb +11 -0
  91. data/lib/dommy/internal/window_constructors.rb +8 -8
  92. data/lib/dommy/internal/xml_serialization.rb +6 -42
  93. data/lib/dommy/js/dom_interfaces.rb +1 -0
  94. data/lib/dommy/js/host_callbacks.rb +10 -0
  95. data/lib/dommy/js/host_runtime.js +24 -1
  96. data/lib/dommy/js/module_loader.rb +12 -2
  97. data/lib/dommy/js/module_preload.rb +116 -0
  98. data/lib/dommy/js/runtime.rb +9 -0
  99. data/lib/dommy/js/script_boot.rb +6 -2
  100. data/lib/dommy/js/webidl_tables.js +25 -1
  101. data/lib/dommy/mathml_elements.rb +13 -0
  102. data/lib/dommy/mutation_observer.rb +1 -7
  103. data/lib/dommy/navigation.rb +2 -2
  104. data/lib/dommy/navigator.rb +1 -5
  105. data/lib/dommy/node.rb +63 -34
  106. data/lib/dommy/notification.rb +1 -5
  107. data/lib/dommy/parser.rb +5 -16
  108. data/lib/dommy/promise.rb +12 -26
  109. data/lib/dommy/range.rb +15 -13
  110. data/lib/dommy/shadow_root.rb +8 -6
  111. data/lib/dommy/streams.rb +2 -6
  112. data/lib/dommy/style_declaration.rb +22 -6
  113. data/lib/dommy/svg_elements.rb +25 -11
  114. data/lib/dommy/tree_walker.rb +2 -8
  115. data/lib/dommy/url.rb +1 -5
  116. data/lib/dommy/url_pattern.rb +1 -9
  117. data/lib/dommy/validity_state.rb +1 -1
  118. data/lib/dommy/version.rb +1 -1
  119. data/lib/dommy/web_socket.rb +5 -5
  120. data/lib/dommy/window.rb +6 -6
  121. data/lib/dommy.rb +8 -9
  122. metadata +20 -4
  123. data/lib/dommy/backend/makiri_adapter.rb +0 -408
data/lib/dommy/backend.rb CHANGED
@@ -1,320 +1,362 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "makiri"
4
+
3
5
  module Dommy
4
- # `Dommy::Backend` — pluggable HTML parser abstraction. Lets Dommy
5
- # work with Makiri (Lexbor-based, HTML5-only). Internally, all DOM library
6
- # code goes through this facade rather than referencing the parser
7
- # directly.
8
- #
9
- # Defaults to Makiri.
10
- #
11
- # Switching backends:
12
- #
13
- # require "dommy"
14
- # Dommy::Backend.use(:makiri)
15
- #
16
- # Or set directly:
17
- #
18
- # Dommy::Backend.current = Dommy::Backend::Makiri
6
+ # `Dommy::Backend` — what the DOM asks of Makiri, the Lexbor-based parser
7
+ # that holds every tree. All DOM library code comes through here rather
8
+ # than reading Makiri's API directly, so what Dommy relies on of it is in
9
+ # one place: the node classes, the creators, the namespaced attribute
10
+ # model, and the few normalizations (an empty namespace is none).
19
11
  #
20
- # All adapters must implement the same interface — see
21
- # `Backend::Makiri` for the canonical reference.
12
+ # Makiri splits its document model into `Makiri::HTML::Document`
13
+ # (case-folding, html/head/body) and `Makiri::XML::Document`
14
+ # (case-preserving, namespaces, CDATA); both share the `Makiri::Document` /
15
+ # `Makiri::Node` bases used here for `is_a?` checks. HTML parses go through
16
+ # HTML::Document; `new Document()` / createDocument go through XML::Document.
22
17
  module Backend
23
- class BackendNotAvailable < StandardError
24
- end
25
-
26
18
  class << self
27
- def current
28
- @current ||= detect_default
29
- end
19
+ # The node classes (the shared bases, so both HTML and XML node
20
+ # subclasses match), so the wrapper cache can route each node.
21
+ def element_class = ::Makiri::Element
30
22
 
31
- attr_writer :current
23
+ def document_class = ::Makiri::Document
32
24
 
33
- def use(name)
34
- @current = case name.to_sym
35
- when :makiri
36
- require_relative "backend/makiri_adapter"
37
- Makiri
38
- else
39
- raise ArgumentError, "Unknown backend: #{name.inspect}. Use :makiri."
40
- end
41
- end
25
+ def text_class = ::Makiri::Text
26
+
27
+ def comment_class = ::Makiri::Comment
28
+
29
+ # A Text subtype, matched before Text.
30
+ def cdata_class = ::Makiri::CDATASection
31
+
32
+ def processing_instruction_class = ::Makiri::ProcessingInstruction
33
+
34
+ def document_fragment_class = ::Makiri::DocumentFragment
42
35
 
43
- # Stable per-document identity key for a backend node, used to cache DOM
44
- # wrappers and key per-node side tables. Makiri mints a fresh wrapper per traversal
45
- # but never frees nodes (arena-owned), so it keys on the stable node pointer.
36
+ def document_type_class = ::Makiri::DocumentType
37
+
38
+ def node_class = ::Makiri::Node
39
+
40
+ # What Makiri raises for markup that is not well-formed XML (an XML
41
+ # document's fragment parse included).
42
+ def xml_syntax_error_class = ::Makiri::XML::SyntaxError
43
+
44
+ # Stable per-document identity key for a backend node, used to key
45
+ # per-node side tables. pointer_id is the underlying lxb_dom_node_t
46
+ # pointer: Makiri detaches but never frees nodes — the document arena
47
+ # owns them — so a live node's pointer is never recycled; a freed
48
+ # document's may be, so a table keyed by it is a document's own.
46
49
  def identity_key(node)
47
- current.identity_key(node)
50
+ node.pointer_id
48
51
  end
49
52
 
50
53
  # Deep (or shallow) copy of an element/node, detached and owned by the
51
- # same document — the backing for DOM cloneNode.
54
+ # same document — the backing for DOM cloneNode. Makiri clones natively
55
+ # (import_node + template fixup), preserving the node's namespace and
56
+ # attributes and carrying <template> contents.
52
57
  def clone_node(node, deep:)
53
- current.clone_node(node, deep: deep)
58
+ node.clone_node(deep)
54
59
  end
55
60
 
56
- # A fresh, empty HTML-backed document.
61
+ # A fresh, empty HTML-backed document — no children, no-quirks. The
62
+ # backing for a shallow clone of an HTML document.
57
63
  def empty_document
58
- current.empty_document
64
+ ::Makiri::HTML::Document.new
59
65
  end
60
66
 
61
- # A fresh, empty XML-backed document — for `new Document()` / createDocument,
62
- # which the DOM defines as XML documents (case-preserving, real CDATA
63
- # nodeType, namespaces).
67
+ # A fresh, empty XML-backed document — the backing for `new Document()` /
68
+ # createDocument, which the DOM defines as XML documents. An XML backing
69
+ # gives them case preservation, real CDATA nodes (nodeType 4) and
70
+ # namespace tracking. Makiri's cross-kind `import_node` translates
71
+ # between the HTML and XML node representations, so a node created here
72
+ # can still be adopted into the main HTML tree (and vice versa).
64
73
  def empty_xml_document
65
- current.empty_xml_document
74
+ ::Makiri::XML::Document.new
66
75
  end
67
76
 
68
- # An empty backing document matching `doc`'s kind (HTML stays HTML, XML stays
69
- # XML) — for a shallow document clone, whose result keeps the source flavor.
77
+ # Whether the HTML parser left `doc` in quirks mode (not limited-quirks,
78
+ # which matches no-quirks everywhere Dommy asks). An XML document is
79
+ # never in it.
80
+ def quirks_mode?(doc)
81
+ doc.respond_to?(:quirks_mode?) && doc.quirks_mode?
82
+ end
83
+
84
+ # An empty backing document matching `doc`'s kind (HTML stays HTML, XML
85
+ # stays XML) — for a shallow document clone, whose result keeps the
86
+ # source flavor.
70
87
  def empty_document_like(doc)
71
- current.empty_document_like(doc)
88
+ doc.is_a?(::Makiri::XML::Document) ? empty_xml_document : empty_document
72
89
  end
73
90
 
74
91
  # Bring `node` (already detached from its old tree) into `target_doc`,
75
- # returning the backend node now owned by `target_doc`. Makiri can't move a node
76
- # between arenas, so it imports a copy — callers must reseat any wrapper
77
- # onto the returned node.
92
+ # returning the backend node now owned by `target_doc`. Lexbor's arenas
93
+ # can't move a node between documents, so adoption imports a detached
94
+ # copy — callers must reseat any wrapper onto the returned node.
95
+ #
96
+ # Gap absorption: Lexbor's HTML serializer has no CDATA case (it errors
97
+ # on a CDATA node) and Lexbor is a pinned upstream submodule, so Makiri's
98
+ # cross-kind import_node fails closed when bringing an XML CDATASection
99
+ # into an HTML document. Rather than let that surface as an error,
100
+ # degrade the node to a text node carrying the same data — the data a
101
+ # spec-faithful HTML serializer emits anyway (CDATASection is a Text
102
+ # subtype). The caller reseats the Dommy CDATASection wrapper onto this
103
+ # node, so `nodeType` stays 4 at the DOM level; only the backing node
104
+ # (and thus serialization) is text. An XML target keeps a real CDATA
105
+ # node. This handles a directly adopted CDATA node (a CDATA descendant
106
+ # inside an adopted element subtree is rare, untested, and still fails
107
+ # closed in the backend).
78
108
  def adopt(node, target_doc)
79
- current.adopt(node, target_doc)
109
+ if node.is_a?(::Makiri::CDATASection) && !target_doc.is_a?(::Makiri::XML::Document)
110
+ return target_doc.create_text_node(node.text)
111
+ end
112
+
113
+ target_doc.import_node(node, true)
80
114
  end
81
115
 
82
116
  # A copy of the element `node` alone, owned by `target_doc`: its name and
83
117
  # attributes exactly as the backend holds them.
84
118
  def import_element(node, target_doc)
85
- current.import_element(node, target_doc)
86
- end
87
-
88
- # Whether the backend can move a node between documents in place or must adopt a copy first
89
- # (Lexbor's arenas can't move a node, so inserting a foreign node requires importing
90
- # it). Lets callers skip a needless — and on an empty target, root-less and
91
- # therefore crashing — adoption on backends that don't need it.
92
- def moves_nodes_across_documents?
93
- current.respond_to?(:moves_nodes_across_documents?) ? current.moves_nodes_across_documents? : true
94
- end
95
-
96
- # CSS query that honors Dommy's custom pseudo-classes
97
- # (`:disabled`/`:enabled`/`:checked`/`:scope`). Each backend applies its
98
- # own mechanism (Lexbor native pseudos plus a
99
- # `:scope` rewrite). `scope_node` binds `:scope` to that element.
100
- def select_all(node, selector, scope_node: nil)
101
- current.select_all(node, selector, scope_node: scope_node)
102
- end
103
-
104
- def select_first(node, selector, scope_node: nil)
105
- current.select_first(node, selector, scope_node: scope_node)
119
+ target_doc.import_node(node, false)
106
120
  end
107
121
 
108
- # Delegate calls so internal code can use `Backend.parse(...)`.
109
122
  def parse(html)
110
- current.parse(html)
123
+ ::Makiri::HTML::Document.parse(html.to_s)
111
124
  end
112
125
 
113
- # Parse XML input into an XML document.
114
- # fall back to the HTML parser.
126
+ # XML parse (DOMParser `text/xml` / `application/xml`): a real XML
127
+ # document, so element/attribute case is preserved, namespaces are
128
+ # tracked, and CDATA round-trips.
115
129
  def parse_xml(xml)
116
- current.parse_xml(xml)
130
+ ::Makiri::XML::Document.parse(xml.to_s)
117
131
  end
118
132
 
119
133
  def fragment(html, owner_doc:)
120
- current.fragment(html, owner_doc: owner_doc)
121
- end
122
-
123
- # Make `node` the sole document element of `doc` (used by
124
- # DOMImplementation.createDocument). Lexbor-backed documents are seeded with an <html> shell
125
- # that must be cleared first.
126
- def set_document_root(doc, node)
127
- current.set_document_root(doc, node)
128
- end
129
-
130
- def create_element(name, doc)
131
- current.create_element(name, doc)
132
- end
133
-
134
- # Create a namespaced element permitting a DOM-valid qualified name that a
135
- # strict XML backend would reject (an internal invalid char like "f}oo"),
136
- # preserving case/prefix. Returns nil when the backend has no loose path
137
- # (fall back to #create_element); raises ArgumentError for a genuinely
138
- # invalid name (the caller maps it to InvalidCharacterError).
139
- def create_element_loose(qualified_name, prefix, local, namespace, doc)
140
- current.create_element_loose(qualified_name, prefix, local, namespace, doc)
141
- end
142
-
143
- # The element's own namespace URI as the DOM reports it: nil for no
144
- # namespace.
145
- def namespace_uri(node)
146
- current.namespace_uri(node)
147
- end
148
-
149
- # Create an element in `namespace` in an HTML document, so the backend
150
- # node carries the namespace and the name's case itself. Returns nil for
151
- # an XML document (fall back to #create_element); raises ArgumentError
152
- # for an invalid name (the caller maps it to InvalidCharacterError).
134
+ ::Makiri::DocumentFragment.parse(html.to_s)
135
+ end
136
+
137
+ # The DOM's createElement: an element whose local name is `local_name`
138
+ # as written, colons included (`foo:` and `f::oo` are valid local names,
139
+ # not prefixed ones), in `namespace` — the HTML namespace in an HTML or
140
+ # XHTML document, nil in any other. An XML document's own creator parses
141
+ # a QName, so its element is built from the parts. An HTML-backed one —
142
+ # which a document typed as XHTML or XML after an HTML parse also is —
143
+ # makes it by createElementNS, which keeps the name's case and the
144
+ # namespace given; that splits a name at its colon, and refuses `xmlns`
145
+ # outside the XMLNS namespace, as createElementNS must — so a name with
146
+ # a colon, or `xmlns`, takes the HTML document's creator, which keeps
147
+ # the name as written but folds it into the HTML namespace in lower case.
148
+ def create_element(local_name, namespace, doc)
149
+ return doc.create_loose_dom_element(local_name, nil, local_name, namespace) unless doc.is_a?(::Makiri::HTML::Document)
150
+ return doc.create_element(local_name) if local_name.include?(":") || local_name == "xmlns"
151
+
152
+ doc.create_element_ns(namespace, local_name)
153
+ end
154
+
155
+ # The DOM's createElementNS, in an HTML or an XML document alike: the
156
+ # element is in `namespace` (nil for none) with `qualified_name` as
157
+ # written — an SVG `feGaussianBlur` keeps its case, a prefixed name its
158
+ # prefix. Makiri checks the name as the DOM does, so a name the caller
159
+ # has validated is never refused; it raises ArgumentError for one it
160
+ # would not have passed.
153
161
  def create_element_ns(namespace, qualified_name, doc)
154
- current.create_element_ns(namespace, qualified_name, doc)
162
+ doc.create_element_ns(namespace, qualified_name.to_s)
155
163
  end
156
164
 
157
- # A detached DocumentType node owned by `doc` (for
158
- # DOMImplementation.createDocumentType). Returns nil when the backend has no
159
- # doctype factory (the caller falls back to a synthetic DocumentType); raises
160
- # ArgumentError for a name the factory rejects (the caller then also falls
161
- # back, since createDocumentType is permissive).
165
+ # A detached DocumentType node owned by `doc`, HTML or XML (for
166
+ # DOMImplementation.createDocumentType). Makiri takes any name the DOM
167
+ # does but the empty one, which Lexbor reads as no name, and raises
168
+ # ArgumentError for it; the caller then falls back to a synthetic
169
+ # (non-tree) DocumentType, since createDocumentType is permissive.
162
170
  def create_document_type(name, public_id, system_id, doc)
163
- current.respond_to?(:create_document_type) ? current.create_document_type(name, public_id, system_id, doc) : nil
171
+ doc.create_document_type(name.to_s, public_id.to_s, system_id.to_s)
164
172
  end
165
173
 
166
- # The parsed document's DocumentType node, or nil when it declares none.
174
+ # The parsed document's DocumentType node (`<!DOCTYPE …>`), or nil when
175
+ # the document declares none.
167
176
  def internal_subset(doc)
168
- current.respond_to?(:internal_subset) ? current.internal_subset(doc) : nil
169
- end
170
-
171
- # The backend class for a DocumentType node (nil if unsupported), so the
172
- # wrapper cache can route it to Dommy::DocumentType.
173
- def document_type_class
174
- current.respond_to?(:document_type_class) ? current.document_type_class : nil
177
+ doc.internal_subset
175
178
  end
176
179
 
177
180
  def create_text(content, doc)
178
- current.create_text(content, doc)
181
+ doc.create_text_node(content)
179
182
  end
180
183
 
181
184
  def create_comment(content, doc)
182
- current.create_comment(content, doc)
185
+ doc.create_comment(content)
183
186
  end
184
187
 
185
- # CDATA section node (XML documents). Backends without CDATA fall back to a
186
- # text node.
188
+ # CDATASection (nodeType 4). A genuine XML document — including a
189
+ # `new Document()` / createDocument document, XML-backed (see
190
+ # #empty_xml_document) — mints a real CDATA node and the XML serializer
191
+ # emits `<![CDATA[…]]>`. `Document#create_cdata_section` rejects HTML
192
+ # documents up front (NotSupportedError, per spec), so the text-node
193
+ # fallback below is a defensive guard for an HTML document that still
194
+ # slips through (Lexbor's HTML serializer raises on a native CDATA node,
195
+ # so a text node keeps serialization safe).
187
196
  def create_cdata(content, doc)
188
- current.respond_to?(:create_cdata) ? current.create_cdata(content, doc) : current.create_text(content, doc)
197
+ if doc.is_a?(::Makiri::XML::Document)
198
+ doc.create_cdata(content)
199
+ else
200
+ doc.create_text_node(content)
201
+ end
189
202
  end
190
203
 
191
- def cdata_class
192
- current.respond_to?(:cdata_class) ? current.cdata_class : nil
204
+ # ProcessingInstruction node (`<?target data?>`). Both Makiri document
205
+ # families mint a real PI node and serialize it, so PIs — unlike CDATA —
206
+ # need no HTML-document fallback.
207
+ def create_processing_instruction(target, data, doc)
208
+ doc.create_processing_instruction(target, data)
193
209
  end
194
210
 
195
- # ProcessingInstruction node (`<?target data?>`). Supported by every
196
- # backend Dommy ships (both Makiri document families), so — unlike CDATA —
197
- # there is no text-node fallback.
198
- def create_processing_instruction(target, data, doc)
199
- current.create_processing_instruction(target, data, doc)
211
+ # DOM "child text content": the data of `node`'s Text (and CDATA
212
+ # section) children, in order — no deeper descendant's.
213
+ def child_text_content(node)
214
+ node.children.select { |c| c.text? || c.cdata? }.map(&:content).join
200
215
  end
201
216
 
202
- def processing_instruction_class
203
- current.processing_instruction_class
217
+ # The element's own namespace URI as the DOM reports it (Lexbor's HTML /
218
+ # SVG / MathML, an XML document's own), nil for none — Makiri answers
219
+ # nil, never "", for a node without one, parsed or created.
220
+ def namespace_uri(node)
221
+ node.namespace_uri
204
222
  end
205
223
 
206
- # The element's in-scope namespace declarations (each responds to
207
- # `prefix`/`href`). Empty on backends without an XML namespace model.
208
- def namespace_definitions(node)
209
- current.namespace_definitions(node)
224
+ # The element's own namespace prefix as the DOM reports it, nil for none.
225
+ def prefix(node)
226
+ node.prefix
210
227
  end
211
228
 
212
- # The backend's own fragment holding a `<template>` element's contents
229
+ # Makiri's own fragment holding a `<template>` element's contents
213
230
  # (Lexbor keeps them off the child list), the same one every time; nil
214
231
  # for a node that has none — any node of an XML document, whose contents
215
232
  # the template-content registry keeps instead.
216
233
  def template_contents(node)
217
- current.template_contents(node)
234
+ node.respond_to?(:content_fragment) ? node.content_fragment : nil
218
235
  end
219
236
 
220
- def add_namespace_definition(node, prefix, href)
221
- current.add_namespace_definition(node, prefix, href)
222
- end
237
+ # ----- Namespaced attributes -----
238
+ # Lexbor tracks the attribute's own namespace: set_attribute_ns records
239
+ # it (splitting prefix/local), and the attr node reports
240
+ # namespace_uri/prefix/local_name. So *AttributeNS matches on
241
+ # (namespace, local name) faithfully, and Makiri finds an attribute by
242
+ # that pair natively. `namespace` is an href String or nil throughout,
243
+ # and Makiri takes "" as none, as the DOM does.
223
244
 
224
- # Namespaced attribute access (DOM *AttributeNS). `namespace` is an href
225
- # String or nil.
226
245
  def get_attribute_ns(node, namespace, local_name)
227
- current.get_attribute_ns(node, namespace, local_name)
246
+ node.attribute_value_ns(namespace, local_name.to_s)
228
247
  end
229
248
 
230
- def set_attribute_ns(node, namespace, prefix, local_name, qualified_name, value)
231
- current.set_attribute_ns(node, namespace, prefix, local_name, qualified_name, value)
249
+ def has_attribute_ns?(node, namespace, local_name)
250
+ !attr_by_ns(node, namespace, local_name).nil?
232
251
  end
233
252
 
234
- def remove_attribute_ns(node, namespace, local_name)
235
- current.remove_attribute_ns(node, namespace, local_name)
253
+ # The attribute node in `namespace` with `local_name`, or nil — what
254
+ # getAttributeNodeNS and the NS removals find.
255
+ def attr_by_ns(node, namespace, local_name)
256
+ node.attribute_node_ns(namespace, local_name.to_s)
236
257
  end
237
258
 
238
- def has_attribute_ns?(node, namespace, local_name)
239
- current.has_attribute_ns?(node, namespace, local_name)
259
+ def set_attribute_ns(node, namespace, prefix, _local_name, qualified_name, value)
260
+ none = namespace.to_s.empty?
261
+ note_namespaced_unprefixed_attribute if prefix.to_s.empty? && !none
262
+ name = qualified_name.to_s
263
+ value = value.to_s
264
+ node.set_attribute_ns(namespace, name, value) unless none && set_null_namespace_attribute(node, name, value)
265
+ value
266
+ end
267
+
268
+ # Whether an attribute in a namespace but with no prefix has ever been
269
+ # made (setAttributeNS("urn:x", "id")). That is the only way an
270
+ # attribute whose qualified name is a plain `id` or `class` can be in a
271
+ # namespace — a parser's are either prefixed or, `xmlns` aside, in none —
272
+ # so until one is made, no element need be asked whether it has one
273
+ # (Element#namespaced_unprefixed_attribute?, which gates the JS bridge's
274
+ # attribute snapshot). Process-wide, as an attribute moves between
275
+ # documents with its node; it only ever decides how fast an answer comes.
276
+ def note_namespaced_unprefixed_attribute
277
+ @namespaced_unprefixed_attribute = true
278
+ end
279
+
280
+ def namespaced_unprefixed_attribute?
281
+ @namespaced_unprefixed_attribute == true
282
+ end
283
+
284
+ # Remove by (namespace, local name) — removing by qualified name is
285
+ # ambiguous once same-name/different-namespace attributes coexist.
286
+ def remove_attribute_ns(node, namespace, local_name)
287
+ node.remove_attribute_ns(namespace, local_name.to_s)
288
+ nil
240
289
  end
241
290
 
242
291
  # Reads a backend attribute node into {namespace_uri:, prefix:,
243
292
  # local_name:, qualified_name:, value:} (namespace-aware).
244
293
  def attribute_ns_info(attr_node)
245
- current.attribute_ns_info(attr_node)
294
+ {
295
+ namespace_uri: attr_node.namespace_uri,
296
+ prefix: attr_node.prefix,
297
+ local_name: attr_node.local_name,
298
+ qualified_name: attr_node.name,
299
+ value: attr_node.value,
300
+ }
246
301
  end
247
302
 
248
303
  # The attribute node whose QUALIFIED name is `qualified_name`, or nil.
249
304
  #
250
305
  # WHATWG's by-name family ("get an attribute by name", `setAttribute`,
251
- # `removeAttribute`) matches on the qualified name. A lookup by local name
252
- # confuses `b` with a prefixed `xml:b`, so those paths must come through
253
- # here rather than through `node[name]`.
306
+ # `removeAttribute`) matches on the qualified name. A lookup by local
307
+ # name confuses `b` with a prefixed `xml:b`, so those paths must come
308
+ # through here rather than through `node[name]`. Makiri scans natively.
254
309
  def attr_by_qualified_name(node, qualified_name)
255
- current.attr_by_qualified_name(node, qualified_name)
310
+ node.attribute_by_qualified_name(qualified_name.to_s)
256
311
  end
257
312
 
258
- # That attribute's VALUE, or nil when there is no such attribute. The same
259
- # match without an attribute node in hand, for the two readers that only
260
- # ever wanted the value — `getAttribute` and `hasAttribute` — which run on
261
- # every CSS match and every reflected IDL attribute.
313
+ # That attribute's VALUE, or nil when there is no such attribute. The
314
+ # same match without an attribute node in hand, for the two readers that
315
+ # only ever wanted the value — `getAttribute` and `hasAttribute` — which
316
+ # run on every CSS match and every reflected IDL attribute.
262
317
  def attr_value_by_qualified_name(node, qualified_name)
263
- current.attr_value_by_qualified_name(node, qualified_name)
318
+ node.attribute_value_by_qualified_name(qualified_name.to_s)
319
+ end
320
+
321
+ # The value of `node`'s attribute named `local_name` in no namespace, or
322
+ # nil — what a selector's `[att]` and HTML's id, class and name read, on
323
+ # the hottest path in the library. One native read, which finds the
324
+ # attribute by namespace and local name, so a namespaced one that shares
325
+ # the name (setAttributeNS("u", "att"), a parsed `xmlns`) is not it.
326
+ def no_namespace_attribute_value(node, local_name)
327
+ node.attribute_value_ns(nil, local_name)
264
328
  end
265
329
 
266
330
  # The element's attribute nodes (each readable via attribute_ns_info).
267
331
  # The single choke point so DOM code doesn't touch parser internals.
268
332
  def attribute_nodes(node)
269
- current.attribute_nodes(node)
270
- end
271
-
272
- # Type constants — proxy through to the current backend so
273
- # `node.is_a?(Backend::Element)` resolves dynamically.
274
- def element_class
275
- current::Element
276
- end
277
-
278
- def document_class
279
- current::Document
280
- end
281
-
282
- # What the backend raises for markup that is not well-formed XML (an XML
283
- # document's fragment parse included).
284
- def xml_syntax_error_class
285
- current::XMLSyntaxError
286
- end
287
-
288
- def text_class
289
- current::Text
290
- end
291
-
292
- def comment_class
293
- current::Comment
294
- end
295
-
296
- def document_fragment_class
297
- current::DocumentFragment
298
- end
299
-
300
- def node_class
301
- current::Node
333
+ node.attribute_nodes
302
334
  end
303
335
 
304
336
  private
305
337
 
306
- def detect_default
307
- try_makiri ||
308
- raise(BackendNotAvailable, "Dommy requires either 'makiri' gem to be installed.")
309
- end
310
-
311
- def try_makiri
312
- require "makiri"
313
-
314
- require_relative "backend/makiri_adapter"
315
- Makiri
316
- rescue LoadError
317
- nil
338
+ # A null-namespace name that only the DOM's `setAttribute` can make: one
339
+ # whose local name holds a colon ("xlink:href", "v-on:click") or is
340
+ # "xmlns". Makiri checks set_attribute_ns as the DOM's setAttributeNS
341
+ # does, which refuses these (a prefix needs a namespace).
342
+ def set_attribute_only_name?(name)
343
+ name == "xmlns" || name.include?(":")
344
+ end
345
+
346
+ # Makiri's `set_loose_dom_attribute`, the DOM's setAttribute, for the
347
+ # setAttribute-only names, which set_attribute_ns refuses: a plain
348
+ # attribute in no namespace (never a namespace declaration) whose local
349
+ # name is the whole name, colon included, lower-cased only on an
350
+ # HTML-namespace element of an HTML document. true when it made the
351
+ # attribute; false leaves every other name to set_attribute_ns, which
352
+ # matches an existing attribute by (namespace, local name) rather than
353
+ # by qualified name: `setAttributeNS("u", "a")` then a null-namespace
354
+ # "a" are two attributes.
355
+ def set_null_namespace_attribute(node, name, value)
356
+ return false unless set_attribute_only_name?(name)
357
+
358
+ node.set_loose_dom_attribute(name, value)
359
+ true
318
360
  end
319
361
  end
320
362
  end
data/lib/dommy/blob.rb CHANGED
@@ -36,6 +36,13 @@ module Dommy
36
36
  @window = window
37
37
  end
38
38
 
39
+ # A File of the same bytes and type named `name`, as FormData's "create an
40
+ # entry" makes of a Blob it is given (named "blob") or of a File given a
41
+ # filename.
42
+ def __internal_file_named__(name)
43
+ File.new([@data], name, {"type" => @type}, @window)
44
+ end
45
+
39
46
  # Return a new Blob over a byte range of this one.
40
47
  # Negative indices are treated as offsets from the end (per spec).
41
48
  def slice(start = 0, last = @size, content_type = "")
@@ -19,7 +19,9 @@ module Dommy
19
19
  #
20
20
  # The per-class `JS_METHOD_NAMES` constant holds the class's OWN names; the
21
21
  # suite asserts it matches the class's own `__js_call__` `when` arms — see
22
- # test/test_js_call_dispatch_invariant.rb.
22
+ # test/test_js_call_dispatch_invariant.rb. A mixin module that answers
23
+ # methods of its own (HTMLOrSVGOrMathMLElement's focus / blur) sets the
24
+ # same constant on itself, and the classes it is included in expose them.
23
25
  module Methods
24
26
  def self.included(base)
25
27
  base.extend(ClassMethods)
@@ -49,7 +51,15 @@ module Dommy
49
51
  def js_method_names
50
52
  @js_method_names ||= begin
51
53
  inherited_names = superclass.respond_to?(:js_method_names) ? superclass.js_method_names : []
52
- (inherited_names + own_js_methods).uniq.freeze
54
+ (inherited_names + mixin_js_methods + own_js_methods).uniq.freeze
55
+ end
56
+ end
57
+
58
+ # The names the modules included in this class itself (not in an
59
+ # ancestor) declare.
60
+ def mixin_js_methods
61
+ (ancestors - superclass.ancestors).grep_v(Class).flat_map do |mod|
62
+ mod.const_defined?(:JS_METHOD_NAMES, false) ? mod::JS_METHOD_NAMES : []
53
63
  end
54
64
  end
55
65
 
data/lib/dommy/browser.rb CHANGED
@@ -298,7 +298,7 @@ module Dommy
298
298
  @runtime&.dispose
299
299
  # The JS engine is pluggable: `@backend` selects a registered runtime
300
300
  # (nil → the configured default, QuickJS when dommy-js-quickjs is loaded).
301
- runtime = Js.build_runtime(@backend)
301
+ runtime = Js::ModulePreload.build_runtime(window.document, @backend)
302
302
  # Every uncaught error the page produces goes through the window's
303
303
  # "report an exception" / "notify about rejected promises" funnel, and only
304
304
  # what the page left unhandled lands here. A page that installs its own
@@ -409,7 +409,7 @@ module Dommy
409
409
  return nil if name.empty? || RESERVED_TARGETS.include?(name.downcase)
410
410
 
411
411
  @window.document.query_selector_all("iframe").find do |frame|
412
- frame.get_attribute("name").to_s == name
412
+ frame.__internal_attribute_value__("name").to_s == name
413
413
  end
414
414
  end
415
415
 
@@ -463,9 +463,9 @@ module Dommy
463
463
  # reloads and is left alone to avoid a busy loop).
464
464
  def meta_refresh_target(document)
465
465
  document.query_selector_all("meta").each do |meta|
466
- next unless meta.get_attribute("http-equiv").to_s.casecmp?("refresh")
466
+ next unless meta.__internal_attribute_value__("http-equiv").to_s.casecmp?("refresh")
467
467
 
468
- _delay, separator, rest = meta.get_attribute("content").to_s.partition(";")
468
+ _delay, separator, rest = meta.__internal_attribute_value__("content").to_s.partition(";")
469
469
  next if separator.empty?
470
470
 
471
471
  url = rest.strip.sub(/\Aurl\s*=\s*/i, "").gsub(/\A["']|["']\z/, "").strip