dommy 0.7.0 → 0.8.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/animation.rb +9 -1
  3. data/lib/dommy/attr.rb +192 -39
  4. data/lib/dommy/backend/nokogiri_adapter.rb +76 -0
  5. data/lib/dommy/backend/nokolexbor_adapter.rb +37 -0
  6. data/lib/dommy/backend.rb +46 -0
  7. data/lib/dommy/blob.rb +28 -9
  8. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  9. data/lib/dommy/bridge/methods.rb +57 -0
  10. data/lib/dommy/bridge.rb +97 -0
  11. data/lib/dommy/callable_invoker.rb +36 -0
  12. data/lib/dommy/cookie_store.rb +3 -1
  13. data/lib/dommy/crypto.rb +7 -1
  14. data/lib/dommy/css.rb +46 -0
  15. data/lib/dommy/custom_elements.rb +27 -3
  16. data/lib/dommy/data_transfer.rb +4 -0
  17. data/lib/dommy/document.rb +615 -48
  18. data/lib/dommy/dom_parser.rb +28 -15
  19. data/lib/dommy/element.rb +999 -471
  20. data/lib/dommy/event.rb +260 -96
  21. data/lib/dommy/event_source.rb +6 -2
  22. data/lib/dommy/fetch.rb +505 -43
  23. data/lib/dommy/file_reader.rb +11 -3
  24. data/lib/dommy/form_data.rb +2 -0
  25. data/lib/dommy/history.rb +43 -8
  26. data/lib/dommy/html_collection.rb +55 -2
  27. data/lib/dommy/html_elements.rb +102 -1519
  28. data/lib/dommy/internal/css_pseudo_handlers.rb +109 -0
  29. data/lib/dommy/internal/global_functions.rb +26 -0
  30. data/lib/dommy/internal/idna.rb +16 -7
  31. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  32. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  33. data/lib/dommy/internal/namespaces.rb +70 -0
  34. data/lib/dommy/internal/node_equality.rb +86 -0
  35. data/lib/dommy/internal/node_wrapper_cache.rb +62 -27
  36. data/lib/dommy/internal/observable_callback.rb +1 -5
  37. data/lib/dommy/internal/parent_node.rb +126 -0
  38. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  39. data/lib/dommy/internal/selector_parser.rb +664 -0
  40. data/lib/dommy/internal/url_parser.rb +677 -0
  41. data/lib/dommy/intersection_observer.rb +2 -0
  42. data/lib/dommy/location.rb +2 -0
  43. data/lib/dommy/media_query_list.rb +7 -1
  44. data/lib/dommy/message_channel.rb +32 -2
  45. data/lib/dommy/mutation_observer.rb +55 -12
  46. data/lib/dommy/navigator.rb +26 -12
  47. data/lib/dommy/node.rb +158 -28
  48. data/lib/dommy/notification.rb +3 -1
  49. data/lib/dommy/performance.rb +4 -0
  50. data/lib/dommy/performance_observer.rb +2 -0
  51. data/lib/dommy/promise.rb +14 -14
  52. data/lib/dommy/range.rb +74 -5
  53. data/lib/dommy/resize_observer.rb +2 -0
  54. data/lib/dommy/scheduler.rb +34 -13
  55. data/lib/dommy/shadow_root.rb +23 -54
  56. data/lib/dommy/storage.rb +2 -0
  57. data/lib/dommy/streams.rb +18 -27
  58. data/lib/dommy/svg_elements.rb +204 -3606
  59. data/lib/dommy/text_codec.rb +174 -21
  60. data/lib/dommy/tree_walker.rb +255 -66
  61. data/lib/dommy/url.rb +287 -449
  62. data/lib/dommy/url_pattern.rb +2 -0
  63. data/lib/dommy/version.rb +1 -1
  64. data/lib/dommy/web_socket.rb +37 -7
  65. data/lib/dommy/window.rb +202 -213
  66. data/lib/dommy/worker.rb +7 -7
  67. data/lib/dommy/xml_http_request.rb +15 -5
  68. data/lib/dommy.rb +7 -0
  69. metadata +12 -3
@@ -10,17 +10,20 @@ module Dommy
10
10
  "utf-8"
11
11
  end
12
12
 
13
- # encode(string) → Array<Integer> (bytes).
14
- # Browsers return a Uint8Array; Dommy returns the equivalent Ruby
15
- # byte array since there is no typed-array layer.
13
+ # encode(string) → Uint8Array (UTF-8 bytes). Lone surrogates in the input
14
+ # have already been replaced with U+FFFD when the JS string crossed into Ruby
15
+ # (Ruby strings can't hold them), matching the spec's USVString conversion.
16
16
  def encode(input = "")
17
- input.to_s.encode(Encoding::UTF_8).bytes
17
+ str = input.equal?(Bridge::UNDEFINED) ? "" : input.to_s
18
+ Bridge::Bytes.new(str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).bytes)
18
19
  end
19
20
 
20
21
  def __js_get__(key)
21
22
  key == "encoding" ? encoding : nil
22
23
  end
23
24
 
25
+ include Bridge::Methods
26
+ js_methods %w[encode]
24
27
  def __js_call__(method, args)
25
28
  case method
26
29
  when "encode"
@@ -34,35 +37,50 @@ module Dommy
34
37
  #
35
38
  # Spec: https://encoding.spec.whatwg.org/#textdecoder
36
39
  class TextDecoder
37
- def initialize(label = "utf-8", _options = nil)
40
+ def initialize(label = "utf-8", options = nil)
41
+ label = "utf-8" if label.nil? || label.equal?(Bridge::UNDEFINED)
38
42
  @encoding = normalize_encoding(label.to_s)
43
+ opts = options.is_a?(Hash) ? options : {}
44
+ @fatal = truthy?(opts["fatal"] || opts[:fatal])
45
+ @ignore_bom = truthy?(opts["ignoreBOM"] || opts[:ignoreBOM])
39
46
  end
40
47
 
41
- def encoding
42
- @encoding
43
- end
48
+ attr_reader :encoding
49
+
50
+ def fatal? = @fatal
51
+ def ignore_bom? = @ignore_bom
44
52
 
45
- # decode(bytes) → String. Accepts Array<Integer> (byte values),
46
- # a binary String, or anything responding to to_a/bytes.
47
- def decode(input = nil, _options = nil)
48
- return "" if input.nil?
53
+ # decode(bytes, {stream}) → String. Accepts a Bytes buffer (JS ArrayBuffer /
54
+ # TypedArray), an Array<Integer>, or a binary String. With `fatal: true` an
55
+ # invalid sequence throws a TypeError; otherwise it is replaced with U+FFFD.
56
+ # A leading BOM is stripped unless `ignoreBOM` was set.
57
+ def decode(input = nil, options = nil)
58
+ stream = options.is_a?(Hash) && truthy?(options["stream"] || options[:stream])
59
+ bytes = extract_bytes(input)
49
60
 
50
- bytes = case input
51
- when String
52
- input.b
53
- when Array
54
- input.pack("C*")
61
+ if @encoding == "utf-8"
62
+ decode_utf8(bytes, stream)
55
63
  else
56
- input.respond_to?(:to_a) ? input.to_a.pack("C*") : input.to_s
57
- end
64
+ # Non-UTF-8 (utf-16le/be, iso-8859-1): no streaming/exact-FFFD semantics,
65
+ # best-effort via Ruby's transcoder.
66
+ bytes = strip_bom(bytes) unless @ignore_bom
67
+ raw = bytes.force_encoding(ruby_encoding)
68
+ raise Bridge::TypeError, "decode failed" if @fatal && !raw.valid_encoding?
58
69
 
59
- bytes.force_encoding(ruby_encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
70
+ raw.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
71
+ end
60
72
  end
61
73
 
62
74
  def __js_get__(key)
63
- key == "encoding" ? encoding : nil
75
+ case key
76
+ when "encoding" then @encoding
77
+ when "fatal" then @fatal
78
+ when "ignoreBOM" then @ignore_bom
79
+ end
64
80
  end
65
81
 
82
+ include Bridge::Methods
83
+ js_methods %w[decode]
66
84
  def __js_call__(method, args)
67
85
  case method
68
86
  when "decode"
@@ -72,6 +90,141 @@ module Dommy
72
90
 
73
91
  private
74
92
 
93
+ def extract_bytes(input)
94
+ return "".b if input.nil? || input.equal?(Bridge::UNDEFINED)
95
+
96
+ case input
97
+ when Bridge::Bytes then input.pack_bytes
98
+ when String then input.b
99
+ when Array then input.pack("C*")
100
+ else input.respond_to?(:to_a) ? input.to_a.pack("C*") : input.to_s.b
101
+ end
102
+ end
103
+
104
+ # WHATWG "UTF-8 decoder" — a stateful machine so streaming chunks, exact
105
+ # U+FFFD placement, and end-of-queue flushing match the spec (Ruby's
106
+ # transcoder replaces per-byte, which is wrong at sequence boundaries).
107
+ # State persists on the decoder across `{stream: true}` calls.
108
+ def decode_utf8(bytes, stream)
109
+ @u8_needed ||= 0
110
+ @u8_seen ||= 0
111
+ @u8_cp ||= 0
112
+ @u8_lower ||= 0x80
113
+ @u8_upper ||= 0xBF
114
+
115
+ out = []
116
+ queue = bytes.bytes
117
+ @u8_started = true
118
+
119
+ until queue.empty?
120
+ byte = queue.shift
121
+ if @u8_needed.zero?
122
+ if byte <= 0x7F
123
+ out << byte
124
+ elsif byte.between?(0xC2, 0xDF)
125
+ @u8_needed = 1
126
+ @u8_cp = byte & 0x1F
127
+ elsif byte.between?(0xE0, 0xEF)
128
+ @u8_lower = 0xA0 if byte == 0xE0
129
+ @u8_upper = 0x9F if byte == 0xED
130
+ @u8_needed = 2
131
+ @u8_cp = byte & 0x0F
132
+ elsif byte.between?(0xF0, 0xF4)
133
+ @u8_lower = 0x90 if byte == 0xF0
134
+ @u8_upper = 0x8F if byte == 0xF4
135
+ @u8_needed = 3
136
+ @u8_cp = byte & 0x07
137
+ else
138
+ out << utf8_error
139
+ end
140
+ next
141
+ end
142
+
143
+ unless byte.between?(@u8_lower, @u8_upper)
144
+ # Invalid continuation: emit an error and REPROCESS this byte.
145
+ reset_utf8_state
146
+ out << utf8_error
147
+ queue.unshift(byte)
148
+ next
149
+ end
150
+
151
+ @u8_lower = 0x80
152
+ @u8_upper = 0xBF
153
+ @u8_cp = (@u8_cp << 6) | (byte & 0x3F)
154
+ @u8_seen += 1
155
+ next unless @u8_seen == @u8_needed
156
+
157
+ out << @u8_cp
158
+ reset_utf8_state
159
+ end
160
+
161
+ unless stream
162
+ if @u8_needed != 0
163
+ reset_utf8_state
164
+ out << utf8_error
165
+ end
166
+ end
167
+
168
+ # Strip a leading byte-order mark at the code-point level, not the byte
169
+ # level: a BOM split across streaming chunks (EF BB in one decode() call,
170
+ # BF in the next) only surfaces as a single U+FEFF once the full sequence
171
+ # decodes, which a byte-prefix check would miss. The "BOM seen" flag
172
+ # persists across `{stream: true}` calls and is reset on flush.
173
+ unless @ignore_bom || @u8_bom_seen
174
+ if out[0] == 0xFEFF
175
+ out.shift
176
+ @u8_bom_seen = true
177
+ elsif !out.empty?
178
+ @u8_bom_seen = true
179
+ end
180
+ end
181
+
182
+ unless stream
183
+ @u8_started = false
184
+ @u8_bom_seen = false
185
+ end
186
+
187
+ out.pack("U*")
188
+ end
189
+
190
+ # In fatal mode an error throws a TypeError; otherwise it is the U+FFFD
191
+ # replacement code point.
192
+ def utf8_error
193
+ raise Bridge::TypeError, "The encoded data was not valid for encoding utf-8" if @fatal
194
+
195
+ 0xFFFD
196
+ end
197
+
198
+ def reset_utf8_state
199
+ @u8_needed = 0
200
+ @u8_seen = 0
201
+ @u8_cp = 0
202
+ @u8_lower = 0x80
203
+ @u8_upper = 0xBF
204
+ end
205
+
206
+ # JS ToBoolean for an option-bag value (false/nil/undefined/0/"" are falsy).
207
+ def truthy?(value)
208
+ return false if value.nil? || value == false || value == 0 || value == ""
209
+ return false if defined?(Bridge::UNDEFINED) && value.equal?(Bridge::UNDEFINED)
210
+
211
+ true
212
+ end
213
+
214
+ # Remove a leading byte-order mark matching this decoder's encoding.
215
+ def strip_bom(bytes)
216
+ case @encoding
217
+ when "utf-8"
218
+ bytes.start_with?("\xEF\xBB\xBF".b) ? bytes.byteslice(3..) : bytes
219
+ when "utf-16le"
220
+ bytes.start_with?("\xFF\xFE".b) ? bytes.byteslice(2..) : bytes
221
+ when "utf-16be"
222
+ bytes.start_with?("\xFE\xFF".b) ? bytes.byteslice(2..) : bytes
223
+ else
224
+ bytes
225
+ end
226
+ end
227
+
75
228
  def normalize_encoding(label)
76
229
  l = label.downcase.strip
77
230
  case l
@@ -21,23 +21,17 @@ module Dommy
21
21
 
22
22
  # Map a wrapped Dommy node to its NodeFilter bitmask. Returns 0
23
23
  # for unknown node types (effectively "doesn't pass any filter").
24
+ # whatToShow bit for a node: WHATWG defines it as `1 << (nodeType - 1)`,
25
+ # which covers every node type (Element, Text, CDATASection, PI, Comment,
26
+ # Document, DocumentType, DocumentFragment, Attribute) uniformly.
24
27
  def self.bitmask_for(node)
25
- case node
26
- when Element
27
- SHOW_ELEMENT
28
- when TextNode
29
- SHOW_TEXT
30
- when CommentNode
31
- SHOW_COMMENT
32
- when Fragment
33
- SHOW_DOCUMENT_FRAGMENT
34
- when Document
35
- SHOW_DOCUMENT
36
- when DocumentType
37
- SHOW_DOCUMENT_TYPE
38
- else
39
- 0
40
- end
28
+ nt =
29
+ if node.respond_to?(:node_type) then node.node_type
30
+ elsif node.respond_to?(:__js_get__) then node.__js_get__("nodeType")
31
+ end
32
+ return 0 unless nt.is_a?(Integer) && nt >= 1
33
+
34
+ 1 << (nt - 1)
41
35
  end
42
36
  end
43
37
 
@@ -58,15 +52,40 @@ module Dommy
58
52
  end
59
53
 
60
54
  def invoke_filter(node)
61
- return NodeFilter::FILTER_ACCEPT if @filter.nil?
55
+ return NodeFilter::FILTER_ACCEPT if @filter.nil? || (defined?(Bridge::UNDEFINED) && @filter.equal?(Bridge::UNDEFINED))
56
+
57
+ cb = filter_callback
58
+ # A non-null filter with no callable acceptNode is a TypeError when invoked.
59
+ raise Bridge::TypeError, "NodeFilter is not callable" unless cb
62
60
 
63
- if @filter.respond_to?(:accept_node)
64
- @filter.accept_node(node)
65
- elsif @filter.respond_to?(:call)
66
- @filter.call(node)
61
+ result = if cb.respond_to?(:__js_call__)
62
+ cb.__js_call__("call", [node])
67
63
  else
68
- NodeFilter::FILTER_ACCEPT
64
+ cb.call(node)
69
65
  end
66
+ # WebIDL coerces the filter return (an `unsigned short`): booleans and
67
+ # null become 0/1, everything else ToInteger.
68
+ return 1 if result == true
69
+ return 0 if result == false || result.nil?
70
+
71
+ result.to_i
72
+ end
73
+
74
+ # The actual filter callback: a function filter is called directly; an object
75
+ # filter's `acceptNode` is used (WebIDL callback interface).
76
+ def filter_callback
77
+ return @filter if callable?(@filter)
78
+
79
+ accept_node =
80
+ if @filter.respond_to?(:accept_node) then @filter.method(:accept_node)
81
+ elsif @filter.is_a?(Hash) then (@filter["acceptNode"] || @filter[:acceptNode])
82
+ elsif @filter.respond_to?(:__js_get__) then @filter.__js_get__("acceptNode")
83
+ end
84
+ callable?(accept_node) ? accept_node : nil
85
+ end
86
+
87
+ def callable?(value)
88
+ value && (value.respond_to?(:__js_call__) || value.respond_to?(:call))
70
89
  end
71
90
  end
72
91
 
@@ -90,32 +109,55 @@ module Dommy
90
109
  end
91
110
 
92
111
  def next_node
93
- node = first_descendant_or_following(@current_node)
94
- while node
95
- verdict = accept(node)
96
- if verdict == NodeFilter::FILTER_ACCEPT
97
- @current_node = node
98
- return node
112
+ node = @current_node
113
+ result = NodeFilter::FILTER_ACCEPT
114
+ loop do
115
+ while result != NodeFilter::FILTER_REJECT && (child = first_wrapped_child(node))
116
+ node = child
117
+ result = accept(node)
118
+ return @current_node = node if result == NodeFilter::FILTER_ACCEPT
99
119
  end
100
120
 
101
- node = (verdict == NodeFilter::FILTER_REJECT) ? following_skip_subtree(node) : first_descendant_or_following(
102
- node
103
- )
104
- end
121
+ sibling = nil
122
+ temp = node
123
+ while temp
124
+ return nil if temp == @root
125
+
126
+ sibling = next_sibling_wrapped(temp)
127
+ if sibling
128
+ node = sibling
129
+ break
130
+ end
131
+ temp = wrapped_parent(temp)
132
+ end
133
+ return nil unless sibling
105
134
 
106
- nil
135
+ result = accept(node)
136
+ return @current_node = node if result == NodeFilter::FILTER_ACCEPT
137
+ end
107
138
  end
108
139
 
109
140
  def previous_node
110
- node = preceding(@current_node)
111
- while node && node != @root
112
- verdict = accept(node)
113
- if verdict == NodeFilter::FILTER_ACCEPT
114
- @current_node = node
115
- return node
141
+ node = @current_node
142
+ while node != @root
143
+ sibling = previous_sibling_wrapped(node)
144
+ while sibling
145
+ node = sibling
146
+ result = accept(node)
147
+ while result != NodeFilter::FILTER_REJECT && (child = last_wrapped_child(node))
148
+ node = child
149
+ result = accept(node)
150
+ end
151
+ return @current_node = node if result == NodeFilter::FILTER_ACCEPT
152
+
153
+ sibling = previous_sibling_wrapped(node)
116
154
  end
117
155
 
118
- node = preceding(node)
156
+ parent = wrapped_parent(node)
157
+ return nil if node == @root || parent.nil?
158
+
159
+ node = parent
160
+ return @current_node = node if accept(node) == NodeFilter::FILTER_ACCEPT
119
161
  end
120
162
 
121
163
  nil
@@ -133,21 +175,19 @@ module Dommy
133
175
  end
134
176
 
135
177
  def first_child
136
- first = first_wrapped_child(@current_node)
137
- walk_siblings(first, :next_sibling_wrapped)
178
+ traverse_children(:first_wrapped_child, :next_sibling_wrapped)
138
179
  end
139
180
 
140
181
  def last_child
141
- last = last_wrapped_child(@current_node)
142
- walk_siblings(last, :previous_sibling_wrapped)
182
+ traverse_children(:last_wrapped_child, :previous_sibling_wrapped)
143
183
  end
144
184
 
145
185
  def next_sibling
146
- walk_siblings(next_sibling_wrapped(@current_node), :next_sibling_wrapped)
186
+ traverse_siblings(:next_sibling_wrapped, :first_wrapped_child)
147
187
  end
148
188
 
149
189
  def previous_sibling
150
- walk_siblings(previous_sibling_wrapped(@current_node), :previous_sibling_wrapped)
190
+ traverse_siblings(:previous_sibling_wrapped, :last_wrapped_child)
151
191
  end
152
192
 
153
193
  def __js_get__(key)
@@ -164,10 +204,17 @@ module Dommy
164
204
  end
165
205
 
166
206
  def __js_set__(key, value)
167
- @current_node = value if key == "currentNode"
207
+ return Bridge::UNHANDLED unless key == "currentNode"
208
+
209
+ # currentNode is a non-null `Node`; non-Node values are a TypeError.
210
+ raise Bridge::TypeError, "currentNode must be a Node" unless value.is_a?(Dommy::Node)
211
+
212
+ @current_node = value
168
213
  nil
169
214
  end
170
215
 
216
+ include Bridge::Methods
217
+ js_methods %w[nextNode previousNode parentNode firstChild lastChild nextSibling previousSibling]
171
218
  def __js_call__(method, _args)
172
219
  case method
173
220
  when "nextNode"
@@ -189,18 +236,62 @@ module Dommy
189
236
 
190
237
  private
191
238
 
192
- def walk_siblings(start, direction)
193
- node = start
239
+ # WHATWG "traverse children": a SKIP node is transparent (descend into its
240
+ # children); a REJECT node is opaque (skip it and its subtree, advance to the
241
+ # next sibling, climbing toward currentNode as needed).
242
+ def traverse_children(descend, sibling_dir)
243
+ node = send(descend, @current_node)
194
244
  while node
195
245
  v = accept(node)
196
246
  return @current_node = node if v == NodeFilter::FILTER_ACCEPT
197
247
 
198
- node = (v == NodeFilter::FILTER_REJECT) ? nil : send(direction, node)
248
+ if v == NodeFilter::FILTER_SKIP
249
+ child = send(descend, node)
250
+ if child
251
+ node = child
252
+ next
253
+ end
254
+ end
255
+
256
+ loop do
257
+ sib = send(sibling_dir, node)
258
+ if sib
259
+ node = sib
260
+ break
261
+ end
262
+ parent = wrapped_parent(node)
263
+ return nil if parent.nil? || parent == @root || parent == @current_node
264
+
265
+ node = parent
266
+ end
199
267
  end
200
268
 
201
269
  nil
202
270
  end
203
271
 
272
+ # WHATWG "traverse siblings": walk siblings of currentNode; a SKIP sibling's
273
+ # children are searched (descend), a REJECT sibling's subtree is skipped.
274
+ def traverse_siblings(sibling_dir, child_dir)
275
+ node = @current_node
276
+ return nil if node == @root
277
+
278
+ loop do
279
+ sib = send(sibling_dir, node)
280
+ while sib
281
+ node = sib
282
+ v = accept(node)
283
+ return @current_node = node if v == NodeFilter::FILTER_ACCEPT
284
+
285
+ sib = send(child_dir, node)
286
+ sib = send(sibling_dir, node) if v == NodeFilter::FILTER_REJECT || sib.nil?
287
+ end
288
+
289
+ node = wrapped_parent(node)
290
+ return nil if node.nil? || node == @root
291
+ return nil if accept(node) == NodeFilter::FILTER_ACCEPT
292
+ end
293
+ end
294
+
204
295
  def first_descendant_or_following(node)
205
296
  child = first_wrapped_child(node)
206
297
  return child if child
@@ -253,23 +344,46 @@ module Dommy
253
344
  doc.wrap_node(parent_nk)
254
345
  end
255
346
 
347
+ # The Nokogiri node backing a wrapper — an element's `__dommy_backend_node__`
348
+ # or, for a Document (which isn't a wrapped node), its `nokogiri_doc`. Lets a
349
+ # walker rooted at the document descend into its children.
350
+ def backend_node_of(node)
351
+ if node.respond_to?(:__dommy_backend_node__)
352
+ node.__dommy_backend_node__
353
+ elsif node.respond_to?(:nokogiri_doc)
354
+ node.nokogiri_doc
355
+ end
356
+ end
357
+
358
+ # Wrap the first Nokogiri node in `list` that has a wrapper, skipping ones
359
+ # that don't (e.g. the DTD, which Dommy doesn't model as a child node).
360
+ def first_wrappable(list, ctx)
361
+ list.each do |nk|
362
+ w = document_for(ctx).wrap_node(nk)
363
+ return w if w
364
+ end
365
+ nil
366
+ end
367
+
256
368
  def first_wrapped_child(node)
257
- child_nk = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__.children.first : nil
258
- child_nk && document_for(node).wrap_node(child_nk)
369
+ bn = backend_node_of(node)
370
+ bn ? first_wrappable(bn.children, node) : nil
259
371
  end
260
372
 
261
373
  def last_wrapped_child(node)
262
- child_nk = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__.children.last : nil
263
- child_nk && document_for(node).wrap_node(child_nk)
374
+ bn = backend_node_of(node)
375
+ bn ? first_wrappable(bn.children.reverse, node) : nil
264
376
  end
265
377
 
266
378
  def next_sibling_wrapped(node)
267
379
  n = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__.next : nil
380
+ n = n.next while n && document_for(node).wrap_node(n).nil?
268
381
  n && document_for(node).wrap_node(n)
269
382
  end
270
383
 
271
384
  def previous_sibling_wrapped(node)
272
385
  n = node.respond_to?(:__dommy_backend_node__) ? node.__dommy_backend_node__.previous : nil
386
+ n = n.previous while n && document_for(node).wrap_node(n).nil?
273
387
  n && document_for(node).wrap_node(n)
274
388
  end
275
389
 
@@ -294,35 +408,45 @@ module Dommy
294
408
  @pointer_before_reference = true
295
409
  end
296
410
 
411
+ # WHATWG "traverse" (direction=next). referenceNode / pointerBeforeReferenceNode
412
+ # are committed only when a node is accepted; if no node matches we return null
413
+ # and leave the iterator's position untouched.
297
414
  def next_node
415
+ node = @reference_node
416
+ before = @pointer_before_reference
298
417
  loop do
299
- node = if @pointer_before_reference
300
- @reference_node
418
+ if before
419
+ before = false
301
420
  else
302
- next_in_document_order(@reference_node)
421
+ node = next_in_document_order(node)
422
+ return nil unless node
303
423
  end
304
424
 
305
- return nil unless node
425
+ next unless accept(node) == NodeFilter::FILTER_ACCEPT
306
426
 
307
427
  @reference_node = node
308
- @pointer_before_reference = false
309
- return node if accept(node) == NodeFilter::FILTER_ACCEPT
428
+ @pointer_before_reference = before
429
+ return node
310
430
  end
311
431
  end
312
432
 
433
+ # WHATWG "traverse" (direction=previous).
313
434
  def previous_node
435
+ node = @reference_node
436
+ before = @pointer_before_reference
314
437
  loop do
315
- node = if @pointer_before_reference
316
- previous_in_document_order(@reference_node)
438
+ if before
439
+ node = previous_in_document_order(node)
440
+ return nil unless node
317
441
  else
318
- @reference_node
442
+ before = true
319
443
  end
320
444
 
321
- return nil unless node
445
+ next unless accept(node) == NodeFilter::FILTER_ACCEPT
322
446
 
323
447
  @reference_node = node
324
- @pointer_before_reference = true
325
- return node if accept(node) == NodeFilter::FILTER_ACCEPT
448
+ @pointer_before_reference = before
449
+ return node
326
450
  end
327
451
  end
328
452
 
@@ -330,6 +454,30 @@ module Dommy
330
454
  nil
331
455
  end
332
456
 
457
+ # WHATWG "NodeIterator pre-removing steps". `removed` is the wrapped node
458
+ # about to be detached (still attached when this runs).
459
+ def pre_remove(removed)
460
+ # Terminate unless `removed` is in this iterator's collection and an
461
+ # inclusive ancestor of the reference node: skip if it is an inclusive
462
+ # ancestor of root (never in the collection) or not one of referenceNode.
463
+ return if inclusive_ancestor?(removed, @root)
464
+ return unless inclusive_ancestor?(removed, @reference_node)
465
+
466
+ unless @pointer_before_reference
467
+ @reference_node = tree_preceding(removed)
468
+ return
469
+ end
470
+
471
+ following = tree_next_descendants(removed)
472
+ if following
473
+ @reference_node = following
474
+ return
475
+ end
476
+
477
+ @reference_node = tree_preceding(removed)
478
+ @pointer_before_reference = false
479
+ end
480
+
333
481
  def __js_get__(key)
334
482
  case key
335
483
  when "root"
@@ -345,6 +493,8 @@ module Dommy
345
493
  end
346
494
  end
347
495
 
496
+ include Bridge::Methods
497
+ js_methods %w[nextNode previousNode detach]
348
498
  def __js_call__(method, _args)
349
499
  case method
350
500
  when "nextNode"
@@ -421,5 +571,44 @@ module Dommy
421
571
  def document_for(node)
422
572
  node.instance_variable_get(:@document) || @root.instance_variable_get(:@document) || @root
423
573
  end
574
+
575
+ def same_node?(node_a, node_b)
576
+ return false unless node_a && node_b
577
+ return true if node_a.equal?(node_b)
578
+
579
+ node_a.respond_to?(:__dommy_backend_node__) && node_b.respond_to?(:__dommy_backend_node__) &&
580
+ node_a.__dommy_backend_node__.equal?(node_b.__dommy_backend_node__)
581
+ end
582
+
583
+ def inclusive_ancestor?(ancestor, descendant)
584
+ current = descendant
585
+ while current
586
+ return true if same_node?(current, ancestor)
587
+
588
+ current = parent_node_of(current)
589
+ end
590
+ false
591
+ end
592
+
593
+ # The last node preceding `node` in tree order (common.js `previousNode`).
594
+ def tree_preceding(node)
595
+ sib = previous_sibling_node(node)
596
+ if sib
597
+ node = sib
598
+ while (last = last_child_node(node))
599
+ node = last
600
+ end
601
+ return node
602
+ end
603
+
604
+ parent_node_of(node)
605
+ end
606
+
607
+ # The first node following `node` and all its descendants in tree order
608
+ # (common.js `nextNodeDescendants`).
609
+ def tree_next_descendants(node)
610
+ node = parent_node_of(node) while node && next_sibling_node(node).nil?
611
+ node && next_sibling_node(node)
612
+ end
424
613
  end
425
614
  end