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.
- checksums.yaml +4 -4
- data/README.md +31 -13
- data/lib/dommy/animation.rb +288 -0
- data/lib/dommy/attr.rb +23 -11
- data/lib/dommy/backend/nokogiri_adapter.rb +51 -0
- data/lib/dommy/backend/nokolexbor_adapter.rb +80 -0
- data/lib/dommy/backend.rb +129 -0
- data/lib/dommy/blob.rb +2 -2
- data/lib/dommy/compression_streams.rb +147 -0
- data/lib/dommy/cookie_store.rb +128 -0
- data/lib/dommy/crypto.rb +396 -0
- data/lib/dommy/css.rb +7 -7
- data/lib/dommy/custom_elements.rb +6 -6
- data/lib/dommy/document.rb +190 -32
- data/lib/dommy/dom_parser.rb +5 -4
- data/lib/dommy/element.rb +356 -53
- data/lib/dommy/event.rb +431 -25
- data/lib/dommy/event_source.rb +131 -0
- data/lib/dommy/fetch.rb +76 -6
- data/lib/dommy/file_reader.rb +176 -0
- data/lib/dommy/form_data.rb +1 -3
- data/lib/dommy/history.rb +82 -0
- data/lib/dommy/html_collection.rb +4 -4
- data/lib/dommy/html_elements.rb +130 -67
- data/lib/dommy/internal/cookie_jar.rb +2 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +28 -0
- data/lib/dommy/internal/dom_matching.rb +4 -4
- data/lib/dommy/internal/idna.rb +443 -0
- data/lib/dommy/internal/idna_data.rb +10379 -0
- data/lib/dommy/internal/ipv4_parser.rb +78 -0
- data/lib/dommy/internal/node_traversal.rb +1 -1
- data/lib/dommy/internal/node_wrapper_cache.rb +23 -12
- data/lib/dommy/internal/observable_callback.rb +25 -0
- data/lib/dommy/internal/punycode.rb +202 -0
- data/lib/dommy/internal/range_text_serializer.rb +72 -0
- data/lib/dommy/internal/reflected_attributes.rb +45 -0
- data/lib/dommy/internal/template_content_registry.rb +6 -6
- data/lib/dommy/intersection_observer.rb +82 -0
- data/lib/dommy/{router.rb → location.rb} +8 -142
- data/lib/dommy/media_query_list.rb +118 -0
- data/lib/dommy/message_channel.rb +249 -0
- data/lib/dommy/{observer.rb → mutation_observer.rb} +21 -11
- data/lib/dommy/navigator.rb +365 -5
- data/lib/dommy/node.rb +12 -0
- data/lib/dommy/notification.rb +89 -0
- data/lib/dommy/parser.rb +13 -13
- data/lib/dommy/performance.rb +146 -0
- data/lib/dommy/performance_observer.rb +55 -0
- data/lib/dommy/range.rb +597 -0
- data/lib/dommy/resize_observer.rb +53 -0
- data/lib/dommy/shadow_root.rb +10 -8
- data/lib/dommy/streams.rb +386 -0
- data/lib/dommy/svg_elements.rb +3863 -0
- data/lib/dommy/text_codec.rb +175 -0
- data/lib/dommy/tree_walker.rb +21 -21
- data/lib/dommy/url.rb +274 -29
- data/lib/dommy/url_pattern.rb +144 -0
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/web_socket.rb +209 -0
- data/lib/dommy/window.rb +369 -0
- data/lib/dommy/worker.rb +143 -0
- data/lib/dommy/xml_http_request.rb +438 -0
- data/lib/dommy.rb +43 -5
- metadata +44 -29
- data/lib/dommy/world.rb +0 -209
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ccbe61a606e042968621f7fa81588634f2cdae566d96cef137bfefdfb6d38dd6
|
|
4
|
+
data.tar.gz: 4358d9b68082608b7e78971be2d2818fb35200b17bec1819ce21a520b3552d7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8c9a3b7bbfacb6f58440cbfec8884cc79c8690beda03225b229ca935f1b6d1ce0778c83a9253f2996198e46c8d8b85f9df53ccedb1ffa4e942a5b9841c051e4
|
|
7
|
+
data.tar.gz: f428a160a4c2f0180e7fa01f8cb1e6ec31dc9855f2bd7b43f1be6abd7a752a6b93926c1e57791a1c8b51a22e62631c997c4fbe8b55ea38fed5748f62cc874033
|
data/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Dommy
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
81
|
-
input.
|
|
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(
|
|
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
|
-
|
|
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`, `
|
|
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
|
-
-
|
|
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
|
|
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
|
-
> -
|
|
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
|
|
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.
|
|
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
|
|
90
|
+
def __internal_attach__(element)
|
|
85
91
|
@owner = element
|
|
86
92
|
@detached_value = ""
|
|
87
93
|
nil
|
|
88
94
|
end
|
|
89
95
|
|
|
90
|
-
def
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
147
|
+
return nil unless @element.__dommy_backend_node__.key?(key)
|
|
142
148
|
|
|
143
|
-
attr = Attr.new(key, owner: nil, value: @element.
|
|
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.
|
|
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.
|
|
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
|