dommy 0.8.1 → 0.10.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +57 -13
  5. data/lib/dommy/backend/makiri_adapter.rb +364 -0
  6. data/lib/dommy/backend.rb +143 -33
  7. data/lib/dommy/blob.rb +50 -6
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +420 -0
  10. data/lib/dommy/callable_invoker.rb +6 -2
  11. data/lib/dommy/compression_streams.rb +4 -0
  12. data/lib/dommy/crypto.rb +4 -0
  13. data/lib/dommy/css.rb +487 -50
  14. data/lib/dommy/custom_elements.rb +2 -2
  15. data/lib/dommy/data_transfer.rb +2 -0
  16. data/lib/dommy/data_uri.rb +35 -0
  17. data/lib/dommy/deferred_response.rb +59 -0
  18. data/lib/dommy/document.rb +901 -275
  19. data/lib/dommy/dom_exception.rb +2 -0
  20. data/lib/dommy/dom_parser.rb +7 -17
  21. data/lib/dommy/element.rb +940 -353
  22. data/lib/dommy/event.rb +529 -17
  23. data/lib/dommy/fetch.rb +577 -53
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +26 -3
  26. data/lib/dommy/html_canvas_element.rb +230 -0
  27. data/lib/dommy/html_collection.rb +150 -27
  28. data/lib/dommy/html_elements.rb +1968 -282
  29. data/lib/dommy/interaction/debug.rb +35 -0
  30. data/lib/dommy/interaction/dom_summary.rb +131 -0
  31. data/lib/dommy/interaction/driver.rb +385 -0
  32. data/lib/dommy/interaction/event_synthesis.rb +164 -0
  33. data/lib/dommy/interaction/field_interactor.rb +177 -0
  34. data/lib/dommy/interaction/form_submission.rb +268 -0
  35. data/lib/dommy/interaction/locator.rb +158 -0
  36. data/lib/dommy/interaction/role_query.rb +58 -0
  37. data/lib/dommy/interaction.rb +32 -0
  38. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  39. data/lib/dommy/internal/accessible_description.rb +38 -0
  40. data/lib/dommy/internal/accessible_name.rb +301 -0
  41. data/lib/dommy/internal/aria_role.rb +252 -0
  42. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  43. data/lib/dommy/internal/aria_state.rb +151 -0
  44. data/lib/dommy/internal/child_node.rb +199 -0
  45. data/lib/dommy/internal/css/calc.rb +242 -0
  46. data/lib/dommy/internal/css/cascade.rb +472 -0
  47. data/lib/dommy/internal/css/color.rb +381 -0
  48. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  49. data/lib/dommy/internal/css/counters.rb +227 -0
  50. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  51. data/lib/dommy/internal/css/media_query.rb +302 -0
  52. data/lib/dommy/internal/css/parser.rb +265 -0
  53. data/lib/dommy/internal/css/property_registry.rb +512 -0
  54. data/lib/dommy/internal/css/rule_index.rb +494 -0
  55. data/lib/dommy/internal/css/supports.rb +158 -0
  56. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  57. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  58. data/lib/dommy/internal/css_rule_text.rb +160 -0
  59. data/lib/dommy/internal/dom_matching.rb +80 -9
  60. data/lib/dommy/internal/element_matching.rb +109 -0
  61. data/lib/dommy/internal/global_functions.rb +55 -9
  62. data/lib/dommy/internal/mutation_coordinator.rb +122 -11
  63. data/lib/dommy/internal/namespaces.rb +49 -5
  64. data/lib/dommy/internal/node_wrapper_cache.rb +217 -38
  65. data/lib/dommy/internal/observer_manager.rb +6 -0
  66. data/lib/dommy/internal/observer_matcher.rb +6 -8
  67. data/lib/dommy/internal/parent_node.rb +98 -57
  68. data/lib/dommy/internal/selector_ast.rb +124 -0
  69. data/lib/dommy/internal/selector_index.rb +146 -0
  70. data/lib/dommy/internal/selector_matcher.rb +845 -0
  71. data/lib/dommy/internal/selector_parser.rb +321 -132
  72. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  73. data/lib/dommy/internal/template_content_registry.rb +26 -18
  74. data/lib/dommy/internal/xml_serialization.rb +344 -0
  75. data/lib/dommy/intersection_observer.rb +2 -0
  76. data/lib/dommy/js/bridge_conformance.rb +80 -0
  77. data/lib/dommy/js/constructor_resolver.rb +44 -0
  78. data/lib/dommy/js/custom_element_bridge.rb +101 -0
  79. data/lib/dommy/js/dom_interfaces.rb +177 -0
  80. data/lib/dommy/js/handle_table.rb +60 -0
  81. data/lib/dommy/js/host_bridge.rb +538 -0
  82. data/lib/dommy/js/host_runtime.js +2357 -0
  83. data/lib/dommy/js/import_map.rb +58 -0
  84. data/lib/dommy/js/marshaller.rb +240 -0
  85. data/lib/dommy/js/module_loader.rb +99 -0
  86. data/lib/dommy/js/observable_runtime.js +742 -0
  87. data/lib/dommy/js/runtime.rb +115 -0
  88. data/lib/dommy/js/script_boot.rb +301 -0
  89. data/lib/dommy/js/wire_tags.rb +62 -0
  90. data/lib/dommy/location.rb +78 -13
  91. data/lib/dommy/media_query_list.rb +50 -14
  92. data/lib/dommy/message_channel.rb +22 -6
  93. data/lib/dommy/minitest/assertions.rb +27 -0
  94. data/lib/dommy/mutation_observer.rb +89 -5
  95. data/lib/dommy/navigation.rb +263 -0
  96. data/lib/dommy/navigator.rb +34 -2
  97. data/lib/dommy/node.rb +198 -35
  98. data/lib/dommy/notification.rb +2 -0
  99. data/lib/dommy/parser.rb +1 -1
  100. data/lib/dommy/performance.rb +21 -1
  101. data/lib/dommy/promise.rb +94 -10
  102. data/lib/dommy/range.rb +187 -33
  103. data/lib/dommy/resources.rb +178 -0
  104. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  105. data/lib/dommy/scheduler.rb +165 -13
  106. data/lib/dommy/screen.rb +91 -0
  107. data/lib/dommy/shadow_root.rb +109 -13
  108. data/lib/dommy/storage.rb +63 -11
  109. data/lib/dommy/streams.rb +6 -0
  110. data/lib/dommy/text_codec.rb +7 -1
  111. data/lib/dommy/tree_walker.rb +51 -46
  112. data/lib/dommy/url.rb +17 -3
  113. data/lib/dommy/version.rb +1 -1
  114. data/lib/dommy/web_socket.rb +45 -2
  115. data/lib/dommy/window.rb +322 -15
  116. data/lib/dommy/worker.rb +8 -4
  117. data/lib/dommy/xml_http_request.rb +76 -9
  118. data/lib/dommy.rb +38 -1
  119. metadata +82 -14
  120. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  121. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2b9f635d1029acc7e1340657ba8440f4c4df5623e386389cd36718ef4dec95f
4
- data.tar.gz: 4b1d5aa007bf64ef0b2fdc20353d7511c4d74fec8ce54eb692a295dabfe28f05
3
+ metadata.gz: 52933caebd00a8d1b7dcfa14421116be45617e04d53ee6f8d8771c5fc5ccefdc
4
+ data.tar.gz: da2372f52dac931a6e7ad4316ec4cb15efb0effa799229eae7bde1435b8aa3c8
5
5
  SHA512:
6
- metadata.gz: fcf29d56432325690f2dd4f8ede45bd4f87daa439c26b0a040a08d4c1263383b073e9e5879b4451c36b30cf97921753dc43a5197685d120e3468fb02c64d1705
7
- data.tar.gz: 888bd534c0f1d4e53ebf4d875ed52d7fcf329efb410fa8ed9482caf772504fbe5a90d323bf568fce85c95456d3fe1e61e3f5cc49172e41a145fa8d477cffa2ab
6
+ metadata.gz: e05358f558f5e84fe6da7e7f705dbdc5bdd178646ef71a3ee9311a9d05893320cda1c0e6ca3596aa05e7e389e9112e96c8d44b640b7dea55480066af40e65eb7
7
+ data.tar.gz: ac0dea6fecd2fe7624f71cdef6f9eab786be60a584a4307d0fcac66540002de7cc1cc52812a638bfc8bb35afb53dd836ed7ebb2b007fee8fabfd172e62e8850a
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Dommy
2
2
 
3
- Dommy is a pure Ruby DOM polyfill built on [Nokogiri::HTML5](https://nokogiri.org/), inspired by happy-dom and jsdom.
3
+ Dommy is a pure Ruby DOM polyfill built on [Makiri](https://github.com/takahashim/makiri), inspired by happy-dom and jsdom.
4
4
  It gives Ruby tests a browser style DOM with events, MutationObserver, Custom Elements, Shadow DOM, the File API, timers, and Storage, without requiring a real browser.
5
5
 
6
6
  ## Quick start
@@ -170,8 +170,8 @@ end
170
170
 
171
171
  Supported Capybara-style options: `text:` / `exact:` / `count:` (Integer or Range) / `visible:` / `href:` / `with:` / `type:`. `wait:` is accepted and ignored (Dommy is synchronous).
172
172
 
173
- > [!CAUTION]
174
- > `:visible` is HTML-level only. Dommy has no CSS engine, so `display: none` set via a CSS class is **not** detected. Detection covers the `hidden` attribute, `<input type=hidden>`, non-rendering ancestors (`head`/`script`/`style`/`template`), and inline `style="display: none"` / `visibility: hidden`. If you toggle visibility through a CSS class, assert on the class instead (`have_dom_class("hidden")`) or keep that spec on Capybara + a real browser.
173
+ > [!NOTE]
174
+ > `:visible` understands CSS stylesheets when the `makiri` gem is available (its bundled lexbor provides the CSS parser the default backend already is makiri): `display: none` / `visibility: hidden` / `opacity: 0` set via a class in a `<style>` sheet is detected, on the element or an ancestor, alongside the HTML-level signals (`hidden` attribute, `<input type=hidden>`, non-rendering ancestors, inline styles). There is still **no layout engine**: geometry-dependent invisibility (zero size, off-screen, overlap) and media-query-conditional rules are not evaluated, and `getComputedStyle` returns computed (not used) values — `width: auto` stays `"auto"`. Without makiri, detection falls back to the HTML-level signals only.
175
175
 
176
176
  ## What's in scope
177
177
 
@@ -45,6 +45,8 @@ module Dommy
45
45
  case key
46
46
  when "target"
47
47
  @target
48
+ else
49
+ Bridge::ABSENT
48
50
  end
49
51
  end
50
52
 
@@ -206,6 +208,8 @@ module Dommy
206
208
  ready
207
209
  when "id"
208
210
  @id
211
+ else
212
+ Bridge::ABSENT
209
213
  end
210
214
  end
211
215
 
data/lib/dommy/attr.rb CHANGED
@@ -8,7 +8,7 @@ module Dommy
8
8
  #
9
9
  # We represent two states:
10
10
  # - "owned" — the Attr is attached to an Element. value reads/writes
11
- # go through the element's Nokogiri attribute slot.
11
+ # go through the element's Makiri attribute slot.
12
12
  # - "detached" — created via `document.createAttribute(name)` but
13
13
  # not yet attached. Value is stored locally; `setAttributeNode`
14
14
  # transfers it to an element.
@@ -22,9 +22,12 @@ module Dommy
22
22
  nil
23
23
  end
24
24
 
25
- def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil)
25
+ def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil, document: nil)
26
26
  qname = name.to_s
27
27
  @owner = owner
28
+ # The node document (for baseURI/ownerDocument when detached from an
29
+ # element). Owned attrs derive it from their owner instead.
30
+ @document = document
28
31
  @detached_value = value.to_s
29
32
  if namespace_uri && !namespace_uri.to_s.empty?
30
33
  # Namespaced attributes preserve case and carry prefix / localName.
@@ -33,11 +36,15 @@ module Dommy
33
36
  @prefix = prefix
34
37
  @local_name = (local_name || qname.split(":", 2).last).to_s
35
38
  else
36
- # Null-namespace (HTML) attributes are lower-cased, as before.
37
- @name = qname.downcase
39
+ # Null-namespace attribute: use the qualified name verbatim. The casing is
40
+ # already decided by the caller — the backend stores `setAttribute` names
41
+ # lower-cased (HTML) but preserves `setAttributeNS("", "FOO")` as "FOO",
42
+ # and `createAttribute` lower-cases up front — so re-downcasing here would
43
+ # wrongly fold a case-preserved null-namespace attribute.
44
+ @name = qname
38
45
  @namespace_uri = nil
39
46
  @prefix = nil
40
- @local_name = @name
47
+ @local_name = local_name ? local_name.to_s : @name
41
48
  end
42
49
  end
43
50
 
@@ -46,6 +53,22 @@ module Dommy
46
53
  @owner
47
54
  end
48
55
 
56
+ # Node.baseURI — the node document's base URL. Derived from the owner
57
+ # element when attached, else the document the attr was created in.
58
+ def base_uri
59
+ return @owner.base_uri if @owner.respond_to?(:base_uri)
60
+
61
+ @document&.base_uri
62
+ end
63
+
64
+ # Node.ownerDocument — the owner element's current document when attached (so
65
+ # it follows the element across adoptNode), else the creation document.
66
+ def owner_document
67
+ return @owner.document if @owner.respond_to?(:document)
68
+
69
+ @document
70
+ end
71
+
49
72
  def value
50
73
  if @owner
51
74
  if @namespace_uri
@@ -85,6 +108,8 @@ module Dommy
85
108
  value
86
109
  when "ownerElement"
87
110
  @owner
111
+ when "ownerDocument"
112
+ owner_document
88
113
  when "localName"
89
114
  @local_name
90
115
  when "namespaceURI"
@@ -93,9 +118,13 @@ module Dommy
93
118
  @prefix
94
119
  when "nodeType"
95
120
  2
121
+ when "baseURI"
122
+ base_uri
96
123
  when "specified"
97
124
  # Legacy/useless attribute — always true (WHATWG DOM).
98
125
  true
126
+ else
127
+ Bridge::ABSENT
99
128
  end
100
129
  end
101
130
 
@@ -113,12 +142,20 @@ module Dommy
113
142
  include Bridge::Methods
114
143
  js_methods %w[cloneNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition
115
144
  appendChild insertBefore removeChild replaceChild
145
+ lookupNamespaceURI lookupPrefix isDefaultNamespace
116
146
  addEventListener removeEventListener dispatchEvent]
117
147
  def __js_call__(method, args)
118
148
  case method
149
+ when "lookupNamespaceURI"
150
+ lookup_namespace_uri(args[0])
151
+ when "lookupPrefix"
152
+ lookup_prefix(args[0])
153
+ when "isDefaultNamespace"
154
+ is_default_namespace(args[0])
119
155
  when "cloneNode"
120
156
  Attr.new(@name, owner: nil, value: value,
121
- namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name)
157
+ namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name,
158
+ document: @document || (@owner.respond_to?(:document) ? @owner.document : nil))
122
159
  when "isSameNode"
123
160
  is_same_node(args[0])
124
161
  when "getRootNode"
@@ -162,7 +199,7 @@ module Dommy
162
199
  # `.getNamedItem(name)`, `.removeNamedItem(name)`, `.setNamedItem(attr)`,
163
200
  # plus property-style access (`attributes.id`, `attributes.class`).
164
201
  #
165
- # NamedNodeMap is *live* — it re-reads the element's Nokogiri
202
+ # NamedNodeMap is *live* — it re-reads the element's Makiri
166
203
  # attributes on every access so DOM mutations are reflected.
167
204
  class NamedNodeMap
168
205
  include Enumerable
@@ -203,7 +240,9 @@ module Dommy
203
240
  end
204
241
 
205
242
  def get_named_item(name)
206
- key = name.to_s.downcase
243
+ # getNamedItem / getAttribute lowercase the qualified name only for an HTML
244
+ # element in an HTML document; other elements match case-sensitively.
245
+ key = @element.__internal_normalize_attr_key__(name)
207
246
  node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
208
247
  Backend.attribute_ns_info(a)[:qualified_name] == key
209
248
  end
@@ -215,7 +254,7 @@ module Dommy
215
254
  end
216
255
 
217
256
  def remove_named_item(name)
218
- key = name.to_s.downcase
257
+ key = @element.__internal_normalize_attr_key__(name)
219
258
  node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
220
259
  Backend.attribute_ns_info(a)[:qualified_name] == key
221
260
  end
@@ -323,12 +362,17 @@ module Dommy
323
362
  end
324
363
  end
325
364
 
326
- # WebIDL "supported property names" for NamedNodeMap: the qualified name of
327
- # each attribute, in order (the indexed names are reflected separately).
365
+ # WebIDL "supported property names" for NamedNodeMap: each attribute's
366
+ # qualified name, in order with duplicates omitted (the indexed names are
367
+ # reflected separately). For an HTML element in an HTML document, names
368
+ # containing an ASCII upper alpha are excluded (they can't be reached by the
369
+ # case-insensitive named getter).
328
370
  def __js_named_props__
329
- Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
371
+ names = Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
330
372
  Backend.attribute_ns_info(a)[:qualified_name]
331
- end
373
+ end.uniq
374
+ names.reject! { |n| n.match?(/[A-Z]/) } unless @element.__internal_case_sensitive_attribute_names__?
375
+ names
332
376
  end
333
377
 
334
378
  include Bridge::Methods
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "makiri"
4
+
5
+ module Dommy
6
+ module Backend
7
+ # Makiri (Lexbor-based) backend. HTML5 parsing and CSS selectors via
8
+ # Lexbor, plus a native XPath 1.0 engine, with no libxml2 dependency.
9
+ # Makiri splits its document model into `Makiri::HTML::Document` (case-folding,
10
+ # html/head/body) and `Makiri::XML::Document` (case-preserving, namespaces,
11
+ # CDATA); both share the `Makiri::Document` / `Makiri::Node` bases used here
12
+ # for `is_a?` checks. HTML parses go through HTML::Document; `new Document()` /
13
+ # createDocument go through XML::Document.
14
+ module Makiri
15
+ # Class references for `is_a?` / type-checking (the shared bases, so both
16
+ # HTML and XML node subclasses match).
17
+ Element = ::Makiri::Element
18
+ Document = ::Makiri::Document
19
+ Text = ::Makiri::Text
20
+ Comment = ::Makiri::Comment
21
+ CDATASection = ::Makiri::CDATASection
22
+ ProcessingInstruction = ::Makiri::ProcessingInstruction
23
+ DocumentFragment = ::Makiri::DocumentFragment
24
+ DocumentType = ::Makiri::DocumentType
25
+ Node = ::Makiri::Node
26
+
27
+ # A minimal namespace wrapper exposing the same `href` API that Nokogiri's
28
+ # Namespace object has, so calling code treats both backends uniformly.
29
+ Namespace = Struct.new(:href)
30
+
31
+ HTML_NAMESPACE_URI = "http://www.w3.org/1999/xhtml"
32
+
33
+ # Throwaway attribute used to bind `:scope` to a context element — Lexbor
34
+ # has no `:scope`, so a scoped query temporarily marks the element and
35
+ # rewrites `:scope` to an attribute selector, removing the mark after.
36
+ SCOPE_ATTR = "data-dommy-scope"
37
+
38
+ module_function
39
+
40
+ # Makiri clones natively (import_node + template fixup), preserving the
41
+ # node's namespace and attributes and carrying <template> contents.
42
+ def clone_node(node, deep:)
43
+ node.clone_node(deep)
44
+ end
45
+
46
+ # Makiri documents have no node-level clone; re-parsing the serialized
47
+ # document reproduces the full tree. Dispatch on the document kind so an XML
48
+ # document round-trips through the XML parser (case/namespaces/CDATA) and an
49
+ # HTML document through the HTML parser.
50
+ def clone_document(doc)
51
+ if doc.is_a?(::Makiri::XML::Document)
52
+ ::Makiri::XML::Document.parse(doc.to_xml)
53
+ else
54
+ ::Makiri::HTML::Document.parse(doc.to_html)
55
+ end
56
+ end
57
+
58
+ # A fresh, empty HTML-backed document (children dropped so it starts with no
59
+ # documentElement). The backing for a shallow clone of an HTML document.
60
+ def empty_document
61
+ doc = ::Makiri::HTML::Document.parse("")
62
+ doc.children.to_a.each(&:unlink)
63
+ doc
64
+ end
65
+
66
+ # A fresh, empty XML-backed document — the backing for `new Document()` /
67
+ # createDocument, which the DOM defines as XML documents. An XML backing
68
+ # gives them case preservation, real CDATA nodes (nodeType 4) and namespace
69
+ # tracking. Makiri's cross-kind `import_node` translates between the HTML and
70
+ # XML node representations, so a node created here can still be adopted into
71
+ # the main HTML tree (and vice versa) — which is why an XML backing no longer
72
+ # blocks the cross-tree inserts WPT performs.
73
+ def empty_xml_document
74
+ ::Makiri::XML::Document.new
75
+ end
76
+
77
+ # An empty document matching `doc`'s kind, for a shallow document clone
78
+ # (cloneNode(false) on a document must keep the same flavor).
79
+ def empty_document_like(doc)
80
+ doc.is_a?(::Makiri::XML::Document) ? empty_xml_document : empty_document
81
+ end
82
+
83
+ # Lexbor seeds even an empty parse with an <html> shell and has no `root=`,
84
+ # so clear the existing children before adopting `node` as the root.
85
+ def set_document_root(doc, node)
86
+ doc.children.to_a.each(&:unlink)
87
+ doc.add_child(node)
88
+ end
89
+
90
+ # Makiri can't move a node between document arenas, so adoption imports a
91
+ # detached copy owned by `target_doc`. The caller reseats the wrapper onto
92
+ # this returned node, preserving Dommy-level identity.
93
+ #
94
+ # Gap absorption: Lexbor's HTML serializer has no CDATA case (it errors on a
95
+ # CDATA node) and Lexbor is a pinned upstream submodule, so Makiri's
96
+ # cross-kind import_node fails closed when bringing an XML CDATASection into
97
+ # an HTML document. Rather than let that surface as an error, degrade the
98
+ # node to a text node carrying the same data — the data a spec-faithful HTML
99
+ # serializer emits anyway (CDATASection is a Text subtype). The caller
100
+ # reseats the Dommy CDATASection wrapper onto this node, so `nodeType`
101
+ # stays 4 at the DOM level; only the backing node (and thus serialization)
102
+ # is text. An XML target keeps a real CDATA node. This handles a directly
103
+ # adopted CDATA node (a CDATA descendant inside an adopted element subtree
104
+ # is rare, untested, and still fails closed in the backend).
105
+ def adopt(node, target_doc)
106
+ if node.is_a?(::Makiri::CDATASection) && !target_doc.is_a?(::Makiri::XML::Document)
107
+ return target_doc.create_text_node(node.text)
108
+ end
109
+
110
+ target_doc.import_node(node, true)
111
+ end
112
+
113
+ # Lexbor arenas can't move a node between documents — a foreign node must be
114
+ # imported (see #adopt) before insertion.
115
+ def moves_nodes_across_documents?
116
+ false
117
+ end
118
+
119
+ # CSS query honoring Dommy's custom pseudo-classes. Lexbor handles
120
+ # `:disabled`/`:enabled`/`:checked` natively, so only `:scope` needs help:
121
+ # when `scope_node` is given and the selector uses `:scope`, bind it to
122
+ # that element via a temporary attribute.
123
+ def select_all(node, selector, scope_node: nil)
124
+ with_scope(selector, scope_node) { |sel| node.css(sel) }
125
+ end
126
+
127
+ def select_first(node, selector, scope_node: nil)
128
+ with_scope(selector, scope_node) { |sel| node.at_css(sel) }
129
+ end
130
+
131
+ def with_scope(selector, scope_node)
132
+ return yield(selector) unless scope_node && selector.include?(":scope")
133
+
134
+ scope_node[SCOPE_ATTR] = ""
135
+ begin
136
+ yield(selector.gsub(":scope", "[#{SCOPE_ATTR}]"))
137
+ ensure
138
+ scope_node.remove_attribute(SCOPE_ATTR)
139
+ end
140
+ end
141
+
142
+ # Makiri hands out a fresh Ruby wrapper on each traversal, so object_id is
143
+ # not stable; pointer_id (the underlying lxb_dom_node_t pointer) is. Safe
144
+ # as an identity key because Makiri detaches but never frees nodes — the
145
+ # document arena owns them — so a live node's pointer is never recycled.
146
+ def identity_key(node)
147
+ node.pointer_id
148
+ end
149
+
150
+ def parse(html)
151
+ ::Makiri::HTML::Document.parse(html.to_s)
152
+ end
153
+
154
+ # XML parse (DOMParser `text/xml` / `application/xml`): a real XML document,
155
+ # so element/attribute case is preserved, namespaces are tracked, and CDATA
156
+ # round-trips. The parsed tree is self-contained (not mixed into the HTML
157
+ # tree), so the HTML/XML node-kind split doesn't bite here.
158
+ def parse_xml(xml)
159
+ ::Makiri::XML::Document.parse(xml.to_s)
160
+ end
161
+
162
+ def fragment(html, owner_doc:)
163
+ ::Makiri::DocumentFragment.parse(html.to_s)
164
+ end
165
+
166
+ def create_element(name, doc)
167
+ # Mint from the owning document so HTML docs lower-case the name and XML
168
+ # docs preserve its case.
169
+ doc.create_element(name)
170
+ end
171
+
172
+ # An XML-backed document rejects a qualified name that DOM allows (e.g.
173
+ # "f}oo" — an invalid char in the local part), so createElementNS uses
174
+ # Makiri's loose creator, which builds it verbatim (case/prefix preserved).
175
+ # nil for a non-XML backend → the caller uses the strict #create_element.
176
+ def create_element_loose(qualified_name, prefix, local, namespace, doc)
177
+ return nil unless doc.is_a?(::Makiri::XML::Document) && doc.respond_to?(:create_loose_dom_element)
178
+
179
+ doc.create_loose_dom_element(qualified_name, prefix, local, namespace)
180
+ end
181
+
182
+ # A detached DocumentType node owned by `doc`, for
183
+ # DOMImplementation.createDocumentType. Only the HTML backend ships the
184
+ # factory (`create_document_type`); nil signals the caller to fall back to a
185
+ # synthetic (non-tree) DocumentType. Raises ArgumentError for a name the
186
+ # factory rejects.
187
+ def create_document_type(name, public_id, system_id, doc)
188
+ return nil unless doc.respond_to?(:create_document_type)
189
+
190
+ doc.create_document_type(name.to_s, public_id.to_s, system_id.to_s)
191
+ end
192
+
193
+ # The parsed document's DocumentType node (`<!DOCTYPE …>`), or nil when the
194
+ # document declares none.
195
+ def internal_subset(doc)
196
+ doc.respond_to?(:internal_subset) ? doc.internal_subset : nil
197
+ end
198
+
199
+ # The backend class for a DocumentType node, so the wrapper routes it to
200
+ # Dommy::DocumentType (node-backed).
201
+ def document_type_class
202
+ ::Makiri::DocumentType
203
+ end
204
+
205
+ def create_text(content, doc)
206
+ doc.create_text_node(content)
207
+ end
208
+
209
+ def create_comment(content, doc)
210
+ doc.create_comment(content)
211
+ end
212
+
213
+ # CDATASection (nodeType 4). A genuine XML document — including a
214
+ # `new Document()` / createDocument document, now XML-backed (see
215
+ # #empty_xml_document) — mints a real CDATA node and the XML serializer emits
216
+ # `<![CDATA[…]]>`. `Document#create_cdata_section` rejects HTML documents up
217
+ # front (NotSupportedError, per spec), so the text-node fallback below is a
218
+ # defensive guard for any non-XML backend that still slips through (Lexbor's
219
+ # HTML serializer raises on a native CDATA node, so a text node keeps
220
+ # serialization safe).
221
+ def create_cdata(content, doc)
222
+ if doc.is_a?(::Makiri::XML::Document)
223
+ doc.create_cdata(content)
224
+ else
225
+ doc.create_text_node(content)
226
+ end
227
+ end
228
+
229
+ # The backend class for a CDATA node, so the wrapper routes it to
230
+ # CDATASectionNode (it is a Text subtype, matched before Text).
231
+ def cdata_class
232
+ ::Makiri::CDATASection
233
+ end
234
+
235
+ # Both Makiri document families (HTML and XML) mint a real PI node and
236
+ # serialize it (HTML as `<?target data>`, XML as `<?target data?>`), so PIs
237
+ # — unlike CDATA — need no HTML-document fallback.
238
+ def create_processing_instruction(target, data, doc)
239
+ doc.create_processing_instruction(target, data)
240
+ end
241
+
242
+ def processing_instruction_class
243
+ ::Makiri::ProcessingInstruction
244
+ end
245
+
246
+ # Makiri doesn't track XML namespaces. We synthesize one for SVG by
247
+ # walking ancestors — necessary so `element_class_for` routes SVG
248
+ # tags to their specialized classes.
249
+ # The element's namespace, from Lexbor's own namespace tracking (HTML /
250
+ # SVG / MathML). nil for the HTML namespace, so Element#namespace_uri
251
+ # falls back to its HTML default (and the wrapper is allocated only for
252
+ # genuine foreign content).
253
+ def namespace_of(node)
254
+ return nil unless node.respond_to?(:namespace_uri)
255
+
256
+ uri = node.namespace_uri
257
+ return nil if uri.nil? || uri.empty? || uri == HTML_NAMESPACE_URI
258
+
259
+ Namespace.new(uri)
260
+ end
261
+
262
+ # Bind a *prefixed* element's namespace so the prefix resolves. An XML
263
+ # document resolves an element's prefix from xmlns declarations at insertion
264
+ # time, so a prefixed element (createElementNS / createDocument with a
265
+ # qualified name like "foo:div") must carry an xmlns:prefix declaration or
266
+ # the insert fails with an unbound-prefix error. The namespaceURI itself is
267
+ # tracked on the Dommy wrapper, so the unprefixed case needs nothing here —
268
+ # and must add no attribute, lest a spurious xmlns surface in the DOM view
269
+ # (attributes/isEqualNode). An HTML (Lexbor) document tracks the namespace
270
+ # natively and needs no declaration either. (This was a blanket no-op back
271
+ # when new Document()/createDocument were HTML-backed; XML-backing them
272
+ # surfaced the prefixed-element gap.)
273
+ def add_namespace_definition(node, prefix, href)
274
+ return if prefix.nil? || prefix.empty?
275
+ return unless node.document.is_a?(::Makiri::XML::Document)
276
+
277
+ node["xmlns:#{prefix}"] = href.to_s
278
+ nil
279
+ end
280
+
281
+ def namespace_definitions(_node)
282
+ # Makiri tracks no XML namespace declarations.
283
+ []
284
+ end
285
+
286
+ # Lexbor keeps <template> contents in a separate content fragment rather
287
+ # than the normal child chain.
288
+ def template_content_nodes(node)
289
+ cf = node.respond_to?(:content_fragment) ? node.content_fragment : nil
290
+ cf ? cf.children.to_a : []
291
+ end
292
+
293
+ # ----- Namespaced attributes -----
294
+ # Lexbor (Makiri >= 0.2) tracks the attribute's own namespace: set_attribute_ns
295
+ # records it (splitting prefix/local), and the attr node reports
296
+ # namespace_uri/prefix/local_name. So *AttributeNS matches on (namespace,
297
+ # local name) faithfully.
298
+
299
+ def get_attribute_ns(node, namespace, local_name)
300
+ attr_by_ns(node, namespace, local_name)&.value
301
+ end
302
+
303
+ def has_attribute_ns?(node, namespace, local_name)
304
+ !attr_by_ns(node, namespace, local_name).nil?
305
+ end
306
+
307
+ def set_attribute_ns(node, namespace, _prefix, _local_name, qualified_name, value)
308
+ node.set_attribute_ns(presence(namespace), qualified_name.to_s, value.to_s)
309
+ value.to_s
310
+ end
311
+
312
+ def remove_attribute_ns(node, namespace, local_name)
313
+ # Remove by (namespace, local name) — removing by qualified name is
314
+ # ambiguous once same-name/different-namespace attributes coexist.
315
+ node.remove_attribute_ns(presence(namespace), local_name.to_s)
316
+ nil
317
+ end
318
+
319
+ def attribute_ns_info(attr_node)
320
+ {
321
+ namespace_uri: presence(attr_node.namespace_uri),
322
+ prefix: presence(attr_node.prefix),
323
+ local_name: attr_node.local_name,
324
+ qualified_name: attr_node.name,
325
+ value: attr_node.value,
326
+ }
327
+ end
328
+
329
+ def attribute_nodes(node)
330
+ node.attribute_nodes
331
+ end
332
+
333
+ # Attribute node matching (namespace, local name) case-sensitively; a
334
+ # null/empty namespace matches a null-namespace attribute.
335
+ def attr_by_ns(node, namespace, local_name)
336
+ want_ns = presence(namespace)
337
+ want_local = local_name.to_s
338
+ node.attribute_nodes.find do |a|
339
+ a.local_name == want_local && presence(a.namespace_uri) == want_ns
340
+ end
341
+ end
342
+
343
+ def presence(value)
344
+ return nil if value.nil?
345
+
346
+ s = value.to_s
347
+ s.empty? ? nil : s
348
+ end
349
+
350
+ # Internal helper — visible to allow testing.
351
+ def in_svg_subtree?(node)
352
+ return true if node.name.to_s.downcase == "svg"
353
+
354
+ current = node.parent
355
+ while current
356
+ return true if current.respond_to?(:name) && current.name.to_s.downcase == "svg"
357
+ current = current.respond_to?(:parent) ? current.parent : nil
358
+ end
359
+
360
+ false
361
+ end
362
+ end
363
+ end
364
+ end