dommy 0.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -38
  3. data/lib/dommy/animation.rb +10 -2
  4. data/lib/dommy/attr.rb +197 -32
  5. data/lib/dommy/backend/nokogiri_adapter.rb +127 -0
  6. data/lib/dommy/backend/nokolexbor_adapter.rb +117 -0
  7. data/lib/dommy/backend.rb +175 -0
  8. data/lib/dommy/blob.rb +30 -11
  9. data/lib/dommy/bridge/constructor_registry.rb +28 -0
  10. data/lib/dommy/bridge/methods.rb +57 -0
  11. data/lib/dommy/bridge.rb +97 -0
  12. data/lib/dommy/callable_invoker.rb +36 -0
  13. data/lib/dommy/compression_streams.rb +4 -4
  14. data/lib/dommy/cookie_store.rb +4 -2
  15. data/lib/dommy/crypto.rb +16 -9
  16. data/lib/dommy/css.rb +53 -7
  17. data/lib/dommy/custom_elements.rb +33 -9
  18. data/lib/dommy/data_transfer.rb +4 -0
  19. data/lib/dommy/document.rb +693 -60
  20. data/lib/dommy/dom_parser.rb +29 -15
  21. data/lib/dommy/element.rb +1147 -438
  22. data/lib/dommy/event.rb +279 -79
  23. data/lib/dommy/event_source.rb +14 -10
  24. data/lib/dommy/fetch.rb +509 -39
  25. data/lib/dommy/file_reader.rb +14 -6
  26. data/lib/dommy/form_data.rb +3 -3
  27. data/lib/dommy/history.rb +46 -8
  28. data/lib/dommy/html_collection.rb +59 -6
  29. data/lib/dommy/html_elements.rb +153 -1502
  30. data/lib/dommy/internal/css_pseudo_handlers.rb +137 -0
  31. data/lib/dommy/internal/dom_matching.rb +3 -3
  32. data/lib/dommy/internal/global_functions.rb +26 -0
  33. data/lib/dommy/internal/idna.rb +16 -7
  34. data/lib/dommy/internal/ipv4_parser.rb +22 -7
  35. data/lib/dommy/internal/mutation_coordinator.rb +11 -2
  36. data/lib/dommy/internal/namespaces.rb +70 -0
  37. data/lib/dommy/internal/node_equality.rb +86 -0
  38. data/lib/dommy/internal/node_traversal.rb +1 -1
  39. data/lib/dommy/internal/node_wrapper_cache.rb +77 -31
  40. data/lib/dommy/internal/observable_callback.rb +1 -5
  41. data/lib/dommy/internal/parent_node.rb +126 -0
  42. data/lib/dommy/internal/reflected_attributes.rb +103 -13
  43. data/lib/dommy/internal/selector_parser.rb +664 -0
  44. data/lib/dommy/internal/template_content_registry.rb +6 -6
  45. data/lib/dommy/internal/url_parser.rb +677 -0
  46. data/lib/dommy/intersection_observer.rb +4 -2
  47. data/lib/dommy/location.rb +10 -4
  48. data/lib/dommy/media_query_list.rb +10 -4
  49. data/lib/dommy/message_channel.rb +41 -11
  50. data/lib/dommy/mutation_observer.rb +76 -23
  51. data/lib/dommy/navigator.rb +38 -24
  52. data/lib/dommy/node.rb +158 -16
  53. data/lib/dommy/notification.rb +6 -4
  54. data/lib/dommy/parser.rb +13 -13
  55. data/lib/dommy/performance.rb +4 -0
  56. data/lib/dommy/performance_observer.rb +4 -2
  57. data/lib/dommy/promise.rb +14 -14
  58. data/lib/dommy/range.rb +74 -5
  59. data/lib/dommy/resize_observer.rb +4 -2
  60. data/lib/dommy/scheduler.rb +34 -13
  61. data/lib/dommy/shadow_root.rb +31 -60
  62. data/lib/dommy/storage.rb +2 -0
  63. data/lib/dommy/streams.rb +40 -49
  64. data/lib/dommy/svg_elements.rb +204 -3606
  65. data/lib/dommy/text_codec.rb +178 -25
  66. data/lib/dommy/tree_walker.rb +270 -81
  67. data/lib/dommy/url.rb +305 -450
  68. data/lib/dommy/url_pattern.rb +2 -0
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/web_socket.rb +49 -19
  71. data/lib/dommy/window.rb +205 -203
  72. data/lib/dommy/worker.rb +12 -12
  73. data/lib/dommy/xml_http_request.rb +32 -7
  74. data/lib/dommy.rb +19 -2
  75. metadata +22 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ce9aec097da24f1f6a44792f44615605a1405483b6c6c5183031757d11a0853
4
- data.tar.gz: cf7c0e05839c313763590fcdb4d134459f5b1fe91ed1b479f819b18181cf1058
3
+ metadata.gz: ae1783921d75a534741bfa2de3be6ddd7492f01a6d63d4a3102b85411518e64d
4
+ data.tar.gz: 3022f80e295f06db17782b034d252e8529435ccae9e7ce7283e936da2cea8804
5
5
  SHA512:
6
- metadata.gz: 0db6e083fb62dd609fd719264a4d35e6bcadd6b97ce8515d08a8a8a53028e1f3f870224ef40ca33240688ddaf782d41e09d261c079c145144fe7b47b83288ef6
7
- data.tar.gz: d33e89e420bf0f93c2e478c93dd9241643ee47c40efa40927ff77714f20b92af561330b2cbedcaa287951cd07aab4ecfb899e24371fe8d6e2b20f289f6973b53
6
+ metadata.gz: c3ccc2658999f785bab29f01e94bca1d7c0505370256fb55687cca8822b0e4dcdd8efb1d64d172a3a7dced5a3a4224612bc38d8df48f943088d56bd81bc85cd9
7
+ data.tar.gz: dc65af8b414579d71d72601e1c1f3ac070ed6e4a67a7385cbc17222ed9b896687a494709f974f75af192d2441f12de3cc6be0f4eaaa263198f5bc868335193ab
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,8 +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
180
- - SVG: SVGElement base + ~63 specialized subclasses covering shapes, gradients, marker / mask, the full set of standard filter primitives (Gaussian blur, offset, blend, color matrix, flood, composite, merge, component transfer, tile, morphology, image, drop shadow, turbulence, displacement map, convolve matrix, diffuse / specular lighting + light sources), `<a>` / `<textPath>` / `<view>` / `<switch>` / `<metadata>`, and SMIL animation (`<animate>` / `<animateTransform>` / `<animateMotion>` / `<set>` / `<mpath>` / `<discard>`) — with case-sensitive attribute round-trip
181
+ - Specialized HTML and SVG element classes
181
182
  - events with composedPath / AbortSignal
182
183
  - MutationObserver (childList / attributes / characterData / subtree)
183
184
  - Custom Elements lifecycle
@@ -185,47 +186,38 @@ Implemented:
185
186
  - form validation
186
187
  - Scheduler (timers + microtasks with `advance_time`)
187
188
  - Promise
188
- - Location / History / URL (WHATWG-leaning parsing with whitespace/tab/newline stripping, percent-encoding of unsafe path chars, `\` → `/` conversion for special schemes, `./` and `../` resolution, IPv4 number forms normalization, ws/wss default port stripping, Punycode hostname encoding, full UTS #46 IDNA with RFC 5893 Bidi and RFC 5892 ContextJ/ContextO)
189
+ - Location / History / URL
189
190
  - Storage
190
- - fetch (stub) / XMLHttpRequest (stub-driven, sync + async, shares the `__fetchy_stub__` fixture map)
191
- - WebSocket / EventSource — test seams (`__simulate_open__` / `__simulate_message__` / `__simulate_close__`) drive the streams
192
- - MessageChannel / MessagePort / BroadcastChannel — in-process pub/sub with `structuredClone`-on-transfer
193
- - FileReader (`readAsText` / `readAsDataURL` / `readAsArrayBuffer` / `readAsBinaryString`)
194
- - Notification (permission settable via `Notification.__set_permission__`)
195
- - Geolocation (mock position via `navigator.geolocation.__set_position__`)
196
- - `window.matchMedia` returning a `MediaQueryList` (`__set_matches__` flips and fires `change`)
197
- - `requestIdleCallback` / `cancelIdleCallback` (modelled on scheduler), `structuredClone` global
198
- - `crypto.subtle.digest` (SHA-1/256/384/512), HMAC sign/verify/import/generateKey, and AES-GCM encrypt/decrypt (128/256-bit with additionalData and tagLength options)
199
- - Streams API (`ReadableStream` / `WritableStream` / `TransformStream` + `TextEncoderStream` / `TextDecoderStream`)
200
- - `CompressionStream` / `DecompressionStream` (gzip / deflate / deflate-raw via Ruby `Zlib`)
201
- - Worker (inline-emulated — same-process message round-trip; tests register handlers via `worker.__on_message__`)
202
- - Performance User Timing (`performance.mark` / `measure` / `getEntriesByName`)
203
- - `cookieStore` (async Cookie Store API, backed by the same jar `document.cookie` uses)
204
- - Navigator extras: `share` / `vibrate` / `wakeLock.request` / `getBattery` / `locks` (Web Locks) / `storage.estimate` / `persist` / `persisted` (StorageManager)
205
- - Layout-adjacent stubs: `element.scrollIntoView` / `scrollTo` / scroll & client / offset metrics (return 0), `getComputedStyle` (inline style passthrough)
206
- - Popover API (`showPopover` / `hidePopover` / `togglePopover` with `beforetoggle` / `toggle` events)
207
- - Fullscreen API (`element.requestFullscreen` / `document.exitFullscreen` / `fullscreenchange`)
208
- - `URLPattern` (pattern matching for URL components — protocol, username, password, hostname, port, pathname, search, hash — with named capture groups and modifiers: `:id`, `*`, `:version+`, `:version?`)
209
- - `document.startViewTransition` (View Transitions API 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
210
198
  - Navigator / Clipboard
211
199
  - TreeWalker / NodeIterator / NodeFilter
212
200
  - File API (Blob / File / FileList / FormData / DataTransfer)
213
- - Web Crypto (`crypto.randomUUID`, `getRandomValues`) / TextEncoder / TextDecoder
214
- - IntersectionObserver / ResizeObserver / PerformanceObserver (test-driven `__trigger__`)
201
+ - IntersectionObserver / ResizeObserver / PerformanceObserver (test-driven `__test_trigger__`)
215
202
  - Range / Selection (DOM-level only, no layout)
216
- - Web Animations API (Animation / KeyframeEffect; lifecycle via scheduler, finished/ready Promises)
203
+ - Web Animations API (Animation / KeyframeEffect)
217
204
  - Extended events: Touch / Clipboard / Composition / Wheel / Focus / BeforeUnload / Input / Pointer / Progress / Drag
218
205
 
206
+ For implementation notes and tradeoffs, see [design.md](./design.md).
207
+
219
208
  > [!IMPORTANT]
220
209
  > Out of scope:
221
210
  >
222
- > - requires a layout / CSS engine or media subsystems: real `getBoundingClientRect` / scroll metrics
223
- > - CSS scoping (`:host`, `::slotted`, computed styles)
211
+ > - layout and CSS-engine behavior
224
212
  > - JS evaluation
225
213
  > - Canvas / WebGL / media playback
226
- > - layout-dependent Range / Selection geometry (`getBoundingClientRect` returns zero rects)
227
- > - SVG-specific value types (SVGAnimatedLength, SVGTransform, SVGMatrix)
228
- > - Web Animations: no actual value interpolation — `Animation` is a state machine (`idle` / `running` / `paused` / `finished`) for testing lifecycle and event wiring
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.
229
221
 
230
222
  ## Running the tests
231
223
 
@@ -48,6 +48,8 @@ module Dommy
48
48
  end
49
49
  end
50
50
 
51
+ include Bridge::Methods
52
+ js_methods %w[getTiming updateTiming]
51
53
  def __js_call__(method, args)
52
54
  case method
53
55
  when "getTiming"
@@ -217,11 +219,17 @@ module Dommy
217
219
  @playback_rate = value.to_f
218
220
  when "id"
219
221
  @id = value.to_s
222
+ else
223
+ return Bridge::UNHANDLED
220
224
  end
221
225
 
222
226
  nil
223
227
  end
224
228
 
229
+ include Bridge::Methods
230
+ js_methods %w[
231
+ play pause cancel finish reverse addEventListener removeEventListener dispatchEvent
232
+ ]
225
233
  def __js_call__(method, args)
226
234
  case method
227
235
  when "play"
@@ -237,14 +245,14 @@ module Dommy
237
245
  when "addEventListener"
238
246
  add_event_listener(args[0], args[1], args[2])
239
247
  when "removeEventListener"
240
- remove_event_listener(args[0], args[1])
248
+ remove_event_listener(args[0], args[1], args[2])
241
249
  when "dispatchEvent"
242
250
  dispatch_event(args[0])
243
251
  end
244
252
  end
245
253
 
246
254
  # Event bubbling stops at Animation — it isn't part of the DOM tree.
247
- def __event_parent__
255
+ def __internal_event_parent__
248
256
  nil
249
257
  end
250
258
 
data/lib/dommy/attr.rb CHANGED
@@ -13,12 +13,32 @@ module Dommy
13
13
  # not yet attached. Value is stored locally; `setAttributeNode`
14
14
  # transfers it to an element.
15
15
  class Attr
16
- attr_reader :name
16
+ include Node
17
+ include EventTarget
17
18
 
18
- def initialize(name, owner: nil, value: "")
19
- @name = name.to_s.downcase
19
+ attr_reader :name, :namespace_uri, :prefix, :local_name
20
+
21
+ def __internal_event_parent__
22
+ nil
23
+ end
24
+
25
+ def initialize(name, owner: nil, value: "", namespace_uri: nil, prefix: nil, local_name: nil)
26
+ qname = name.to_s
20
27
  @owner = owner
21
28
  @detached_value = value.to_s
29
+ if namespace_uri && !namespace_uri.to_s.empty?
30
+ # Namespaced attributes preserve case and carry prefix / localName.
31
+ @name = qname
32
+ @namespace_uri = namespace_uri.to_s
33
+ @prefix = prefix
34
+ @local_name = (local_name || qname.split(":", 2).last).to_s
35
+ else
36
+ # Null-namespace (HTML) attributes are lower-cased, as before.
37
+ @name = qname.downcase
38
+ @namespace_uri = nil
39
+ @prefix = nil
40
+ @local_name = @name
41
+ end
22
42
  end
23
43
 
24
44
  # The Element this attr is on, or nil if detached.
@@ -28,7 +48,11 @@ module Dommy
28
48
 
29
49
  def value
30
50
  if @owner
31
- @owner.__node__[@name].to_s
51
+ if @namespace_uri
52
+ Backend.get_attribute_ns(@owner.__dommy_backend_node__, @namespace_uri, @local_name).to_s
53
+ else
54
+ @owner.__dommy_backend_node__[@name].to_s
55
+ end
32
56
  else
33
57
  @detached_value
34
58
  end
@@ -36,7 +60,11 @@ module Dommy
36
60
 
37
61
  def value=(new_value)
38
62
  if @owner
39
- @owner.set_attribute(@name, new_value.to_s)
63
+ if @namespace_uri
64
+ @owner.set_attribute_ns(@namespace_uri, @name, new_value.to_s)
65
+ else
66
+ @owner.set_attribute(@name, new_value.to_s)
67
+ end
40
68
  else
41
69
  @detached_value = new_value.to_s
42
70
  end
@@ -52,42 +80,77 @@ module Dommy
52
80
  @name
53
81
  when "nodeValue"
54
82
  value
83
+ when "textContent"
84
+ # Node.textContent for an Attr returns its value (WHATWG DOM).
85
+ value
55
86
  when "ownerElement"
56
87
  @owner
57
88
  when "localName"
58
- @name
89
+ @local_name
59
90
  when "namespaceURI"
60
- nil
91
+ @namespace_uri
92
+ when "prefix"
93
+ @prefix
61
94
  when "nodeType"
62
95
  2
96
+ when "specified"
97
+ # Legacy/useless attribute — always true (WHATWG DOM).
98
+ true
63
99
  end
64
100
  end
65
101
 
66
102
  def __js_set__(key, val)
67
103
  case key
68
- when "value", "nodeValue"
104
+ when "value", "nodeValue", "textContent"
69
105
  self.value = val
106
+ else
107
+ return Bridge::UNHANDLED
70
108
  end
71
109
 
72
110
  nil
73
111
  end
74
112
 
75
- def __js_call__(method, _args)
113
+ include Bridge::Methods
114
+ js_methods %w[cloneNode isSameNode getRootNode hasChildNodes normalize compareDocumentPosition
115
+ appendChild insertBefore removeChild replaceChild
116
+ addEventListener removeEventListener dispatchEvent]
117
+ def __js_call__(method, args)
76
118
  case method
77
119
  when "cloneNode"
78
- Attr.new(@name, owner: nil, value: value)
120
+ Attr.new(@name, owner: nil, value: value,
121
+ namespace_uri: @namespace_uri, prefix: @prefix, local_name: @local_name)
122
+ when "isSameNode"
123
+ is_same_node(args[0])
124
+ when "getRootNode"
125
+ get_root_node(args[0])
126
+ when "compareDocumentPosition"
127
+ compare_document_position(args[0])
128
+ when "appendChild", "insertBefore"
129
+ raise DOMException::HierarchyRequestError, "an Attr may not have children"
130
+ when "removeChild", "replaceChild"
131
+ raise DOMException::NotFoundError, "the node to be removed is not a child of this node"
132
+ when "hasChildNodes"
133
+ false
134
+ when "normalize"
135
+ nil
136
+ when "addEventListener"
137
+ add_event_listener(args[0], args[1], args[2])
138
+ when "removeEventListener"
139
+ remove_event_listener(args[0], args[1], args[2])
140
+ when "dispatchEvent"
141
+ dispatch_event(args[0])
79
142
  end
80
143
  end
81
144
 
82
145
  # Internal: called by Element when the attr is being transferred
83
146
  # to (or detached from) an Element.
84
- def __attach__(element)
147
+ def __internal_attach__(element)
85
148
  @owner = element
86
149
  @detached_value = ""
87
150
  nil
88
151
  end
89
152
 
90
- def __detach__
153
+ def __internal_detach__
91
154
  cached = value
92
155
  @owner = nil
93
156
  @detached_value = cached
@@ -106,49 +169,134 @@ module Dommy
106
169
 
107
170
  def initialize(element)
108
171
  @element = element
172
+ # Attr-node identity cache, keyed by [namespace_or_nil, localName].
173
+ # Every accessor (item / index / getNamedItem(NS)) returns the SAME
174
+ # Attr object for a given underlying attribute, per the DOM.
175
+ @attrs = {}
109
176
  end
110
177
 
111
178
  def length
112
- @element.__node__.attribute_nodes.size
179
+ Backend.attribute_nodes(@element.__dommy_backend_node__).size
113
180
  end
114
181
 
115
182
  alias size length
116
183
 
117
184
  def item(index)
118
- name = @element.__node__.attribute_nodes[index.to_i]&.name
119
- name && Attr.new(name, owner: @element)
185
+ node = Backend.attribute_nodes(@element.__dommy_backend_node__)[index.to_i]
186
+ node && attr_for(node)
187
+ end
188
+
189
+ # Return the cached Attr for a backend attribute node, creating (and
190
+ # caching) one on first access so DOM node identity holds.
191
+ def attr_for(attr_node)
192
+ info = Backend.attribute_ns_info(attr_node)
193
+ key = [info[:namespace_uri], info[:local_name]]
194
+ cached = @attrs[key]
195
+ return cached if cached && cached.owner_element.equal?(@element)
196
+
197
+ attr = Attr.new(info[:qualified_name], owner: @element,
198
+ namespace_uri: info[:namespace_uri],
199
+ prefix: info[:prefix],
200
+ local_name: info[:local_name])
201
+ @attrs[key] = attr
202
+ attr
120
203
  end
121
204
 
122
205
  def get_named_item(name)
123
206
  key = name.to_s.downcase
124
- return nil unless @element.__node__.key?(key)
125
-
126
- Attr.new(key, owner: @element)
207
+ node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
208
+ Backend.attribute_ns_info(a)[:qualified_name] == key
209
+ end
210
+ node && attr_for(node)
127
211
  end
128
212
 
129
213
  def set_named_item(attr)
130
- return nil unless attr.is_a?(Attr)
131
-
132
- key = attr.name
133
- val = attr.value
134
- attr.__attach__(@element)
135
- @element.set_attribute(key, val)
136
- attr
214
+ set_attribute_node(attr)
137
215
  end
138
216
 
139
217
  def remove_named_item(name)
140
218
  key = name.to_s.downcase
141
- return nil unless @element.__node__.key?(key)
219
+ node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
220
+ Backend.attribute_ns_info(a)[:qualified_name] == key
221
+ end
222
+ return nil unless node
142
223
 
143
- attr = Attr.new(key, owner: nil, value: @element.__node__[key].to_s)
224
+ removed = attr_for(node)
144
225
  @element.remove_attribute(key)
145
- attr
226
+ removed
227
+ end
228
+
229
+ def each
230
+ Backend.attribute_nodes(@element.__dommy_backend_node__).each do |a|
231
+ yield attr_for(a)
232
+ end
233
+ end
234
+
235
+ # WHATWG "set an attribute" / "set attribute node". Adopts `attr` (the
236
+ # exact object — identity is preserved), replacing any attribute with the
237
+ # same (namespace, localName) and returning the previous Attr (detached),
238
+ # or nil. Throws InUseAttributeError if `attr` is bound to another element.
239
+ def set_attribute_node(attr)
240
+ return nil unless attr.is_a?(Attr)
241
+
242
+ owner = attr.owner_element
243
+ if owner && !owner.equal?(@element)
244
+ raise DOMException::InUseAttributeError, "attribute is in use by another element"
245
+ end
246
+
247
+ ns = attr.namespace_uri
248
+ local = attr.local_name
249
+ old = get_named_item_ns(ns, local)
250
+ return attr if old && old.equal?(attr)
251
+
252
+ value = attr.value
253
+ key = [ns, local]
254
+ if old
255
+ old.__internal_detach__
256
+ @attrs.delete(key)
257
+ end
258
+ attr.__internal_attach__(@element)
259
+ if ns
260
+ @element.set_attribute_ns(ns, attr.name, value)
261
+ else
262
+ @element.set_attribute(attr.name, value)
263
+ end
264
+ @attrs[key] = attr
265
+ old
266
+ end
267
+
268
+ # Detach and evict the cached Attr for (namespace, localName), if any —
269
+ # called by Element after the underlying attribute is removed so a held
270
+ # reference reports `ownerElement === null`.
271
+ def __internal_evict__(namespace, local_name)
272
+ key = [namespace.to_s.empty? ? nil : namespace.to_s, local_name.to_s]
273
+ attr = @attrs.delete(key)
274
+ attr&.__internal_detach__
275
+ nil
146
276
  end
147
277
 
148
- def each(&blk)
149
- @element.__node__.attribute_nodes.each do |a|
150
- yield Attr.new(a.name, owner: @element)
278
+ # ----- Namespaced named-item access (getNamedItemNS etc.) -----
279
+
280
+ def get_named_item_ns(namespace, local_name)
281
+ node = Backend.attribute_nodes(@element.__dommy_backend_node__).find do |a|
282
+ info = Backend.attribute_ns_info(a)
283
+ info[:local_name] == local_name.to_s &&
284
+ (info[:namespace_uri] || nil) == (namespace.to_s.empty? ? nil : namespace.to_s)
151
285
  end
286
+ node && attr_for(node)
287
+ end
288
+
289
+ # setNamedItemNS shares the "set an attribute" algorithm with setNamedItem.
290
+ def set_named_item_ns(attr)
291
+ set_attribute_node(attr)
292
+ end
293
+
294
+ def remove_named_item_ns(namespace, local_name)
295
+ existing = get_named_item_ns(namespace, local_name)
296
+ return nil unless existing
297
+
298
+ @element.remove_attribute_ns(namespace, local_name)
299
+ existing
152
300
  end
153
301
 
154
302
  # Property-style access — `el.attributes.id`, `el.attributes["class"]`.
@@ -175,6 +323,17 @@ module Dommy
175
323
  end
176
324
  end
177
325
 
326
+ # WebIDL "supported property names" for NamedNodeMap: the qualified name of
327
+ # each attribute, in order (the indexed names are reflected separately).
328
+ def __js_named_props__
329
+ Backend.attribute_nodes(@element.__dommy_backend_node__).map do |a|
330
+ Backend.attribute_ns_info(a)[:qualified_name]
331
+ end
332
+ end
333
+
334
+ include Bridge::Methods
335
+ js_methods %w[item getNamedItem setNamedItem removeNamedItem
336
+ getNamedItemNS setNamedItemNS removeNamedItemNS]
178
337
  def __js_call__(method, args)
179
338
  case method
180
339
  when "item"
@@ -185,6 +344,12 @@ module Dommy
185
344
  set_named_item(args[0])
186
345
  when "removeNamedItem"
187
346
  remove_named_item(args[0])
347
+ when "getNamedItemNS"
348
+ get_named_item_ns(args[0], args[1])
349
+ when "setNamedItemNS"
350
+ set_named_item_ns(args[0])
351
+ when "removeNamedItemNS"
352
+ remove_named_item_ns(args[0], args[1])
188
353
  end
189
354
  end
190
355
 
@@ -194,7 +359,7 @@ module Dommy
194
359
  end
195
360
 
196
361
  def respond_to_missing?(name, include_private = false)
197
- @element.__node__.key?(name.to_s.downcase) || super
362
+ @element.__dommy_backend_node__.key?(name.to_s.downcase) || super
198
363
  end
199
364
  end
200
365
  end
@@ -0,0 +1,127 @@
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
+ # Parse an XML string into an XML document (DOMParser "text/xml" etc.).
25
+ def parse_xml(xml)
26
+ ::Nokogiri::XML(xml.to_s)
27
+ end
28
+
29
+ def fragment(html, owner_doc:)
30
+ # owner_doc is unused by Nokogiri — the fragment carries its
31
+ # own document. The Parser layer copies nodes into the target.
32
+ ::Nokogiri::HTML5.fragment(html.to_s, max_errors: 0)
33
+ end
34
+
35
+ def create_element(name, doc)
36
+ ::Nokogiri::XML::Node.new(name, doc)
37
+ end
38
+
39
+ def create_text(content, doc)
40
+ ::Nokogiri::XML::Text.new(content, doc)
41
+ end
42
+
43
+ def create_comment(content, doc)
44
+ ::Nokogiri::XML::Comment.new(doc, content)
45
+ end
46
+
47
+ def create_cdata(content, doc)
48
+ ::Nokogiri::XML::CDATA.new(doc, content.to_s)
49
+ end
50
+
51
+ def cdata_class = ::Nokogiri::XML::CDATA
52
+
53
+ def namespace_of(node)
54
+ node.namespace
55
+ end
56
+
57
+ def add_namespace_definition(node, prefix, href)
58
+ node.add_namespace_definition(prefix, href)
59
+ end
60
+
61
+ # ----- Namespaced attributes -----
62
+
63
+ def get_attribute_ns(node, namespace, local_name)
64
+ find_attr_ns(node, namespace, local_name)&.value
65
+ end
66
+
67
+ def has_attribute_ns?(node, namespace, local_name)
68
+ !find_attr_ns(node, namespace, local_name).nil?
69
+ end
70
+
71
+ def set_attribute_ns(node, namespace, prefix, local_name, qualified_name, value)
72
+ # WHATWG "set an attribute value": when an attribute with this
73
+ # (namespace, localName) already exists, only its value changes —
74
+ # the existing prefix/qualified name is preserved, not replaced by
75
+ # the one in this call.
76
+ existing = find_attr_ns(node, namespace, local_name)
77
+ if existing
78
+ existing.value = value.to_s
79
+ return value.to_s
80
+ end
81
+
82
+ if namespace.nil? || namespace.to_s.empty?
83
+ node[qualified_name] = value.to_s
84
+ return value.to_s
85
+ end
86
+
87
+ # Defining the namespace before the qualified-name assignment lets
88
+ # libxml2 bind the prefix to it (verified behavior). Reuse an existing
89
+ # matching definition so repeated sets don't pile up declarations.
90
+ node.namespace_definitions.find { |d| d.href == namespace.to_s && d.prefix == prefix } ||
91
+ node.add_namespace_definition(prefix, namespace.to_s)
92
+ node[qualified_name] = value.to_s
93
+ value.to_s
94
+ end
95
+
96
+ def remove_attribute_ns(node, namespace, local_name)
97
+ find_attr_ns(node, namespace, local_name)&.remove
98
+ nil
99
+ end
100
+
101
+ def attribute_ns_info(attr_node)
102
+ ns = attr_node.namespace
103
+ {
104
+ namespace_uri: ns&.href,
105
+ prefix: ns&.prefix,
106
+ local_name: attr_node.name,
107
+ qualified_name: ns&.prefix ? "#{ns.prefix}:#{attr_node.name}" : attr_node.name,
108
+ value: attr_node.value,
109
+ }
110
+ end
111
+
112
+ def attribute_nodes(node)
113
+ node.attribute_nodes
114
+ end
115
+
116
+ # Finds the attribute node matching (namespace, localName). A null
117
+ # namespace matches only the un-namespaced attribute of that local name.
118
+ def find_attr_ns(node, namespace, local_name)
119
+ if namespace.nil? || namespace.to_s.empty?
120
+ node.attribute_nodes.find { |a| a.namespace.nil? && a.name == local_name.to_s }
121
+ else
122
+ node.attribute_with_ns(local_name.to_s, namespace.to_s)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end