dommy 0.5.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +213 -0
  3. data/lib/dommy/attr.rb +200 -0
  4. data/lib/dommy/blob.rb +182 -0
  5. data/lib/dommy/bridge.rb +141 -0
  6. data/lib/dommy/css.rb +283 -0
  7. data/lib/dommy/custom_elements.rb +125 -0
  8. data/lib/dommy/data_transfer.rb +98 -0
  9. data/lib/dommy/document.rb +674 -0
  10. data/lib/dommy/dom_exception.rb +258 -0
  11. data/lib/dommy/dom_parser.rb +88 -0
  12. data/lib/dommy/element.rb +1975 -0
  13. data/lib/dommy/event.rb +589 -0
  14. data/lib/dommy/fetch.rb +241 -0
  15. data/lib/dommy/form_data.rb +208 -0
  16. data/lib/dommy/html_collection.rb +207 -0
  17. data/lib/dommy/html_elements.rb +4455 -0
  18. data/lib/dommy/internal/cookie_jar.rb +27 -0
  19. data/lib/dommy/internal/dom_matching.rb +141 -0
  20. data/lib/dommy/internal/mutation_coordinator.rb +172 -0
  21. data/lib/dommy/internal/node_traversal.rb +36 -0
  22. data/lib/dommy/internal/node_wrapper_cache.rb +179 -0
  23. data/lib/dommy/internal/observer_manager.rb +31 -0
  24. data/lib/dommy/internal/observer_matcher.rb +31 -0
  25. data/lib/dommy/internal/scope_resolution.rb +27 -0
  26. data/lib/dommy/internal/shadow_root_registry.rb +35 -0
  27. data/lib/dommy/internal/template_content_registry.rb +97 -0
  28. data/lib/dommy/minitest/assertions.rb +105 -0
  29. data/lib/dommy/minitest.rb +17 -0
  30. data/lib/dommy/navigator.rb +271 -0
  31. data/lib/dommy/node.rb +218 -0
  32. data/lib/dommy/observer.rb +199 -0
  33. data/lib/dommy/parser.rb +29 -0
  34. data/lib/dommy/promise.rb +199 -0
  35. data/lib/dommy/router.rb +275 -0
  36. data/lib/dommy/rspec/capy_style_matchers.rb +356 -0
  37. data/lib/dommy/rspec/matchers.rb +230 -0
  38. data/lib/dommy/rspec.rb +18 -0
  39. data/lib/dommy/scheduler.rb +135 -0
  40. data/lib/dommy/shadow_root.rb +255 -0
  41. data/lib/dommy/storage.rb +112 -0
  42. data/lib/dommy/test_helpers.rb +78 -0
  43. data/lib/dommy/tree_walker.rb +425 -0
  44. data/lib/dommy/url.rb +479 -0
  45. data/lib/dommy/version.rb +5 -0
  46. data/lib/dommy/world.rb +209 -0
  47. data/lib/dommy.rb +119 -0
  48. metadata +110 -0
@@ -0,0 +1,674 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "internal/node_wrapper_cache"
6
+ require_relative "internal/mutation_coordinator"
7
+ require_relative "internal/shadow_root_registry"
8
+ require_relative "internal/cookie_jar"
9
+ require_relative "internal/node_traversal"
10
+ require_relative "internal/observer_manager"
11
+ require_relative "internal/template_content_registry"
12
+
13
+ module Dommy
14
+ # Stub DocumentType (`<!doctype html>`) — exposes `name` and `nodeType=10`.
15
+ # Real browsers also expose `publicId` / `systemId` which we leave empty
16
+ # since HTML5 doctypes don't carry those.
17
+ class DocumentType
18
+ include Node
19
+
20
+ attr_reader :name
21
+
22
+ def initialize(name)
23
+ @name = name.to_s
24
+ end
25
+
26
+ def __js_get__(key)
27
+ case key
28
+ when "name"
29
+ @name
30
+ when "nodeType"
31
+ 10
32
+ when "publicId"
33
+ ""
34
+ when "systemId"
35
+ ""
36
+ end
37
+ end
38
+ end
39
+
40
+ # `document` — the entry point for DOM construction and querying.
41
+ # Wrapper caching keeps DOM identity stable across repeated
42
+ # traversals (`body.children[0].parentElement`).
43
+ class Document
44
+ include EventTarget
45
+ include Node
46
+
47
+ attr_reader :body, :nokogiri_doc
48
+ attr_accessor :default_view
49
+
50
+ def initialize(host = nil, nokogiri_doc: nil, default_view: nil)
51
+ @host = host
52
+ @default_view = default_view
53
+ @node_wrapper_cache = Internal::NodeWrapperCache.new(self)
54
+ @observer_manager = Internal::ObserverManager.new
55
+ @shadow_registry = Internal::ShadowRootRegistry.new
56
+ @cookie_jar = Internal::CookieJar.new
57
+ @template_content_registry = Internal::TemplateContentRegistry.new(self)
58
+ @mutation_coordinator = Internal::MutationCoordinator.new(self, @observer_manager)
59
+ @nokogiri_doc = nokogiri_doc || Nokogiri::HTML5("<!doctype html><html><head></head><body></body></html>")
60
+ body_node = @nokogiri_doc.at_css("body")
61
+ @body = wrap_node(body_node) if body_node
62
+ end
63
+
64
+ # ----- Public Ruby API (snake_case) -----
65
+
66
+ def title
67
+ read_title
68
+ end
69
+
70
+ def title=(value)
71
+ write_title(value.to_s)
72
+ end
73
+
74
+ def document_element
75
+ wrap_node(@nokogiri_doc.at_css("html"))
76
+ end
77
+
78
+ def head
79
+ wrap_node(@nokogiri_doc.at_css("head"))
80
+ end
81
+
82
+ # `document.URL` / `documentURI` — both return location.href in
83
+ # real browsers (legacy aliases of the same field).
84
+ def url
85
+ view = @default_view
86
+ view&.location ? view.location.href : ""
87
+ end
88
+
89
+ alias document_uri url
90
+
91
+ # `document.baseURI` — resolves the first `<base href>` (if any)
92
+ # relative to the document URL; otherwise just the document URL.
93
+ # When `<base href>` is itself absolute, that wins. Browsers also
94
+ # ignore subsequent <base> elements; we mirror that.
95
+ def base_uri
96
+ doc_url = url
97
+ base_el = @nokogiri_doc.at_css("base[href]")
98
+ return doc_url unless base_el
99
+
100
+ href = base_el["href"].to_s
101
+ return doc_url if href.empty?
102
+
103
+ begin
104
+ URI.join(doc_url.to_s.empty? ? "about:blank" : doc_url, href).to_s
105
+ rescue URI::InvalidURIError
106
+ doc_url
107
+ end
108
+ end
109
+
110
+ # `document.domain` — host portion of the URL. Real browsers
111
+ # restrict cross-origin reads of this; we just return the bare host.
112
+ def domain
113
+ view = @default_view
114
+ return "" unless view&.location
115
+
116
+ view.location.__js_get__("hostname").to_s
117
+ end
118
+
119
+ # `document.referrer` — Dommy never has a referring page, so this
120
+ # is always empty.
121
+ def referrer
122
+ ""
123
+ end
124
+
125
+ # Live HTMLCollection helpers — each call re-queries the
126
+ # document so post-mutation reads reflect the current state.
127
+ def links
128
+ HTMLCollection.new do
129
+ @nokogiri_doc.css("a[href], area[href]").map { |n| wrap_node(n) }.compact
130
+ end
131
+ end
132
+
133
+ def forms
134
+ HTMLCollection.new do
135
+ @nokogiri_doc.css("form").map { |n| wrap_node(n) }.compact
136
+ end
137
+ end
138
+
139
+ def scripts
140
+ HTMLCollection.new do
141
+ @nokogiri_doc.css("script").map { |n| wrap_node(n) }.compact
142
+ end
143
+ end
144
+
145
+ def images
146
+ HTMLCollection.new do
147
+ @nokogiri_doc.css("img").map { |n| wrap_node(n) }.compact
148
+ end
149
+ end
150
+
151
+ # ParentNode mixin (operates on the document's element children —
152
+ # in practice the `<html>` root).
153
+ def children
154
+ HTMLCollection.new do
155
+ root = @nokogiri_doc.root
156
+ root ? [wrap_node(root)].compact : []
157
+ end
158
+ end
159
+
160
+ def child_element_count
161
+ children.size
162
+ end
163
+
164
+ def first_element_child
165
+ wrap_node(@nokogiri_doc.root)
166
+ end
167
+
168
+ def last_element_child
169
+ wrap_node(@nokogiri_doc.root)
170
+ end
171
+
172
+ # Currently-focused element (or body if none). Updated via
173
+ # `el.focus()` / `el.blur()`.
174
+ def active_element
175
+ @active_element || @body
176
+ end
177
+
178
+ def __set_active_element__(el)
179
+ @active_element = el
180
+ end
181
+
182
+ # Create a detached Attr. `setAttributeNode` attaches it to an
183
+ # element. Per spec, name must match the XML Name production —
184
+ # invalid names throw InvalidCharacterError.
185
+ def create_attribute(name)
186
+ @node_wrapper_cache.create_attribute(name)
187
+ end
188
+
189
+ def create_attribute_ns(namespace_uri, qualified_name)
190
+ @node_wrapper_cache.create_attribute_ns(namespace_uri, qualified_name)
191
+ end
192
+
193
+ # `document.createTreeWalker(root, whatToShow?, filter?)` — stateful
194
+ # tree traversal with sibling/parent navigation. `filter` may be a
195
+ # Ruby Proc, a JS-bridge callable, or an object with
196
+ # `accept_node` / `acceptNode`.
197
+ def create_tree_walker(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
198
+ TreeWalker.new(root, what_to_show, filter)
199
+ end
200
+
201
+ # Copy a node from another document into this one. The returned
202
+ # wrapper is owned by `this`. Per spec, the source node is left
203
+ # in place. `deep: true` copies the entire subtree.
204
+ def import_node(node, deep = false)
205
+ return nil unless node.respond_to?(:__node__)
206
+
207
+ copy = clone_into_doc(node.__node__, deep)
208
+ wrap_node(copy)
209
+ end
210
+
211
+ # Move a node from another document into this one. The source
212
+ # node is detached from its previous owner and its ownerDocument
213
+ # becomes this. Returns the (possibly re-wrapped) node.
214
+ def adopt_node(node)
215
+ return nil unless node.respond_to?(:__node__)
216
+
217
+ src = node.__node__
218
+ 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
+
225
+ wrap_node(moved)
226
+ end
227
+
228
+ # Legacy `document.createEvent("EventName")` factory. Returns an
229
+ # Event subclass instance whose init still has to be called
230
+ # (`event.initEvent(type, bubbles, cancelable)`). Matches the
231
+ # mapping happy-dom and linkedom use.
232
+ def create_event(type_name)
233
+ name = type_name.to_s
234
+ case name
235
+ when "Event", "Events", "HTMLEvents"
236
+ Event.new("")
237
+ when "CustomEvent"
238
+ CustomEvent.new("")
239
+ when "MouseEvent", "MouseEvents"
240
+ MouseEvent.new("")
241
+ when "KeyboardEvent", "KeyboardEvents"
242
+ KeyboardEvent.new("")
243
+ else
244
+ Event.new("")
245
+ end
246
+ end
247
+
248
+ # Stubs for layout / focus / selection / execCommand APIs that
249
+ # don't apply to a layout-less DOM. They exist so callers don't
250
+ # hit NoMethodError; semantics are documented as no-op.
251
+
252
+ def has_focus?
253
+ true
254
+ end
255
+
256
+ alias has_focus has_focus?
257
+
258
+ def get_selection
259
+ nil
260
+ end
261
+
262
+ def element_from_point(_x, _y)
263
+ nil
264
+ end
265
+
266
+ def query_command_supported(_command)
267
+ false
268
+ end
269
+
270
+ # `document.createNodeIterator(root, whatToShow?, filter?)` —
271
+ # flat depth-first iteration.
272
+ def create_node_iterator(root, what_to_show = NodeFilter::SHOW_ALL, filter = nil)
273
+ NodeIterator.new(root, what_to_show, filter)
274
+ end
275
+
276
+ # Minimal DocumentType — represents the `<!doctype html>` line.
277
+ # Always present in HTML5 documents we parse, so we synthesize a
278
+ # stub object whose only useful field is `name`. Tests just need
279
+ # `nodeType == 10`.
280
+ def doctype
281
+ @doctype ||= DocumentType.new("html")
282
+ end
283
+
284
+ # Delegate to CookieJar
285
+
286
+ def cookie
287
+ @cookie_jar.to_cookie_string
288
+ end
289
+
290
+ def cookie=(value)
291
+ @cookie_jar.set_cookie(value)
292
+ nil
293
+ end
294
+
295
+ def create_element_ns(namespace_uri, qualified_name)
296
+ @node_wrapper_cache.create_element_ns(namespace_uri, qualified_name)
297
+ end
298
+
299
+ def get_elements_by_tag_name(name)
300
+ @node_wrapper_cache.get_elements_by_tag_name(name)
301
+ end
302
+
303
+ def get_elements_by_name(name)
304
+ @node_wrapper_cache.get_elements_by_name(name)
305
+ end
306
+
307
+ # `document.write(html)` — legacy API. Appends parsed nodes to the
308
+ # body. Real browsers only re-stream the DOM during initial parse;
309
+ # this stub is enough for tests that fire write() during teardown.
310
+ def write(*args)
311
+ html = args.join
312
+ fragment = Parser.fragment(html, owner_doc: @nokogiri_doc)
313
+ removed = []
314
+ 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)
317
+ nil
318
+ end
319
+
320
+ # No-ops — real browsers reset the DOM on `open()` and flush
321
+ # pending writes on `close()`. We don't model the parse pipeline.
322
+ def open
323
+ nil
324
+ end
325
+
326
+ def close
327
+ nil
328
+ end
329
+
330
+ def [](key)
331
+ __js_get__(key.to_s)
332
+ end
333
+
334
+ def []=(key, value)
335
+ __js_set__(key.to_s, value)
336
+ end
337
+
338
+ # Create a Comment node. Wraps the Nokogiri comment so it flows
339
+ # through the same wrap_node identity machinery as Element / TextNode.
340
+ def create_comment(text)
341
+ @node_wrapper_cache.create_comment(text)
342
+ end
343
+
344
+ def create_document_fragment
345
+ @node_wrapper_cache.create_document_fragment
346
+ end
347
+
348
+ def get_elements_by_class_name(name)
349
+ @node_wrapper_cache.get_elements_by_class_name(name)
350
+ end
351
+
352
+ def __js_get__(key)
353
+ case key
354
+ when "body"
355
+ @body
356
+ when "head"
357
+ head
358
+ when "doctype"
359
+ doctype
360
+ when "defaultView"
361
+ @default_view
362
+ when "documentElement"
363
+ wrap_node(@nokogiri_doc.at_css("html"))
364
+ when "title"
365
+ read_title
366
+ when "cookie"
367
+ cookie
368
+ when "nodeType"
369
+ 9
370
+ when "activeElement"
371
+ active_element
372
+ when "URL", "documentURI"
373
+ url
374
+ when "baseURI"
375
+ base_uri
376
+ when "domain"
377
+ domain
378
+ when "referrer"
379
+ referrer
380
+ when "links"
381
+ links
382
+ when "forms"
383
+ forms
384
+ when "scripts"
385
+ scripts
386
+ when "images"
387
+ images
388
+ when "children"
389
+ children
390
+ when "childElementCount"
391
+ child_element_count
392
+ when "firstElementChild"
393
+ first_element_child
394
+ when "lastElementChild"
395
+ last_element_child
396
+ when "nodeName"
397
+ "#document"
398
+ else
399
+ nil
400
+ end
401
+ end
402
+
403
+ def __js_set__(key, value)
404
+ case key
405
+ when "title"
406
+ write_title(value.to_s)
407
+ when "cookie"
408
+ self.cookie = value.to_s
409
+ end
410
+
411
+ nil
412
+ end
413
+
414
+ def __js_call__(method, args)
415
+ case method
416
+ when "createElement"
417
+ create_element(args[0])
418
+ when "createElementNS"
419
+ create_element_ns(args[0], args[1])
420
+ when "createTextNode"
421
+ create_text_node(args[0])
422
+ when "createComment"
423
+ create_comment(args[0])
424
+ when "createDocumentFragment"
425
+ create_document_fragment
426
+ when "querySelector"
427
+ query_selector(args[0])
428
+ when "querySelectorAll"
429
+ query_selector_all(args[0])
430
+ when "getElementById"
431
+ get_element_by_id(args[0])
432
+ when "getElementsByClassName"
433
+ get_elements_by_class_name(args[0])
434
+ when "getElementsByTagName"
435
+ get_elements_by_tag_name(args[0])
436
+ when "getElementsByName"
437
+ get_elements_by_name(args[0])
438
+ when "createAttribute"
439
+ create_attribute(args[0])
440
+ when "createAttributeNS"
441
+ create_attribute_ns(args[0], args[1])
442
+ when "createTreeWalker"
443
+ create_tree_walker(args[0], args[1] || NodeFilter::SHOW_ALL, args[2])
444
+ when "createNodeIterator"
445
+ create_node_iterator(args[0], args[1] || NodeFilter::SHOW_ALL, args[2])
446
+ when "createEvent"
447
+ create_event(args[0])
448
+ when "importNode"
449
+ import_node(args[0], args[1])
450
+ when "adoptNode"
451
+ adopt_node(args[0])
452
+ when "hasFocus"
453
+ has_focus?
454
+ when "getSelection"
455
+ get_selection
456
+ when "elementFromPoint"
457
+ element_from_point(args[0], args[1])
458
+ when "queryCommandSupported"
459
+ query_command_supported(args[0])
460
+ when "addEventListener"
461
+ add_event_listener(args[0], args[1], args[2])
462
+ when "removeEventListener"
463
+ remove_event_listener(args[0], args[1])
464
+ when "dispatchEvent"
465
+ dispatch_event(args[0])
466
+ when "write"
467
+ write(*args)
468
+ when "open"
469
+ open
470
+ when "close"
471
+ close
472
+ else
473
+ nil
474
+ end
475
+ end
476
+
477
+ def __event_parent__
478
+ @default_view
479
+ end
480
+
481
+ # Delegate node wrapping to NodeWrapperCache
482
+ def wrap_node(node)
483
+ @node_wrapper_cache.wrap(node)
484
+ end
485
+
486
+ # Clear the cached wrapper so the next `wrap_node` creates a new
487
+ # one. Used by `customElements.define` to upgrade nodes that were
488
+ # constructed before the registration landed.
489
+ def __reset_wrapper__(nokogiri_node)
490
+ @node_wrapper_cache.reset_wrapper(nokogiri_node)
491
+ end
492
+
493
+ # ShadowRoot identity registry: map a Nokogiri DocumentFragment
494
+ # (the shadow tree's backing node) to the wrapping ShadowRoot so
495
+ # slot assignment and event composition can walk from any inner
496
+ # node back to its shadow boundary.
497
+ # Delegate to ShadowRootRegistry
498
+
499
+ def __register_shadow_fragment__(fragment_node, shadow_root)
500
+ @shadow_registry.register(fragment_node, shadow_root)
501
+ end
502
+
503
+ def __shadow_root_for_fragment__(fragment_node)
504
+ @shadow_registry.find_for_fragment(fragment_node)
505
+ end
506
+
507
+ def __shadow_root_containing__(node)
508
+ @shadow_registry.find_enclosing(node)
509
+ end
510
+
511
+ # Lifecycle callback dispatchers. Errors raised inside user
512
+ # callbacks are swallowed so a single buggy custom element can't
513
+ # break the whole mutation pipeline.
514
+ # Delegate to MutationCoordinator
515
+
516
+ def __notify_connected__(element)
517
+ @mutation_coordinator.notify_connected(element)
518
+ end
519
+
520
+ def __notify_disconnected__(element)
521
+ @mutation_coordinator.notify_disconnected(element)
522
+ end
523
+
524
+ def __notify_connected_subtree__(nk)
525
+ @mutation_coordinator.notify_connected_subtree(nk)
526
+ end
527
+
528
+ def __notify_disconnected_subtree__(nk)
529
+ @mutation_coordinator.notify_disconnected_subtree(nk)
530
+ end
531
+
532
+ def __notify_attribute_changed__(element, name, old_value, new_value)
533
+ @mutation_coordinator.notify_attribute_changed(element, name, old_value, new_value)
534
+ end
535
+
536
+ def register_observer(observer)
537
+ @mutation_coordinator.register_observer(observer)
538
+ end
539
+
540
+ def unregister_observer(observer)
541
+ @mutation_coordinator.unregister_observer(observer)
542
+ end
543
+
544
+ def notify_child_list_mutation(
545
+ target_node:,
546
+ added_nodes:,
547
+ removed_nodes:,
548
+ previous_sibling: nil,
549
+ next_sibling: nil
550
+ )
551
+ @mutation_coordinator.notify_child_list_mutation(
552
+ target_node: target_node,
553
+ added_nodes: added_nodes,
554
+ removed_nodes: removed_nodes,
555
+ previous_sibling: previous_sibling,
556
+ next_sibling: next_sibling
557
+ )
558
+ end
559
+
560
+ def notify_attribute_mutation(target_node:, attribute_name:, old_value:)
561
+ @mutation_coordinator.notify_attribute_mutation(
562
+ target_node: target_node,
563
+ attribute_name: attribute_name,
564
+ old_value: old_value
565
+ )
566
+ end
567
+
568
+ def notify_character_data_mutation(target_node:, old_value:)
569
+ @mutation_coordinator.notify_character_data_mutation(
570
+ target_node: target_node,
571
+ old_value: old_value
572
+ )
573
+ end
574
+
575
+ # Spec-permitted name pattern (XML "Name" production restricted to
576
+ # ASCII for practicality). Used by `createElement` and
577
+ # `createAttribute` to validate the argument.
578
+ NAME_RE = /\A[A-Za-z_][\w\-.:]*\z/.freeze
579
+
580
+ # Delegate factory methods to NodeWrapperCache
581
+
582
+ def create_element(name)
583
+ @node_wrapper_cache.create_element(name)
584
+ end
585
+
586
+ def create_text_node(text)
587
+ @node_wrapper_cache.create_text_node(text)
588
+ end
589
+
590
+ def query_selector(selector)
591
+ @node_wrapper_cache.query_selector(selector)
592
+ end
593
+
594
+ def query_selector_all(selector)
595
+ @node_wrapper_cache.query_selector_all(selector)
596
+ end
597
+
598
+ def get_element_by_id(id)
599
+ @node_wrapper_cache.get_element_by_id(id)
600
+ end
601
+
602
+ # ----- template content helpers (called from Element) -----
603
+
604
+ def attach_template_content(template_element, html)
605
+ @template_content_registry.attach(template_element, html)
606
+ end
607
+
608
+ def template_content_fragment(template_element)
609
+ @template_content_registry.fragment_for(template_element)
610
+ end
611
+
612
+ def template_content_inner_html(template_element)
613
+ @template_content_registry.inner_html_of(template_element)
614
+ end
615
+
616
+ def migrate_template_descendants(root)
617
+ @template_content_registry.migrate_descendants(root)
618
+ end
619
+
620
+ def has_template_content?(nokogiri_node)
621
+ @template_content_registry.has_content?(nokogiri_node)
622
+ end
623
+
624
+ private
625
+
626
+ # Build a Nokogiri copy of the given node inside our @nokogiri_doc.
627
+ # `deep: true` recurses into children. Used by importNode and
628
+ # adoptNode for cross-document transfer.
629
+ def clone_into_doc(source, deep)
630
+ copy = if source.element?
631
+ new_el = Nokogiri::XML::Node.new(source.name, @nokogiri_doc)
632
+ source.attribute_nodes.each { |a| new_el[a.name] = a.value }
633
+ new_el
634
+ 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)
638
+ else
639
+ # Fallback: serialize + reparse via fragment for unusual types.
640
+ fragment = Parser.fragment(source.to_html, owner_doc: @nokogiri_doc)
641
+ fragment.children.first || Nokogiri::XML::Text.new("", @nokogiri_doc)
642
+ end
643
+
644
+ if deep && source.respond_to?(:children)
645
+ source.children.each do |child|
646
+ copy.add_child(clone_into_doc(child, true))
647
+ end
648
+ end
649
+
650
+ copy
651
+ end
652
+
653
+ def read_title
654
+ head = @nokogiri_doc.at_css("head")
655
+ title = head&.at_css("title")
656
+ title ? title.text : ""
657
+ end
658
+
659
+ def write_title(value)
660
+ head = @nokogiri_doc.at_css("head")
661
+ return unless head
662
+
663
+ title = head.at_css("title")
664
+ unless title
665
+ title = Nokogiri::XML::Node.new("title", @nokogiri_doc)
666
+ head.add_child(title)
667
+ end
668
+
669
+ title.children.each(&:unlink)
670
+ title.add_child(Nokogiri::XML::Text.new(value, @nokogiri_doc))
671
+ end
672
+
673
+ end
674
+ end