dommy 0.5.0 → 0.7.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -13
  3. data/lib/dommy/animation.rb +288 -0
  4. data/lib/dommy/attr.rb +23 -11
  5. data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
  7. data/lib/dommy/backend.rb +129 -0
  8. data/lib/dommy/blob.rb +2 -2
  9. data/lib/dommy/compression_streams.rb +147 -0
  10. data/lib/dommy/cookie_store.rb +128 -0
  11. data/lib/dommy/crypto.rb +396 -0
  12. data/lib/dommy/css.rb +7 -7
  13. data/lib/dommy/custom_elements.rb +6 -6
  14. data/lib/dommy/document.rb +190 -32
  15. data/lib/dommy/dom_parser.rb +5 -4
  16. data/lib/dommy/element.rb +356 -53
  17. data/lib/dommy/event.rb +431 -25
  18. data/lib/dommy/event_source.rb +131 -0
  19. data/lib/dommy/fetch.rb +76 -6
  20. data/lib/dommy/file_reader.rb +176 -0
  21. data/lib/dommy/form_data.rb +1 -3
  22. data/lib/dommy/history.rb +82 -0
  23. data/lib/dommy/html_collection.rb +4 -4
  24. data/lib/dommy/html_elements.rb +130 -67
  25. data/lib/dommy/internal/cookie_jar.rb +2 -0
  26. data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
  27. data/lib/dommy/internal/dom_matching.rb +4 -4
  28. data/lib/dommy/internal/idna.rb +443 -0
  29. data/lib/dommy/internal/idna_data.rb +10379 -0
  30. data/lib/dommy/internal/ipv4_parser.rb +78 -0
  31. data/lib/dommy/internal/node_traversal.rb +1 -1
  32. data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
  33. data/lib/dommy/internal/observable_callback.rb +25 -0
  34. data/lib/dommy/internal/punycode.rb +202 -0
  35. data/lib/dommy/internal/range_text_serializer.rb +72 -0
  36. data/lib/dommy/internal/reflected_attributes.rb +45 -0
  37. data/lib/dommy/internal/template_content_registry.rb +6 -6
  38. data/lib/dommy/intersection_observer.rb +82 -0
  39. data/lib/dommy/{router.rb → location.rb} +8 -142
  40. data/lib/dommy/media_query_list.rb +118 -0
  41. data/lib/dommy/message_channel.rb +249 -0
  42. data/lib/dommy/{observer.rb → mutation_observer.rb} +21 -11
  43. data/lib/dommy/navigator.rb +365 -5
  44. data/lib/dommy/node.rb +12 -0
  45. data/lib/dommy/notification.rb +89 -0
  46. data/lib/dommy/parser.rb +13 -13
  47. data/lib/dommy/performance.rb +146 -0
  48. data/lib/dommy/performance_observer.rb +55 -0
  49. data/lib/dommy/range.rb +597 -0
  50. data/lib/dommy/resize_observer.rb +53 -0
  51. data/lib/dommy/shadow_root.rb +10 -8
  52. data/lib/dommy/streams.rb +386 -0
  53. data/lib/dommy/svg_elements.rb +3863 -0
  54. data/lib/dommy/text_codec.rb +175 -0
  55. data/lib/dommy/tree_walker.rb +21 -21
  56. data/lib/dommy/url.rb +274 -29
  57. data/lib/dommy/url_pattern.rb +144 -0
  58. data/lib/dommy/version.rb +1 -1
  59. data/lib/dommy/web_socket.rb +209 -0
  60. data/lib/dommy/window.rb +369 -0
  61. data/lib/dommy/worker.rb +143 -0
  62. data/lib/dommy/xml_http_request.rb +438 -0
  63. data/lib/dommy.rb +43 -5
  64. metadata +44 -29
  65. data/lib/dommy/world.rb +0 -209
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 217a4ff53f58e6c12e424951b057cc0d3cbab3b898fdb85d5fd69ca7c34cd3c5
4
- data.tar.gz: bedaba24772a900d85e62a27729b8e62f8db8a18e0b48360f1717799108432f8
3
+ metadata.gz: ccbe61a606e042968621f7fa81588634f2cdae566d96cef137bfefdfb6d38dd6
4
+ data.tar.gz: 4358d9b68082608b7e78971be2d2818fb35200b17bec1819ce21a520b3552d7c
5
5
  SHA512:
6
- metadata.gz: 3a9687cf21dde61fa2c65cd47ca90f18e4d65da61912ffdbd8366cbf75f1110a96da52c43a27abee498dc9f2a19ffc306a491e14bb6aa4d985f7dd74fec88816
7
- data.tar.gz: 201bb5b9217bc0373923f89e36cc968cb55328f249d902acdfa973f9f01179d9912463b5ee9d165c8c5e862fb7ff3f40ed531db9f2ee811432e6f77db8c42faa
6
+ metadata.gz: a8c9a3b7bbfacb6f58440cbfec8884cc79c8690beda03225b229ca935f1b6d1ce0778c83a9253f2996198e46c8d8b85f9df53ccedb1ffa4e942a5b9841c051e4
7
+ data.tar.gz: f428a160a4c2f0180e7fa01f8cb1e6ec31dc9855f2bd7b43f1be6abd7a752a6b93926c1e57791a1c8b51a22e62631c997c4fbe8b55ea38fed5748f62cc874033
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Dommy
2
2
 
3
- A pure-Ruby DOM polyfill on top of [Nokogiri::HTML5](https://nokogiri.org/) a Ruby-side analogue to happy-dom / jsdom.
4
- Dommy exposes browser-like DOM semantics (events, MutationObserver, Custom Elements, Shadow DOM, File API, timers, Storage) so view / component / request specs can verify DOM structure and behavior without spinning up a real browser.
3
+ Dommy is a pure Ruby DOM polyfill built on [Nokogiri::HTML5](https://nokogiri.org/), inspired by happy-dom and jsdom.
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
7
7
 
@@ -74,22 +74,24 @@ input.validation_message #=> "Please fill out this field."
74
74
  ### File API (Blob / File / FormData / DataTransfer)
75
75
 
76
76
  ```ruby
77
+ win = Dommy.parse("<form><input type='file' name='attachment'></form>")
77
78
  file = Dommy::File.new(["pdf body"], "doc.pdf", "type" => "application/pdf")
78
79
 
79
80
  # Seed a file input for tests
80
- input = dom.query_selector("input[type='file']")
81
- input.__set_files__([file])
81
+ input = win.document.query_selector("input[type='file']")
82
+ input.__driver_set_files__([file])
82
83
 
83
84
  # FormData picks it up
84
- fd = Dommy::FormData.new(dom.query_selector("form"))
85
+ fd = Dommy::FormData.new(win.document.query_selector("form"))
85
86
  fd.entries.to_a #=> [["attachment", #<Dommy::File doc.pdf>]]
86
87
 
87
88
  # Drag-and-drop simulation
88
89
  dt = Dommy::DataTransfer.new(files: [file])
89
90
  ev = Dommy::DragEvent.new("drop", "dataTransfer" => dt, "bubbles" => true)
90
- dropzone.dispatch_event(ev)
91
+ win.document.body.dispatch_event(ev)
91
92
 
92
93
  # Blob URLs
94
+ blob = Dommy::Blob.new(["blob body"], "type" => "text/plain")
93
95
  url = Dommy::URL.create_object_url(blob) # "blob:dommy/..."
94
96
  ```
95
97
 
@@ -102,7 +104,7 @@ response = win.__js_call__("fetch", ["/api"]).await
102
104
  ```
103
105
 
104
106
  > [!WARNING]
105
- > Most Dommy accessors (`Blob#text`, `Response#text`, `localStorage.get_item`) return synchronous Ruby values — not Promises. `.await` is for the JS-bridged async surface.
107
+ > Most Dommy accessors (`Blob#text`, `localStorage.get_item`) return synchronous Ruby values — not Promises. `.await` is only for the JS-bridged async surface (e.g., `fetch()`, `window.__js_call__`). Methods like `Response#text()` are Promise-returning and require `.await`.
106
108
 
107
109
  ## Test helpers
108
110
 
@@ -176,7 +178,7 @@ Supported Capybara-style options: `text:` / `exact:` / `count:` (Integer or Rang
176
178
  Implemented:
177
179
 
178
180
  - Core DOM (Document, Element, Text/Comment/Fragment, NodeList, Attr)
179
- - 26 specialized HTMLElement subclasses
181
+ - Specialized HTML and SVG element classes
180
182
  - events with composedPath / AbortSignal
181
183
  - MutationObserver (childList / attributes / characterData / subtree)
182
184
  - Custom Elements lifecycle
@@ -186,20 +188,36 @@ Implemented:
186
188
  - Promise
187
189
  - Location / History / URL
188
190
  - Storage
189
- - fetch (stub)
191
+ - fetch / XMLHttpRequest stubs
192
+ - WebSocket / EventSource / MessageChannel / BroadcastChannel test doubles
193
+ - FileReader / Notification / Geolocation / `matchMedia`
194
+ - `requestIdleCallback`, `structuredClone`, `URLPattern`
195
+ - Web Crypto, Streams, Compression Streams, Worker
196
+ - `performance`, `cookieStore`, Navigator extras
197
+ - Popover API, Fullscreen API, View Transitions API stub
190
198
  - Navigator / Clipboard
191
199
  - TreeWalker / NodeIterator / NodeFilter
192
200
  - File API (Blob / File / FileList / FormData / DataTransfer)
201
+ - IntersectionObserver / ResizeObserver / PerformanceObserver (test-driven `__test_trigger__`)
202
+ - Range / Selection (DOM-level only, no layout)
203
+ - Web Animations API (Animation / KeyframeEffect)
204
+ - Extended events: Touch / Clipboard / Composition / Wheel / Focus / BeforeUnload / Input / Pointer / Progress / Drag
205
+
206
+ For implementation notes and tradeoffs, see [design.md](./design.md).
193
207
 
194
208
  > [!IMPORTANT]
195
209
  > Out of scope:
196
210
  >
197
- > - requires a layout / CSS engine or media subsystems: real `getBoundingClientRect` / scroll metrics
198
- > - CSS scoping (`:host`, `::slotted`, computed styles)
211
+ > - layout and CSS-engine behavior
199
212
  > - JS evaluation
200
213
  > - Canvas / WebGL / media playback
201
- > - layout-dependent Range / Selection
202
- > - SVG specialized classes
214
+ > - layout-dependent Range / Selection geometry
215
+ > - SVG-specific value types
216
+ > - animation value interpolation
217
+
218
+ ## Method naming conventions
219
+
220
+ See [docs/development.md](./docs/development.md) for method-naming conventions and internal protocols.
203
221
 
204
222
  ## Running the tests
205
223
 
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ # `KeyframeEffect` — wraps a target element + keyframes + timing.
5
+ # Dommy doesn't interpolate values (no layout / render); the
6
+ # effect is just a record of what the animation describes.
7
+ #
8
+ # Spec: https://drafts.csswg.org/web-animations/#keyframeeffect
9
+ class KeyframeEffect
10
+ attr_reader :target, :keyframes
11
+
12
+ def initialize(target, keyframes, options = nil)
13
+ @target = target
14
+ @keyframes = case keyframes
15
+ when nil
16
+ []
17
+ when Array
18
+ keyframes
19
+ else
20
+ [keyframes]
21
+ end
22
+
23
+ @timing = normalize_timing(options)
24
+ end
25
+
26
+ def get_timing
27
+ @timing.dup
28
+ end
29
+
30
+ alias getTiming get_timing
31
+
32
+ def update_timing(timing)
33
+ @timing.merge!(timing.transform_keys(&:to_s))
34
+ nil
35
+ end
36
+
37
+ alias updateTiming update_timing
38
+
39
+ def duration_ms
40
+ d = @timing["duration"]
41
+ d.is_a?(Numeric) ? d.to_i : 0
42
+ end
43
+
44
+ def __js_get__(key)
45
+ case key
46
+ when "target"
47
+ @target
48
+ end
49
+ end
50
+
51
+ def __js_call__(method, args)
52
+ case method
53
+ when "getTiming"
54
+ get_timing
55
+ when "updateTiming"
56
+ update_timing(args[0] || {})
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def normalize_timing(input)
63
+ case input
64
+ when nil
65
+ {"duration" => 0}
66
+ when Numeric
67
+ {"duration" => input.to_i}
68
+ when Hash
69
+ h = input.transform_keys(&:to_s)
70
+ h["duration"] = (h["duration"] || 0).is_a?(Numeric) ? h["duration"].to_i : 0
71
+ h
72
+ else
73
+ {"duration" => 0}
74
+ end
75
+ end
76
+ end
77
+
78
+ # `Animation` — represents a running animation, mirroring the Web
79
+ # Animations API's lifecycle and event surface. Dommy doesn't
80
+ # interpolate any property values; the animation transitions
81
+ # through "idle" → "running" → "finished" by either virtual time
82
+ # (`scheduler.advance_time`) or by an explicit `finish()` call.
83
+ #
84
+ # Spec: https://drafts.csswg.org/web-animations/#animation
85
+ class Animation
86
+ include EventTarget
87
+
88
+ attr_accessor :id
89
+ attr_reader :effect, :timeline, :play_state
90
+
91
+ def initialize(effect = nil, timeline = nil, window: nil)
92
+ @effect = effect
93
+ @timeline = timeline
94
+ @window = window
95
+ @play_state = "idle"
96
+ @playback_rate = 1.0
97
+ @current_time = nil
98
+ @start_time = nil
99
+ @id = ""
100
+ @finished_promise = nil
101
+ @ready_promise = nil
102
+ @scheduled_finish_id = nil
103
+ end
104
+
105
+ def current_time
106
+ @current_time
107
+ end
108
+
109
+ def current_time=(value)
110
+ @current_time = value
111
+ end
112
+
113
+ def start_time
114
+ @start_time
115
+ end
116
+
117
+ def start_time=(value)
118
+ @start_time = value
119
+ end
120
+
121
+ def playback_rate
122
+ @playback_rate
123
+ end
124
+
125
+ def playback_rate=(value)
126
+ @playback_rate = value.to_f
127
+ end
128
+
129
+ # Start (or resume) the animation. Returns self.
130
+ def play
131
+ return self if @play_state == "running"
132
+
133
+ previous = @play_state
134
+ @play_state = "running"
135
+ @start_time ||= @window&.scheduler&.now_ms || 0
136
+ ensure_ready_resolved
137
+ schedule_auto_finish if previous != "paused"
138
+ self
139
+ end
140
+
141
+ def pause
142
+ cancel_scheduled_finish
143
+ @play_state = "paused" unless @play_state == "idle"
144
+ self
145
+ end
146
+
147
+ def cancel
148
+ cancel_scheduled_finish
149
+ @play_state = "idle"
150
+ @current_time = nil
151
+ reject_finished_with_abort
152
+ dispatch_event(Event.new("cancel"))
153
+ self
154
+ end
155
+
156
+ def finish
157
+ cancel_scheduled_finish
158
+ @play_state = "finished"
159
+ @current_time = effect_duration_ms
160
+ resolve_finished
161
+ dispatch_event(Event.new("finish"))
162
+ self
163
+ end
164
+
165
+ def reverse
166
+ @playback_rate = -@playback_rate
167
+ play if @play_state == "idle"
168
+ self
169
+ end
170
+
171
+ # PromiseValue that resolves when the animation finishes.
172
+ # Rejected (with AbortError-style RuntimeError) on cancel.
173
+ def finished
174
+ @finished_promise ||= PromiseValue.new(@window)
175
+ end
176
+
177
+ # PromiseValue that resolves once the animation is ready to play
178
+ # (immediately in Dommy — there's no render-thread handoff).
179
+ def ready
180
+ @ready_promise ||= if @window
181
+ PromiseValue.resolve(@window, self)
182
+ else
183
+ PromiseValue.new(@window)
184
+ end
185
+ end
186
+
187
+ def __js_get__(key)
188
+ case key
189
+ when "playState"
190
+ @play_state
191
+ when "playbackRate"
192
+ @playback_rate
193
+ when "currentTime"
194
+ @current_time
195
+ when "startTime"
196
+ @start_time
197
+ when "effect"
198
+ @effect
199
+ when "timeline"
200
+ @timeline
201
+ when "finished"
202
+ finished
203
+ when "ready"
204
+ ready
205
+ when "id"
206
+ @id
207
+ end
208
+ end
209
+
210
+ def __js_set__(key, value)
211
+ case key
212
+ when "currentTime"
213
+ @current_time = value
214
+ when "startTime"
215
+ @start_time = value
216
+ when "playbackRate"
217
+ @playback_rate = value.to_f
218
+ when "id"
219
+ @id = value.to_s
220
+ end
221
+
222
+ nil
223
+ end
224
+
225
+ def __js_call__(method, args)
226
+ case method
227
+ when "play"
228
+ play
229
+ when "pause"
230
+ pause
231
+ when "cancel"
232
+ cancel
233
+ when "finish"
234
+ finish
235
+ when "reverse"
236
+ reverse
237
+ when "addEventListener"
238
+ add_event_listener(args[0], args[1], args[2])
239
+ when "removeEventListener"
240
+ remove_event_listener(args[0], args[1])
241
+ when "dispatchEvent"
242
+ dispatch_event(args[0])
243
+ end
244
+ end
245
+
246
+ # Event bubbling stops at Animation — it isn't part of the DOM tree.
247
+ def __internal_event_parent__
248
+ nil
249
+ end
250
+
251
+ private
252
+
253
+ def effect_duration_ms
254
+ @effect ? @effect.duration_ms : 0
255
+ end
256
+
257
+ def schedule_auto_finish
258
+ return unless @window&.scheduler
259
+ return if effect_duration_ms <= 0
260
+
261
+ @scheduled_finish_id = @window.scheduler.set_timeout(
262
+ proc { finish if @play_state == "running" },
263
+ effect_duration_ms
264
+ )
265
+ end
266
+
267
+ def cancel_scheduled_finish
268
+ return unless @scheduled_finish_id && @window&.scheduler
269
+
270
+ @window.scheduler.clear_timeout(@scheduled_finish_id)
271
+ @scheduled_finish_id = nil
272
+ end
273
+
274
+ def ensure_ready_resolved
275
+ ready.fulfill(self) if ready.respond_to?(:fulfill)
276
+ end
277
+
278
+ def resolve_finished
279
+ finished.fulfill(self)
280
+ end
281
+
282
+ def reject_finished_with_abort
283
+ return unless @finished_promise
284
+
285
+ @finished_promise.reject(RuntimeError.new("AbortError: animation cancelled"))
286
+ end
287
+ end
288
+ end
data/lib/dommy/attr.rb CHANGED
@@ -28,7 +28,7 @@ module Dommy
28
28
 
29
29
  def value
30
30
  if @owner
31
- @owner.__node__[@name].to_s
31
+ @owner.__dommy_backend_node__[@name].to_s
32
32
  else
33
33
  @detached_value
34
34
  end
@@ -72,6 +72,12 @@ module Dommy
72
72
  nil
73
73
  end
74
74
 
75
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
76
+ JS_METHOD_NAMES = %w[cloneNode].freeze
77
+ def __js_method_names__
78
+ JS_METHOD_NAMES
79
+ end
80
+
75
81
  def __js_call__(method, _args)
76
82
  case method
77
83
  when "cloneNode"
@@ -81,13 +87,13 @@ module Dommy
81
87
 
82
88
  # Internal: called by Element when the attr is being transferred
83
89
  # to (or detached from) an Element.
84
- def __attach__(element)
90
+ def __internal_attach__(element)
85
91
  @owner = element
86
92
  @detached_value = ""
87
93
  nil
88
94
  end
89
95
 
90
- def __detach__
96
+ def __internal_detach__
91
97
  cached = value
92
98
  @owner = nil
93
99
  @detached_value = cached
@@ -109,19 +115,19 @@ module Dommy
109
115
  end
110
116
 
111
117
  def length
112
- @element.__node__.attribute_nodes.size
118
+ @element.__dommy_backend_node__.attribute_nodes.size
113
119
  end
114
120
 
115
121
  alias size length
116
122
 
117
123
  def item(index)
118
- name = @element.__node__.attribute_nodes[index.to_i]&.name
124
+ name = @element.__dommy_backend_node__.attribute_nodes[index.to_i]&.name
119
125
  name && Attr.new(name, owner: @element)
120
126
  end
121
127
 
122
128
  def get_named_item(name)
123
129
  key = name.to_s.downcase
124
- return nil unless @element.__node__.key?(key)
130
+ return nil unless @element.__dommy_backend_node__.key?(key)
125
131
 
126
132
  Attr.new(key, owner: @element)
127
133
  end
@@ -131,22 +137,22 @@ module Dommy
131
137
 
132
138
  key = attr.name
133
139
  val = attr.value
134
- attr.__attach__(@element)
140
+ attr.__internal_attach__(@element)
135
141
  @element.set_attribute(key, val)
136
142
  attr
137
143
  end
138
144
 
139
145
  def remove_named_item(name)
140
146
  key = name.to_s.downcase
141
- return nil unless @element.__node__.key?(key)
147
+ return nil unless @element.__dommy_backend_node__.key?(key)
142
148
 
143
- attr = Attr.new(key, owner: nil, value: @element.__node__[key].to_s)
149
+ attr = Attr.new(key, owner: nil, value: @element.__dommy_backend_node__[key].to_s)
144
150
  @element.remove_attribute(key)
145
151
  attr
146
152
  end
147
153
 
148
154
  def each(&blk)
149
- @element.__node__.attribute_nodes.each do |a|
155
+ @element.__dommy_backend_node__.attribute_nodes.each do |a|
150
156
  yield Attr.new(a.name, owner: @element)
151
157
  end
152
158
  end
@@ -175,6 +181,12 @@ module Dommy
175
181
  end
176
182
  end
177
183
 
184
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
185
+ JS_METHOD_NAMES = %w[item getNamedItem setNamedItem removeNamedItem].freeze
186
+ def __js_method_names__
187
+ JS_METHOD_NAMES
188
+ end
189
+
178
190
  def __js_call__(method, args)
179
191
  case method
180
192
  when "item"
@@ -194,7 +206,7 @@ module Dommy
194
206
  end
195
207
 
196
208
  def respond_to_missing?(name, include_private = false)
197
- @element.__node__.key?(name.to_s.downcase) || super
209
+ @element.__dommy_backend_node__.key?(name.to_s.downcase) || super
198
210
  end
199
211
  end
200
212
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Dommy
6
+ module Backend
7
+ # Nokogiri (libxml2-based) backend. Mature, full XML namespace
8
+ # support. Default backend.
9
+ module Nokogiri
10
+ # Class references for `is_a?` / type-checking from Dommy internals.
11
+ Element = ::Nokogiri::XML::Element
12
+ Document = ::Nokogiri::XML::Document
13
+ Text = ::Nokogiri::XML::Text
14
+ Comment = ::Nokogiri::XML::Comment
15
+ DocumentFragment = ::Nokogiri::XML::DocumentFragment
16
+ Node = ::Nokogiri::XML::Node
17
+
18
+ module_function
19
+
20
+ def parse(html)
21
+ ::Nokogiri::HTML5(html.to_s, max_errors: 0)
22
+ end
23
+
24
+ def fragment(html, owner_doc:)
25
+ # owner_doc is unused by Nokogiri — the fragment carries its
26
+ # own document. The Parser layer copies nodes into the target.
27
+ ::Nokogiri::HTML5.fragment(html.to_s, max_errors: 0)
28
+ end
29
+
30
+ def create_element(name, doc)
31
+ ::Nokogiri::XML::Node.new(name, doc)
32
+ end
33
+
34
+ def create_text(content, doc)
35
+ ::Nokogiri::XML::Text.new(content, doc)
36
+ end
37
+
38
+ def create_comment(content, doc)
39
+ ::Nokogiri::XML::Comment.new(doc, content)
40
+ end
41
+
42
+ def namespace_of(node)
43
+ node.namespace
44
+ end
45
+
46
+ def add_namespace_definition(node, prefix, href)
47
+ node.add_namespace_definition(prefix, href)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokolexbor"
4
+
5
+ module Dommy
6
+ module Backend
7
+ # Nokolexbor (Lexbor-based) backend. 3-7× faster CSS selectors,
8
+ # 6× faster HTML5 parsing. HTML-only — XML namespaces are not
9
+ # tracked, so SVG detection falls back to ancestor walking.
10
+ module Nokolexbor
11
+ # Class references for `is_a?` / type-checking.
12
+ Element = ::Nokolexbor::Element
13
+ Document = ::Nokolexbor::Document
14
+ Text = ::Nokolexbor::Text
15
+ Comment = ::Nokolexbor::Comment
16
+ DocumentFragment = ::Nokolexbor::DocumentFragment
17
+ Node = ::Nokolexbor::Node
18
+
19
+ # Fake "namespace" wrapper returned for nodes inside <svg> subtrees.
20
+ # Provides the same `href` API that Nokogiri's Namespace object has,
21
+ # so calling code can treat them uniformly.
22
+ SvgNamespace = Struct.new(:href) do
23
+ def initialize
24
+ super("http://www.w3.org/2000/svg")
25
+ end
26
+ end
27
+
28
+ SVG_NAMESPACE = SvgNamespace.new.freeze
29
+
30
+ module_function
31
+
32
+ def parse(html)
33
+ ::Nokolexbor.parse(html.to_s)
34
+ end
35
+
36
+ def fragment(html, owner_doc:)
37
+ ::Nokolexbor::DocumentFragment.parse(html.to_s)
38
+ end
39
+
40
+ def create_element(name, doc)
41
+ ::Nokolexbor::Node.new(name, doc)
42
+ end
43
+
44
+ def create_text(content, doc)
45
+ ::Nokolexbor::Text.new(content, doc)
46
+ end
47
+
48
+ def create_comment(content, doc)
49
+ # Nokolexbor's argument order is (content, doc).
50
+ ::Nokolexbor::Comment.new(content, doc)
51
+ end
52
+
53
+ # Nokolexbor doesn't track XML namespaces. We synthesize one for
54
+ # SVG by walking ancestors — necessary so `element_class_for`
55
+ # routes SVG tags to their specialized classes.
56
+ def namespace_of(node)
57
+ return nil unless node.respond_to?(:name)
58
+
59
+ in_svg_subtree?(node) ? SVG_NAMESPACE : nil
60
+ end
61
+
62
+ def add_namespace_definition(_node, _prefix, _href)
63
+ # No-op: Nokolexbor doesn't support XML namespaces.
64
+ end
65
+
66
+ # Internal helper — visible to allow testing.
67
+ def in_svg_subtree?(node)
68
+ return true if node.name.to_s.downcase == "svg"
69
+
70
+ current = node.parent
71
+ while current
72
+ return true if current.respond_to?(:name) && current.name.to_s.downcase == "svg"
73
+ current = current.respond_to?(:parent) ? current.parent : nil
74
+ end
75
+
76
+ false
77
+ end
78
+ end
79
+ end
80
+ end