dommy 0.8.1 → 0.9.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 (115) 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 +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +86 -14
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. 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: ab840b2c863131b5f20d7be40f28028bec227fb490688b4b0f26157058c0ea68
4
+ data.tar.gz: a87acf4824167a08eae696de0ffe504cdf808d9085176e084b3e4f3d225e5c7a
5
5
  SHA512:
6
- metadata.gz: fcf29d56432325690f2dd4f8ede45bd4f87daa439c26b0a040a08d4c1263383b073e9e5879b4451c36b30cf97921753dc43a5197685d120e3468fb02c64d1705
7
- data.tar.gz: 888bd534c0f1d4e53ebf4d875ed52d7fcf329efb410fa8ed9482caf772504fbe5a90d323bf568fce85c95456d3fe1e61e3f5cc49172e41a145fa8d477cffa2ab
6
+ metadata.gz: db29082c6b8b686eb762aa53f3ae213b40da69210732c99a53f15a1f18318a326fd6bcf4c65fcfa4abc27dc41bebff0b16164682b7d18e7fed7a44415a9bb7e6
7
+ data.tar.gz: 71669401a09832106ec301e0b2d4747fb7782b2ac2514fbf4cfbf625358695f0c498561cbd3efb565b7b2abbaf43b4b2068dbe5f1831523a74ec25dc2c2e1435
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.
@@ -33,11 +33,15 @@ module Dommy
33
33
  @prefix = prefix
34
34
  @local_name = (local_name || qname.split(":", 2).last).to_s
35
35
  else
36
- # Null-namespace (HTML) attributes are lower-cased, as before.
37
- @name = qname.downcase
36
+ # Null-namespace attribute: use the qualified name verbatim. The casing is
37
+ # already decided by the caller — the backend stores `setAttribute` names
38
+ # lower-cased (HTML) but preserves `setAttributeNS("", "FOO")` as "FOO",
39
+ # and `createAttribute` lower-cases up front — so re-downcasing here would
40
+ # wrongly fold a case-preserved null-namespace attribute.
41
+ @name = qname
38
42
  @namespace_uri = nil
39
43
  @prefix = nil
40
- @local_name = @name
44
+ @local_name = local_name ? local_name.to_s : @name
41
45
  end
42
46
  end
43
47
 
@@ -96,6 +100,8 @@ module Dommy
96
100
  when "specified"
97
101
  # Legacy/useless attribute — always true (WHATWG DOM).
98
102
  true
103
+ else
104
+ Bridge::ABSENT
99
105
  end
100
106
  end
101
107
 
@@ -162,7 +168,7 @@ module Dommy
162
168
  # `.getNamedItem(name)`, `.removeNamedItem(name)`, `.setNamedItem(attr)`,
163
169
  # plus property-style access (`attributes.id`, `attributes.class`).
164
170
  #
165
- # NamedNodeMap is *live* — it re-reads the element's Nokogiri
171
+ # NamedNodeMap is *live* — it re-reads the element's Makiri
166
172
  # attributes on every access so DOM mutations are reflected.
167
173
  class NamedNodeMap
168
174
  include Enumerable
@@ -0,0 +1,330 @@
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
+ Node = ::Makiri::Node
25
+
26
+ # A minimal namespace wrapper exposing the same `href` API that Nokogiri's
27
+ # Namespace object has, so calling code treats both backends uniformly.
28
+ Namespace = Struct.new(:href)
29
+
30
+ HTML_NAMESPACE_URI = "http://www.w3.org/1999/xhtml"
31
+
32
+ # Throwaway attribute used to bind `:scope` to a context element — Lexbor
33
+ # has no `:scope`, so a scoped query temporarily marks the element and
34
+ # rewrites `:scope` to an attribute selector, removing the mark after.
35
+ SCOPE_ATTR = "data-dommy-scope"
36
+
37
+ module_function
38
+
39
+ # Makiri clones natively (import_node + template fixup), preserving the
40
+ # node's namespace and attributes and carrying <template> contents.
41
+ def clone_node(node, deep:)
42
+ node.clone_node(deep)
43
+ end
44
+
45
+ # Makiri documents have no node-level clone; re-parsing the serialized
46
+ # document reproduces the full tree. Dispatch on the document kind so an XML
47
+ # document round-trips through the XML parser (case/namespaces/CDATA) and an
48
+ # HTML document through the HTML parser.
49
+ def clone_document(doc)
50
+ if doc.is_a?(::Makiri::XML::Document)
51
+ ::Makiri::XML::Document.parse(doc.to_xml)
52
+ else
53
+ ::Makiri::HTML::Document.parse(doc.to_html)
54
+ end
55
+ end
56
+
57
+ # A fresh, empty HTML-backed document (children dropped so it starts with no
58
+ # documentElement). The backing for a shallow clone of an HTML document.
59
+ def empty_document
60
+ doc = ::Makiri::HTML::Document.parse("")
61
+ doc.children.to_a.each(&:unlink)
62
+ doc
63
+ end
64
+
65
+ # A fresh, empty XML-backed document — the backing for `new Document()` /
66
+ # createDocument, which the DOM defines as XML documents. An XML backing
67
+ # gives them case preservation, real CDATA nodes (nodeType 4) and namespace
68
+ # tracking. Makiri's cross-kind `import_node` translates between the HTML and
69
+ # XML node representations, so a node created here can still be adopted into
70
+ # the main HTML tree (and vice versa) — which is why an XML backing no longer
71
+ # blocks the cross-tree inserts WPT performs.
72
+ def empty_xml_document
73
+ ::Makiri::XML::Document.new
74
+ end
75
+
76
+ # An empty document matching `doc`'s kind, for a shallow document clone
77
+ # (cloneNode(false) on a document must keep the same flavor).
78
+ def empty_document_like(doc)
79
+ doc.is_a?(::Makiri::XML::Document) ? empty_xml_document : empty_document
80
+ end
81
+
82
+ # Lexbor seeds even an empty parse with an <html> shell and has no `root=`,
83
+ # so clear the existing children before adopting `node` as the root.
84
+ def set_document_root(doc, node)
85
+ doc.children.to_a.each(&:unlink)
86
+ doc.add_child(node)
87
+ end
88
+
89
+ # Makiri can't move a node between document arenas, so adoption imports a
90
+ # detached copy owned by `target_doc`. The caller reseats the wrapper onto
91
+ # this returned node, preserving Dommy-level identity.
92
+ #
93
+ # Gap absorption: Lexbor's HTML serializer has no CDATA case (it errors on a
94
+ # CDATA node) and Lexbor is a pinned upstream submodule, so Makiri's
95
+ # cross-kind import_node fails closed when bringing an XML CDATASection into
96
+ # an HTML document. Rather than let that surface as an error, degrade the
97
+ # node to a text node carrying the same data — the data a spec-faithful HTML
98
+ # serializer emits anyway (CDATASection is a Text subtype). The caller
99
+ # reseats the Dommy CDATASection wrapper onto this node, so `nodeType`
100
+ # stays 4 at the DOM level; only the backing node (and thus serialization)
101
+ # is text. An XML target keeps a real CDATA node. This handles a directly
102
+ # adopted CDATA node (a CDATA descendant inside an adopted element subtree
103
+ # is rare, untested, and still fails closed in the backend).
104
+ def adopt(node, target_doc)
105
+ if node.is_a?(::Makiri::CDATASection) && !target_doc.is_a?(::Makiri::XML::Document)
106
+ return target_doc.create_text_node(node.text)
107
+ end
108
+
109
+ target_doc.import_node(node, true)
110
+ end
111
+
112
+ # Lexbor arenas can't move a node between documents — a foreign node must be
113
+ # imported (see #adopt) before insertion.
114
+ def moves_nodes_across_documents?
115
+ false
116
+ end
117
+
118
+ # CSS query honoring Dommy's custom pseudo-classes. Lexbor handles
119
+ # `:disabled`/`:enabled`/`:checked` natively, so only `:scope` needs help:
120
+ # when `scope_node` is given and the selector uses `:scope`, bind it to
121
+ # that element via a temporary attribute.
122
+ def select_all(node, selector, scope_node: nil)
123
+ with_scope(selector, scope_node) { |sel| node.css(sel) }
124
+ end
125
+
126
+ def select_first(node, selector, scope_node: nil)
127
+ with_scope(selector, scope_node) { |sel| node.at_css(sel) }
128
+ end
129
+
130
+ def with_scope(selector, scope_node)
131
+ return yield(selector) unless scope_node && selector.include?(":scope")
132
+
133
+ scope_node[SCOPE_ATTR] = ""
134
+ begin
135
+ yield(selector.gsub(":scope", "[#{SCOPE_ATTR}]"))
136
+ ensure
137
+ scope_node.remove_attribute(SCOPE_ATTR)
138
+ end
139
+ end
140
+
141
+ # Makiri hands out a fresh Ruby wrapper on each traversal, so object_id is
142
+ # not stable; pointer_id (the underlying lxb_dom_node_t pointer) is. Safe
143
+ # as an identity key because Makiri detaches but never frees nodes — the
144
+ # document arena owns them — so a live node's pointer is never recycled.
145
+ def identity_key(node)
146
+ node.pointer_id
147
+ end
148
+
149
+ def parse(html)
150
+ ::Makiri::HTML::Document.parse(html.to_s)
151
+ end
152
+
153
+ # XML parse (DOMParser `text/xml` / `application/xml`): a real XML document,
154
+ # so element/attribute case is preserved, namespaces are tracked, and CDATA
155
+ # round-trips. The parsed tree is self-contained (not mixed into the HTML
156
+ # tree), so the HTML/XML node-kind split doesn't bite here.
157
+ def parse_xml(xml)
158
+ ::Makiri::XML::Document.parse(xml.to_s)
159
+ end
160
+
161
+ def fragment(html, owner_doc:)
162
+ ::Makiri::DocumentFragment.parse(html.to_s)
163
+ end
164
+
165
+ def create_element(name, doc)
166
+ # Mint from the owning document so HTML docs lower-case the name and XML
167
+ # docs preserve its case.
168
+ doc.create_element(name)
169
+ end
170
+
171
+ def create_text(content, doc)
172
+ doc.create_text_node(content)
173
+ end
174
+
175
+ def create_comment(content, doc)
176
+ doc.create_comment(content)
177
+ end
178
+
179
+ # CDATASection (nodeType 4). A genuine XML document — including a
180
+ # `new Document()` / createDocument document, now XML-backed (see
181
+ # #empty_xml_document) — mints a real CDATA node and the XML serializer emits
182
+ # `<![CDATA[…]]>`. `Document#create_cdata_section` rejects HTML documents up
183
+ # front (NotSupportedError, per spec), so the text-node fallback below is a
184
+ # defensive guard for any non-XML backend that still slips through (Lexbor's
185
+ # HTML serializer raises on a native CDATA node, so a text node keeps
186
+ # serialization safe).
187
+ def create_cdata(content, doc)
188
+ if doc.is_a?(::Makiri::XML::Document)
189
+ doc.create_cdata(content)
190
+ else
191
+ doc.create_text_node(content)
192
+ end
193
+ end
194
+
195
+ # The backend class for a CDATA node, so the wrapper routes it to
196
+ # CDATASectionNode (it is a Text subtype, matched before Text).
197
+ def cdata_class
198
+ ::Makiri::CDATASection
199
+ end
200
+
201
+ # Both Makiri document families (HTML and XML) mint a real PI node and
202
+ # serialize it (HTML as `<?target data>`, XML as `<?target data?>`), so PIs
203
+ # — unlike CDATA — need no HTML-document fallback.
204
+ def create_processing_instruction(target, data, doc)
205
+ doc.create_processing_instruction(target, data)
206
+ end
207
+
208
+ def processing_instruction_class
209
+ ::Makiri::ProcessingInstruction
210
+ end
211
+
212
+ # Makiri doesn't track XML namespaces. We synthesize one for SVG by
213
+ # walking ancestors — necessary so `element_class_for` routes SVG
214
+ # tags to their specialized classes.
215
+ # The element's namespace, from Lexbor's own namespace tracking (HTML /
216
+ # SVG / MathML). nil for the HTML namespace, so Element#namespace_uri
217
+ # falls back to its HTML default (and the wrapper is allocated only for
218
+ # genuine foreign content).
219
+ def namespace_of(node)
220
+ return nil unless node.respond_to?(:namespace_uri)
221
+
222
+ uri = node.namespace_uri
223
+ return nil if uri.nil? || uri.empty? || uri == HTML_NAMESPACE_URI
224
+
225
+ Namespace.new(uri)
226
+ end
227
+
228
+ # Bind a *prefixed* element's namespace so the prefix resolves. An XML
229
+ # document resolves an element's prefix from xmlns declarations at insertion
230
+ # time, so a prefixed element (createElementNS / createDocument with a
231
+ # qualified name like "foo:div") must carry an xmlns:prefix declaration or
232
+ # the insert fails with an unbound-prefix error. The namespaceURI itself is
233
+ # tracked on the Dommy wrapper, so the unprefixed case needs nothing here —
234
+ # and must add no attribute, lest a spurious xmlns surface in the DOM view
235
+ # (attributes/isEqualNode). An HTML (Lexbor) document tracks the namespace
236
+ # natively and needs no declaration either. (This was a blanket no-op back
237
+ # when new Document()/createDocument were HTML-backed; XML-backing them
238
+ # surfaced the prefixed-element gap.)
239
+ def add_namespace_definition(node, prefix, href)
240
+ return if prefix.nil? || prefix.empty?
241
+ return unless node.document.is_a?(::Makiri::XML::Document)
242
+
243
+ node["xmlns:#{prefix}"] = href.to_s
244
+ nil
245
+ end
246
+
247
+ def namespace_definitions(_node)
248
+ # Makiri tracks no XML namespace declarations.
249
+ []
250
+ end
251
+
252
+ # Lexbor keeps <template> contents in a separate content fragment rather
253
+ # than the normal child chain.
254
+ def template_content_nodes(node)
255
+ cf = node.respond_to?(:content_fragment) ? node.content_fragment : nil
256
+ cf ? cf.children.to_a : []
257
+ end
258
+
259
+ # ----- Namespaced attributes -----
260
+ # Lexbor (Makiri >= 0.2) tracks the attribute's own namespace: set_attribute_ns
261
+ # records it (splitting prefix/local), and the attr node reports
262
+ # namespace_uri/prefix/local_name. So *AttributeNS matches on (namespace,
263
+ # local name) faithfully.
264
+
265
+ def get_attribute_ns(node, namespace, local_name)
266
+ attr_by_ns(node, namespace, local_name)&.value
267
+ end
268
+
269
+ def has_attribute_ns?(node, namespace, local_name)
270
+ !attr_by_ns(node, namespace, local_name).nil?
271
+ end
272
+
273
+ def set_attribute_ns(node, namespace, _prefix, _local_name, qualified_name, value)
274
+ node.set_attribute_ns(presence(namespace), qualified_name.to_s, value.to_s)
275
+ value.to_s
276
+ end
277
+
278
+ def remove_attribute_ns(node, namespace, local_name)
279
+ # Remove by (namespace, local name) — removing by qualified name is
280
+ # ambiguous once same-name/different-namespace attributes coexist.
281
+ node.remove_attribute_ns(presence(namespace), local_name.to_s)
282
+ nil
283
+ end
284
+
285
+ def attribute_ns_info(attr_node)
286
+ {
287
+ namespace_uri: presence(attr_node.namespace_uri),
288
+ prefix: presence(attr_node.prefix),
289
+ local_name: attr_node.local_name,
290
+ qualified_name: attr_node.name,
291
+ value: attr_node.value,
292
+ }
293
+ end
294
+
295
+ def attribute_nodes(node)
296
+ node.attribute_nodes
297
+ end
298
+
299
+ # Attribute node matching (namespace, local name) case-sensitively; a
300
+ # null/empty namespace matches a null-namespace attribute.
301
+ def attr_by_ns(node, namespace, local_name)
302
+ want_ns = presence(namespace)
303
+ want_local = local_name.to_s
304
+ node.attribute_nodes.find do |a|
305
+ a.local_name == want_local && presence(a.namespace_uri) == want_ns
306
+ end
307
+ end
308
+
309
+ def presence(value)
310
+ return nil if value.nil?
311
+
312
+ s = value.to_s
313
+ s.empty? ? nil : s
314
+ end
315
+
316
+ # Internal helper — visible to allow testing.
317
+ def in_svg_subtree?(node)
318
+ return true if node.name.to_s.downcase == "svg"
319
+
320
+ current = node.parent
321
+ while current
322
+ return true if current.respond_to?(:name) && current.name.to_s.downcase == "svg"
323
+ current = current.respond_to?(:parent) ? current.parent : nil
324
+ end
325
+
326
+ false
327
+ end
328
+ end
329
+ end
330
+ end
data/lib/dommy/backend.rb CHANGED
@@ -2,24 +2,23 @@
2
2
 
3
3
  module Dommy
4
4
  # `Dommy::Backend` — pluggable HTML parser abstraction. Lets Dommy
5
- # work with either Nokogiri (mature, full namespace support) or
6
- # Nokolexbor (faster, HTML5-only). Internally, all DOM library
5
+ # work with Makiri (Lexbor-based, HTML5-only). Internally, all DOM library
7
6
  # code goes through this facade rather than referencing the parser
8
7
  # directly.
9
8
  #
10
- # Defaults to Nokogiri if available, else Nokolexbor.
9
+ # Defaults to Makiri.
11
10
  #
12
11
  # Switching backends:
13
12
  #
14
13
  # require "dommy"
15
- # Dommy::Backend.use(:nokolexbor)
14
+ # Dommy::Backend.use(:makiri)
16
15
  #
17
16
  # Or set directly:
18
17
  #
19
- # Dommy::Backend.current = Dommy::Backend::Nokolexbor
18
+ # Dommy::Backend.current = Dommy::Backend::Makiri
20
19
  #
21
20
  # All adapters must implement the same interface — see
22
- # `Backend::Nokogiri` for the canonical reference.
21
+ # `Backend::Makiri` for the canonical reference.
23
22
  module Backend
24
23
  class BackendNotAvailable < StandardError
25
24
  end
@@ -33,32 +32,100 @@ module Dommy
33
32
 
34
33
  def use(name)
35
34
  @current = case name.to_sym
36
- when :nokogiri
37
- require_relative "backend/nokogiri_adapter"
38
- Nokogiri
39
- when :nokolexbor
40
- require_relative "backend/nokolexbor_adapter"
41
- Nokolexbor
35
+ when :makiri
36
+ require_relative "backend/makiri_adapter"
37
+ Makiri
42
38
  else
43
- raise ArgumentError, "Unknown backend: #{name.inspect}. Use :nokogiri or :nokolexbor."
39
+ raise ArgumentError, "Unknown backend: #{name.inspect}. Use :makiri."
44
40
  end
45
41
  end
46
42
 
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.
46
+ def identity_key(node)
47
+ current.identity_key(node)
48
+ end
49
+
50
+ # Deep (or shallow) copy of an element/node, detached and owned by the
51
+ # same document — the backing for DOM cloneNode.
52
+ def clone_node(node, deep:)
53
+ current.clone_node(node, deep: deep)
54
+ end
55
+
56
+ # Deep copy of a whole document (DOM cloneNode on the document).
57
+ def clone_document(doc)
58
+ current.clone_document(doc)
59
+ end
60
+
61
+ # A fresh, empty HTML-backed document.
62
+ def empty_document
63
+ current.empty_document
64
+ end
65
+
66
+ # A fresh, empty XML-backed document — for `new Document()` / createDocument,
67
+ # which the DOM defines as XML documents (case-preserving, real CDATA
68
+ # nodeType, namespaces).
69
+ def empty_xml_document
70
+ current.empty_xml_document
71
+ end
72
+
73
+ # An empty backing document matching `doc`'s kind (HTML stays HTML, XML stays
74
+ # XML) — for a shallow document clone, whose result keeps the source flavor.
75
+ def empty_document_like(doc)
76
+ current.empty_document_like(doc)
77
+ end
78
+
79
+ # Bring `node` (already detached from its old tree) into `target_doc`,
80
+ # returning the backend node now owned by `target_doc`. Makiri can't move a node
81
+ # between arenas, so it imports a copy — callers must reseat any wrapper
82
+ # onto the returned node.
83
+ def adopt(node, target_doc)
84
+ current.adopt(node, target_doc)
85
+ end
86
+
87
+ # Whether the backend can move a node between documents in place or must adopt a copy first
88
+ # (Lexbor's arenas can't move a node, so inserting a foreign node requires importing
89
+ # it). Lets callers skip a needless — and on an empty target, root-less and
90
+ # therefore crashing — adoption on backends that don't need it.
91
+ def moves_nodes_across_documents?
92
+ current.respond_to?(:moves_nodes_across_documents?) ? current.moves_nodes_across_documents? : true
93
+ end
94
+
95
+ # CSS query that honors Dommy's custom pseudo-classes
96
+ # (`:disabled`/`:enabled`/`:checked`/`:scope`). Each backend applies its
97
+ # own mechanism (Lexbor native pseudos plus a
98
+ # `:scope` rewrite). `scope_node` binds `:scope` to that element.
99
+ def select_all(node, selector, scope_node: nil)
100
+ current.select_all(node, selector, scope_node: scope_node)
101
+ end
102
+
103
+ def select_first(node, selector, scope_node: nil)
104
+ current.select_first(node, selector, scope_node: scope_node)
105
+ end
106
+
47
107
  # Delegate calls so internal code can use `Backend.parse(...)`.
48
108
  def parse(html)
49
109
  current.parse(html)
50
110
  end
51
111
 
52
- # Parse XML input into an XML document. Backends without a real XML parser
53
- # (HTML-only, e.g. Nokolexbor) fall back to the HTML parser.
112
+ # Parse XML input into an XML document.
113
+ # fall back to the HTML parser.
54
114
  def parse_xml(xml)
55
- current.respond_to?(:parse_xml) ? current.parse_xml(xml) : current.parse(xml)
115
+ current.parse_xml(xml)
56
116
  end
57
117
 
58
118
  def fragment(html, owner_doc:)
59
119
  current.fragment(html, owner_doc: owner_doc)
60
120
  end
61
121
 
122
+ # Make `node` the sole document element of `doc` (used by
123
+ # DOMImplementation.createDocument). Lexbor-backed documents are seeded with an <html> shell
124
+ # that must be cleared first.
125
+ def set_document_root(doc, node)
126
+ current.set_document_root(doc, node)
127
+ end
128
+
62
129
  def create_element(name, doc)
63
130
  current.create_element(name, doc)
64
131
  end
@@ -81,16 +148,40 @@ module Dommy
81
148
  current.respond_to?(:cdata_class) ? current.cdata_class : nil
82
149
  end
83
150
 
151
+ # ProcessingInstruction node (`<?target data?>`). Supported by every
152
+ # backend Dommy ships (both Makiri document families), so — unlike CDATA —
153
+ # there is no text-node fallback.
154
+ def create_processing_instruction(target, data, doc)
155
+ current.create_processing_instruction(target, data, doc)
156
+ end
157
+
158
+ def processing_instruction_class
159
+ current.processing_instruction_class
160
+ end
161
+
84
162
  def namespace_of(node)
85
163
  current.namespace_of(node)
86
164
  end
87
165
 
166
+ # The element's in-scope namespace declarations (each responds to
167
+ # `prefix`/`href`). Empty on backends without an XML namespace model.
168
+ def namespace_definitions(node)
169
+ current.namespace_definitions(node)
170
+ end
171
+
172
+ # The content child nodes of a `<template>` element. HTML5 parsers model
173
+ # template contents differently — Lexbor/Makiri in a separate content fragment — so reading them goes
174
+ # through the backend. Used by the template-content registry's migration.
175
+ def template_content_nodes(node)
176
+ current.template_content_nodes(node)
177
+ end
178
+
88
179
  def add_namespace_definition(node, prefix, href)
89
180
  current.add_namespace_definition(node, prefix, href)
90
181
  end
91
182
 
92
183
  # Namespaced attribute access (DOM *AttributeNS). `namespace` is an href
93
- # String or nil. Nokolexbor degrades to qualified-name (null-namespace).
184
+ # String or nil.
94
185
  def get_attribute_ns(node, namespace, local_name)
95
186
  current.get_attribute_ns(node, namespace, local_name)
96
187
  end
@@ -148,25 +239,15 @@ module Dommy
148
239
  private
149
240
 
150
241
  def detect_default
151
- try_nokogiri ||
152
- try_nokolexbor ||
153
- raise(BackendNotAvailable, "Dommy requires either 'nokogiri' or 'nokolexbor' gem to be installed.")
154
- end
155
-
156
- def try_nokogiri
157
- require "nokogiri"
158
-
159
- require_relative "backend/nokogiri_adapter"
160
- Nokogiri
161
- rescue LoadError
162
- nil
242
+ try_makiri ||
243
+ raise(BackendNotAvailable, "Dommy requires either 'makiri' gem to be installed.")
163
244
  end
164
245
 
165
- def try_nokolexbor
166
- require "nokolexbor"
246
+ def try_makiri
247
+ require "makiri"
167
248
 
168
- require_relative "backend/nokolexbor_adapter"
169
- Nokolexbor
249
+ require_relative "backend/makiri_adapter"
250
+ Makiri
170
251
  rescue LoadError
171
252
  nil
172
253
  end
data/lib/dommy/blob.rb CHANGED
@@ -63,6 +63,8 @@ module Dommy
63
63
  @size
64
64
  when "type"
65
65
  @type
66
+ else
67
+ Bridge::ABSENT
66
68
  end
67
69
  end
68
70
 
data/lib/dommy/bridge.rb CHANGED
@@ -37,6 +37,17 @@ module Dommy
37
37
  def UNDEFINED.inspect = "#<Dommy::Bridge::UNDEFINED>"
38
38
  UNDEFINED.freeze
39
39
 
40
+ # The sentinel a `__js_get__` returns for a GENUINELY-ABSENT property (a key
41
+ # the object does not have), as distinct from a present property whose value
42
+ # is `nil`/JS null or `UNDEFINED`/JS undefined. It marshals to JS `undefined`
43
+ # for the value, but the proxy reports `("x" in obj) === false` for it — so
44
+ # feature detection like `isUndefined(window.Vue)` AND `"Vue" in window` are
45
+ # both correct. (UNDEFINED means present-but-undefined → reported present.)
46
+ ABSENT = Object.new
47
+ def ABSENT.to_s = "undefined"
48
+ def ABSENT.inspect = "#<Dommy::Bridge::ABSENT>"
49
+ ABSENT.freeze
50
+
40
51
  # An opaque handle to a JS-side value that Ruby only stores and hands back
41
52
  # (an AbortSignal's reason, a CustomEvent's detail). A non-plain JS object
42
53
  # (Error, class instance, …) crosses as one of these instead of being