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
@@ -46,6 +46,9 @@ module Dommy
46
46
 
47
47
  attr_reader :body, :nokogiri_doc
48
48
  attr_accessor :default_view
49
+ # content_type defaults to "text/html"; settable so an integration layer
50
+ # can reflect the response Content-Type. Read-only over the JS bridge.
51
+ attr_accessor :content_type
49
52
 
50
53
  def initialize(host = nil, nokogiri_doc: nil, default_view: nil)
51
54
  @host = host
@@ -56,9 +59,10 @@ module Dommy
56
59
  @cookie_jar = Internal::CookieJar.new
57
60
  @template_content_registry = Internal::TemplateContentRegistry.new(self)
58
61
  @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
59
- @nokogiri_doc = nokogiri_doc || Nokogiri::HTML5("<!doctype html><html><head></head><body></body></html>")
62
+ @nokogiri_doc = nokogiri_doc || Backend.parse("<!doctype html><html><head></head><body></body></html>")
60
63
  body_node = @nokogiri_doc.at_css("body")
61
64
  @body = wrap_node(body_node) if body_node
65
+ @content_type = "text/html"
62
66
  end
63
67
 
64
68
  # ----- Public Ruby API (snake_case) -----
@@ -79,6 +83,21 @@ module Dommy
79
83
  wrap_node(@nokogiri_doc.at_css("head"))
80
84
  end
81
85
 
86
+ # Serialize the whole document to HTML (including the doctype).
87
+ def to_html
88
+ @nokogiri_doc.to_html
89
+ end
90
+
91
+ # XPath queries returning wrapped nodes (Element / TextNode / etc).
92
+ def at_xpath(expression)
93
+ node = @nokogiri_doc.at_xpath(expression)
94
+ node && wrap_node(node)
95
+ end
96
+
97
+ def xpath(expression)
98
+ @nokogiri_doc.xpath(expression).map { |node| wrap_node(node) }
99
+ end
100
+
82
101
  # `document.URL` / `documentURI` — both return location.href in
83
102
  # real browsers (legacy aliases of the same field).
84
103
  def url
@@ -116,6 +135,15 @@ module Dommy
116
135
  view.location.__js_get__("hostname").to_s
117
136
  end
118
137
 
138
+ # `document.origin` — serialized origin of the document URL, mirroring
139
+ # `window.location.origin`. Empty when there is no associated window.
140
+ def origin
141
+ view = @default_view
142
+ return "" unless view&.location
143
+
144
+ view.location.__js_get__("origin").to_s
145
+ end
146
+
119
147
  # `document.referrer` — Dommy never has a referring page, so this
120
148
  # is always empty.
121
149
  def referrer
@@ -175,7 +203,7 @@ module Dommy
175
203
  @active_element || @body
176
204
  end
177
205
 
178
- def __set_active_element__(el)
206
+ def __internal_set_active_element__(el)
179
207
  @active_element = el
180
208
  end
181
209
 
@@ -202,9 +230,9 @@ module Dommy
202
230
  # wrapper is owned by `this`. Per spec, the source node is left
203
231
  # in place. `deep: true` copies the entire subtree.
204
232
  def import_node(node, deep = false)
205
- return nil unless node.respond_to?(:__node__)
233
+ return nil unless node.respond_to?(:__dommy_backend_node__)
206
234
 
207
- copy = clone_into_doc(node.__node__, deep)
235
+ copy = clone_into_doc(node.__dommy_backend_node__, deep)
208
236
  wrap_node(copy)
209
237
  end
210
238
 
@@ -212,17 +240,32 @@ module Dommy
212
240
  # node is detached from its previous owner and its ownerDocument
213
241
  # becomes this. Returns the (possibly re-wrapped) node.
214
242
  def adopt_node(node)
215
- return nil unless node.respond_to?(:__node__)
243
+ return nil unless node.respond_to?(:__dommy_backend_node__)
216
244
 
217
- src = node.__node__
245
+ src = node.__dommy_backend_node__
218
246
  src.unlink if src.parent
219
- moved = if src.document == @nokogiri_doc
220
- src
221
- else
222
- clone_into_doc(src, true)
223
- end
224
247
 
225
- wrap_node(moved)
248
+ # Same document: just return the wrapper after the detach above.
249
+ return wrap_node(src) if src.document == @nokogiri_doc
250
+
251
+ # Cross-document: Nokogiri reassigns `src.document` when src is
252
+ # added under a node owned by another document. We transiently
253
+ # attach to our root, then unlink so src ends up free-floating
254
+ # but now belongs to @nokogiri_doc. The underlying Ruby object
255
+ # identity is preserved.
256
+ src_doc_wrapper = node.instance_variable_get(:@document)
257
+ @nokogiri_doc.root.add_child(src)
258
+ src.unlink
259
+
260
+ # Move the caller's Dommy wrapper from the source document's
261
+ # wrapper cache into ours, and re-point its @document. This
262
+ # keeps `adopt_node(x).equal?(x)` true across documents.
263
+ node.instance_variable_set(:@document, self)
264
+ if src_doc_wrapper.respond_to?(:__internal_reset_wrapper__)
265
+ src_doc_wrapper.__internal_reset_wrapper__(src)
266
+ end
267
+ @node_wrapper_cache.register(src, node)
268
+ node
226
269
  end
227
270
 
228
271
  # Legacy `document.createEvent("EventName")` factory. Returns an
@@ -256,9 +299,36 @@ module Dommy
256
299
  alias has_focus has_focus?
257
300
 
258
301
  def get_selection
259
- nil
302
+ @__selection ||= Selection.new(self)
303
+ end
304
+
305
+ def create_range
306
+ Range.new(self)
260
307
  end
261
308
 
309
+ # Fullscreen API — no actual fullscreen mode, just track which
310
+ # element claimed it. `element.requestFullscreen()` sets it; this
311
+ # is the read side.
312
+ attr_reader :fullscreen_element
313
+
314
+ def __internal_set_fullscreen_element__(element)
315
+ previous = @fullscreen_element
316
+ @fullscreen_element = element
317
+ return if previous == element
318
+
319
+ dispatch_event(Event.new("fullscreenchange"))
320
+ end
321
+
322
+ def exit_fullscreen
323
+ return PromiseValue.resolve(@default_view, nil) if @fullscreen_element.nil?
324
+
325
+ @fullscreen_element = nil
326
+ dispatch_event(Event.new("fullscreenchange"))
327
+ PromiseValue.resolve(@default_view, nil)
328
+ end
329
+
330
+ alias exitFullscreen exit_fullscreen
331
+
262
332
  def element_from_point(_x, _y)
263
333
  nil
264
334
  end
@@ -312,8 +382,8 @@ module Dommy
312
382
  fragment = Parser.fragment(html, owner_doc: @nokogiri_doc)
313
383
  removed = []
314
384
  added = fragment.children.to_a
315
- added.each { |node| @body.__node__.add_child(node) }
316
- notify_child_list_mutation(target_node: @body.__node__, added_nodes: added, removed_nodes: removed)
385
+ added.each { |node| @body.__dommy_backend_node__.add_child(node) }
386
+ notify_child_list_mutation(target_node: @body.__dommy_backend_node__, added_nodes: added, removed_nodes: removed)
317
387
  nil
318
388
  end
319
389
 
@@ -359,6 +429,12 @@ module Dommy
359
429
  doctype
360
430
  when "defaultView"
361
431
  @default_view
432
+ when "fullscreenElement"
433
+ @fullscreen_element
434
+ when "fullscreenEnabled"
435
+ true
436
+ when "scrollingElement"
437
+ wrap_node(@nokogiri_doc.at_css("html"))
362
438
  when "documentElement"
363
439
  wrap_node(@nokogiri_doc.at_css("html"))
364
440
  when "title"
@@ -375,6 +451,10 @@ module Dommy
375
451
  base_uri
376
452
  when "domain"
377
453
  domain
454
+ when "origin"
455
+ origin
456
+ when "contentType"
457
+ content_type
378
458
  when "referrer"
379
459
  referrer
380
460
  when "links"
@@ -411,8 +491,35 @@ module Dommy
411
491
  nil
412
492
  end
413
493
 
494
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
495
+ JS_METHOD_NAMES = %w[
496
+ exitFullscreen startViewTransition createElement createElementNS createTextNode
497
+ createComment createDocumentFragment querySelector querySelectorAll getElementById
498
+ getElementsByClassName getElementsByTagName getElementsByName createAttribute
499
+ createAttributeNS createTreeWalker createNodeIterator createEvent importNode adoptNode
500
+ hasFocus getSelection elementFromPoint queryCommandSupported addEventListener
501
+ removeEventListener dispatchEvent write open close
502
+ ].freeze
503
+ def __js_method_names__
504
+ JS_METHOD_NAMES
505
+ end
506
+
414
507
  def __js_call__(method, args)
415
508
  case method
509
+ when "exitFullscreen"
510
+ exit_fullscreen
511
+ when "startViewTransition"
512
+ # View Transitions API stub. Spec: invoke the callback
513
+ # synchronously; return a ViewTransition with already-resolved
514
+ # `finished` / `ready` / `updateCallbackDone` promises.
515
+ callback = args[0]
516
+ if callback.respond_to?(:__js_call__)
517
+ callback.__js_call__("call", [])
518
+ elsif callback.respond_to?(:call)
519
+ callback.call
520
+ end
521
+
522
+ ViewTransition.new(@default_view)
416
523
  when "createElement"
417
524
  create_element(args[0])
418
525
  when "createElementNS"
@@ -474,7 +581,7 @@ module Dommy
474
581
  end
475
582
  end
476
583
 
477
- def __event_parent__
584
+ def __internal_event_parent__
478
585
  @default_view
479
586
  end
480
587
 
@@ -486,7 +593,7 @@ module Dommy
486
593
  # Clear the cached wrapper so the next `wrap_node` creates a new
487
594
  # one. Used by `customElements.define` to upgrade nodes that were
488
595
  # constructed before the registration landed.
489
- def __reset_wrapper__(nokogiri_node)
596
+ def __internal_reset_wrapper__(nokogiri_node)
490
597
  @node_wrapper_cache.reset_wrapper(nokogiri_node)
491
598
  end
492
599
 
@@ -496,15 +603,15 @@ module Dommy
496
603
  # node back to its shadow boundary.
497
604
  # Delegate to ShadowRootRegistry
498
605
 
499
- def __register_shadow_fragment__(fragment_node, shadow_root)
606
+ def __internal_register_shadow_fragment__(fragment_node, shadow_root)
500
607
  @shadow_registry.register(fragment_node, shadow_root)
501
608
  end
502
609
 
503
- def __shadow_root_for_fragment__(fragment_node)
610
+ def __internal_shadow_root_for_fragment__(fragment_node)
504
611
  @shadow_registry.find_for_fragment(fragment_node)
505
612
  end
506
613
 
507
- def __shadow_root_containing__(node)
614
+ def __internal_shadow_root_containing__(node)
508
615
  @shadow_registry.find_enclosing(node)
509
616
  end
510
617
 
@@ -513,23 +620,23 @@ module Dommy
513
620
  # break the whole mutation pipeline.
514
621
  # Delegate to MutationCoordinator
515
622
 
516
- def __notify_connected__(element)
623
+ def __internal_notify_connected__(element)
517
624
  @mutation_coordinator.notify_connected(element)
518
625
  end
519
626
 
520
- def __notify_disconnected__(element)
627
+ def __internal_notify_disconnected__(element)
521
628
  @mutation_coordinator.notify_disconnected(element)
522
629
  end
523
630
 
524
- def __notify_connected_subtree__(nk)
631
+ def __internal_notify_connected_subtree__(nk)
525
632
  @mutation_coordinator.notify_connected_subtree(nk)
526
633
  end
527
634
 
528
- def __notify_disconnected_subtree__(nk)
635
+ def __internal_notify_disconnected_subtree__(nk)
529
636
  @mutation_coordinator.notify_disconnected_subtree(nk)
530
637
  end
531
638
 
532
- def __notify_attribute_changed__(element, name, old_value, new_value)
639
+ def __internal_notify_attribute_changed__(element, name, old_value, new_value)
533
640
  @mutation_coordinator.notify_attribute_changed(element, name, old_value, new_value)
534
641
  end
535
642
 
@@ -628,17 +735,17 @@ module Dommy
628
735
  # adoptNode for cross-document transfer.
629
736
  def clone_into_doc(source, deep)
630
737
  copy = if source.element?
631
- new_el = Nokogiri::XML::Node.new(source.name, @nokogiri_doc)
738
+ new_el = Backend.create_element(source.name, @nokogiri_doc)
632
739
  source.attribute_nodes.each { |a| new_el[a.name] = a.value }
633
740
  new_el
634
741
  elsif source.text?
635
- Nokogiri::XML::Text.new(source.content, @nokogiri_doc)
636
- elsif source.is_a?(Nokogiri::XML::Comment)
637
- Nokogiri::XML::Comment.new(@nokogiri_doc, source.content)
742
+ Backend.create_text(source.content, @nokogiri_doc)
743
+ elsif source.is_a?(Backend.comment_class)
744
+ Backend.create_comment(source.content, @nokogiri_doc)
638
745
  else
639
746
  # Fallback: serialize + reparse via fragment for unusual types.
640
747
  fragment = Parser.fragment(source.to_html, owner_doc: @nokogiri_doc)
641
- fragment.children.first || Nokogiri::XML::Text.new("", @nokogiri_doc)
748
+ fragment.children.first || Backend.create_text("", @nokogiri_doc)
642
749
  end
643
750
 
644
751
  if deep && source.respond_to?(:children)
@@ -662,13 +769,64 @@ module Dommy
662
769
 
663
770
  title = head.at_css("title")
664
771
  unless title
665
- title = Nokogiri::XML::Node.new("title", @nokogiri_doc)
772
+ title = Backend.create_element("title", @nokogiri_doc)
666
773
  head.add_child(title)
667
774
  end
668
775
 
669
776
  title.children.each(&:unlink)
670
- title.add_child(Nokogiri::XML::Text.new(value, @nokogiri_doc))
777
+ title.add_child(Backend.create_text(value, @nokogiri_doc))
778
+ end
779
+
780
+ end
781
+
782
+ # `ViewTransition` — return value of `document.startViewTransition()`.
783
+ # All three Promises (`finished` / `ready` / `updateCallbackDone`)
784
+ # resolve immediately since dommy has no actual paint phase.
785
+ #
786
+ # Spec: https://drafts.csswg.org/css-view-transitions/
787
+ class ViewTransition
788
+ def initialize(window)
789
+ @finished = PromiseValue.resolve(window, nil)
790
+ @ready = PromiseValue.resolve(window, nil)
791
+ @update_callback_done = PromiseValue.resolve(window, nil)
792
+ end
793
+
794
+ attr_reader :finished, :ready
795
+
796
+ def update_callback_done
797
+ @update_callback_done
798
+ end
799
+
800
+ alias updateCallbackDone update_callback_done
801
+
802
+ def skip_transition
803
+ nil
671
804
  end
672
805
 
806
+ alias skipTransition skip_transition
807
+
808
+ def __js_get__(key)
809
+ case key
810
+ when "finished"
811
+ @finished
812
+ when "ready"
813
+ @ready
814
+ when "updateCallbackDone"
815
+ @update_callback_done
816
+ end
817
+ end
818
+
819
+ # Methods routed through __js_call__ (keep in sync with its when-arms).
820
+ JS_METHOD_NAMES = %w[skipTransition].freeze
821
+ def __js_method_names__
822
+ JS_METHOD_NAMES
823
+ end
824
+
825
+ def __js_call__(method, _args)
826
+ case method
827
+ when "skipTransition"
828
+ skip_transition
829
+ end
830
+ end
673
831
  end
674
832
  end
@@ -44,12 +44,13 @@ module Dommy
44
44
  private
45
45
 
46
46
  def parse_html(str)
47
- nokogiri_doc = Nokogiri::HTML5(str.empty? ? "<html><body></body></html>" : str, max_errors: 0)
47
+ nokogiri_doc = Backend.parse(str.empty? ? "<html><body></body></html>" : str)
48
48
  Document.new(nil, nokogiri_doc: nokogiri_doc)
49
49
  end
50
50
 
51
51
  def parse_xml(str)
52
- nokogiri_doc = Nokogiri::XML(str)
52
+ # Backends are HTML-only; parse XML input as HTML for now.
53
+ nokogiri_doc = Backend.parse(str.empty? ? "<html><body></body></html>" : str)
53
54
  Document.new(nil, nokogiri_doc: nokogiri_doc)
54
55
  end
55
56
  end
@@ -63,8 +64,8 @@ module Dommy
63
64
 
64
65
  if node.respond_to?(:outer_html)
65
66
  node.outer_html
66
- elsif node.respond_to?(:__node__)
67
- node.__node__.to_xml
67
+ elsif node.respond_to?(:__dommy_backend_node__)
68
+ node.__dommy_backend_node__.to_xml
68
69
  elsif node.respond_to?(:to_xml)
69
70
  node.to_xml
70
71
  else