dommy 0.10.0 → 0.12.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dommy/attr.rb +4 -5
  3. data/lib/dommy/backend/makiri_adapter.rb +24 -14
  4. data/lib/dommy/backend.rb +18 -5
  5. data/lib/dommy/browser.rb +8 -1
  6. data/lib/dommy/callable_invoker.rb +13 -5
  7. data/lib/dommy/css.rb +33 -10
  8. data/lib/dommy/custom_elements.rb +62 -16
  9. data/lib/dommy/document.rb +1096 -223
  10. data/lib/dommy/dom_parser.rb +12 -1
  11. data/lib/dommy/element.rb +513 -293
  12. data/lib/dommy/event.rb +462 -111
  13. data/lib/dommy/html_collection.rb +7 -4
  14. data/lib/dommy/html_elements.rb +763 -182
  15. data/lib/dommy/interaction/driver.rb +23 -1
  16. data/lib/dommy/interaction/event_synthesis.rb +4 -7
  17. data/lib/dommy/interaction/field_interactor.rb +13 -7
  18. data/lib/dommy/internal/accessible_name.rb +45 -8
  19. data/lib/dommy/internal/aria_role.rb +5 -3
  20. data/lib/dommy/internal/child_node.rb +151 -41
  21. data/lib/dommy/internal/css/cascade.rb +3 -1
  22. data/lib/dommy/internal/css/parser.rb +97 -14
  23. data/lib/dommy/internal/css/rule_index.rb +288 -18
  24. data/lib/dommy/internal/css_priority.rb +33 -0
  25. data/lib/dommy/internal/dom_matching.rb +1 -1
  26. data/lib/dommy/internal/leaf_node.rb +45 -0
  27. data/lib/dommy/internal/mutation_coordinator.rb +180 -46
  28. data/lib/dommy/internal/node_adopter.rb +190 -0
  29. data/lib/dommy/internal/node_traversal.rb +10 -0
  30. data/lib/dommy/internal/node_wrapper_cache.rb +76 -13
  31. data/lib/dommy/internal/observer_manager.rb +18 -0
  32. data/lib/dommy/internal/observer_matcher.rb +37 -4
  33. data/lib/dommy/internal/parent_node.rb +284 -45
  34. data/lib/dommy/internal/range_text_serializer.rb +7 -5
  35. data/lib/dommy/internal/selector_ast.rb +17 -0
  36. data/lib/dommy/internal/selector_index.rb +24 -8
  37. data/lib/dommy/internal/selector_matcher.rb +139 -24
  38. data/lib/dommy/internal/selector_parser.rb +104 -32
  39. data/lib/dommy/internal/shadow_root_registry.rb +12 -1
  40. data/lib/dommy/internal/template_content_registry.rb +23 -7
  41. data/lib/dommy/internal/utf16.rb +45 -0
  42. data/lib/dommy/internal/web_idl.rb +57 -0
  43. data/lib/dommy/internal/xml_serialization.rb +18 -5
  44. data/lib/dommy/js/custom_element_bridge.rb +10 -0
  45. data/lib/dommy/js/dom_interfaces.rb +82 -9
  46. data/lib/dommy/js/handle_table.rb +15 -0
  47. data/lib/dommy/js/host_bridge.rb +79 -15
  48. data/lib/dommy/js/host_runtime.js +607 -57
  49. data/lib/dommy/js/marshaller.rb +13 -7
  50. data/lib/dommy/js/script_boot.rb +30 -29
  51. data/lib/dommy/mutation_observer.rb +109 -19
  52. data/lib/dommy/node.rb +46 -3
  53. data/lib/dommy/parser.rb +14 -0
  54. data/lib/dommy/promise.rb +4 -4
  55. data/lib/dommy/range.rb +448 -219
  56. data/lib/dommy/selection.rb +422 -0
  57. data/lib/dommy/shadow_root.rb +40 -20
  58. data/lib/dommy/static_range.rb +65 -0
  59. data/lib/dommy/svg_elements.rb +45 -0
  60. data/lib/dommy/tree_walker.rb +16 -20
  61. data/lib/dommy/url_pattern/canonicalizer.rb +183 -0
  62. data/lib/dommy/url_pattern/component.rb +64 -0
  63. data/lib/dommy/url_pattern/constructor_string_parser.rb +281 -0
  64. data/lib/dommy/url_pattern/init_processor.rb +163 -0
  65. data/lib/dommy/url_pattern/pattern_parser.rb +343 -0
  66. data/lib/dommy/url_pattern/regexp_translator.rb +694 -0
  67. data/lib/dommy/url_pattern/tokenizer.rb +199 -0
  68. data/lib/dommy/url_pattern.rb +310 -103
  69. data/lib/dommy/version.rb +1 -1
  70. data/lib/dommy/window.rb +76 -5
  71. data/lib/dommy.rb +7 -1
  72. metadata +18 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52933caebd00a8d1b7dcfa14421116be45617e04d53ee6f8d8771c5fc5ccefdc
4
- data.tar.gz: da2372f52dac931a6e7ad4316ec4cb15efb0effa799229eae7bde1435b8aa3c8
3
+ metadata.gz: 6306b86751026b1b48072c497fd34cd008c5b0bfe1838ae62ab3896d89a2aa3f
4
+ data.tar.gz: 817d56deed527b5f2718242937bb1a30a7d22738ca8f09276750505cf9368b5a
5
5
  SHA512:
6
- metadata.gz: e05358f558f5e84fe6da7e7f705dbdc5bdd178646ef71a3ee9311a9d05893320cda1c0e6ca3596aa05e7e389e9112e96c8d44b640b7dea55480066af40e65eb7
7
- data.tar.gz: ac0dea6fecd2fe7624f71cdef6f9eab786be60a584a4307d0fcac66540002de7cc1cc52812a638bfc8bb35afb53dd836ed7ebb2b007fee8fabfd172e62e8850a
6
+ metadata.gz: bfce9a98c80d522db534045b6ee378d0bdfb8e1f03646173e4fc74fd5e8ed46e35e723fe4ce5f7828cdab9d71b6e567c2022ad63d01f2f70af8a0baf0acd34cf
7
+ data.tar.gz: 6b0aa54054201df72970198f18450268d53cbe434b3da15bd9b49cd68eb98b9d94ae1acf80d40b668706052385fa8597e4580b683c472760ee970a10ee13d2f4
data/lib/dommy/attr.rb CHANGED
@@ -71,11 +71,10 @@ module Dommy
71
71
 
72
72
  def value
73
73
  if @owner
74
- if @namespace_uri
75
- Backend.get_attribute_ns(@owner.__dommy_backend_node__, @namespace_uri, @local_name).to_s
76
- else
77
- @owner.__dommy_backend_node__[@name].to_s
78
- end
74
+ # Both branches go through the namespace-aware read: the backend's
75
+ # `node[name]` indexes by local name, so a null-namespace `b` would read
76
+ # back the value of a prefixed `xml:b` sitting on the same element.
77
+ Backend.get_attribute_ns(@owner.__dommy_backend_node__, @namespace_uri, @local_name).to_s
79
78
  else
80
79
  @detached_value
81
80
  end
@@ -43,18 +43,6 @@ module Dommy
43
43
  node.clone_node(deep)
44
44
  end
45
45
 
46
- # Makiri documents have no node-level clone; re-parsing the serialized
47
- # document reproduces the full tree. Dispatch on the document kind so an XML
48
- # document round-trips through the XML parser (case/namespaces/CDATA) and an
49
- # HTML document through the HTML parser.
50
- def clone_document(doc)
51
- if doc.is_a?(::Makiri::XML::Document)
52
- ::Makiri::XML::Document.parse(doc.to_xml)
53
- else
54
- ::Makiri::HTML::Document.parse(doc.to_html)
55
- end
56
- end
57
-
58
46
  # A fresh, empty HTML-backed document (children dropped so it starts with no
59
47
  # documentElement). The backing for a shallow clone of an HTML document.
60
48
  def empty_document
@@ -233,8 +221,8 @@ module Dommy
233
221
  end
234
222
 
235
223
  # Both Makiri document families (HTML and XML) mint a real PI node and
236
- # serialize it (HTML as `<?target data>`, XML as `<?target data?>`), so PIs
237
- # — unlike CDATA — need no HTML-document fallback.
224
+ # serialize it as `<?target data?>`, so PIs — unlike CDATA — need no
225
+ # HTML-document fallback.
238
226
  def create_processing_instruction(target, data, doc)
239
227
  doc.create_processing_instruction(target, data)
240
228
  end
@@ -276,6 +264,12 @@ module Dommy
276
264
 
277
265
  node["xmlns:#{prefix}"] = href.to_s
278
266
  nil
267
+ rescue ArgumentError
268
+ # DOM validates a qualified name against the Name production, which
269
+ # admits prefixes an XML backend cannot spell as an `xmlns:` attribute
270
+ # ("0:a", ";:a"). The element is still valid — its prefix and namespace
271
+ # live on the wrapper — so the declaration is simply not written.
272
+ nil
279
273
  end
280
274
 
281
275
  def namespace_definitions(_node)
@@ -330,6 +324,22 @@ module Dommy
330
324
  node.attribute_nodes
331
325
  end
332
326
 
327
+ # Attribute node matching the qualified name exactly. `node[name]` would
328
+ # also answer for a prefixed attribute with that local name.
329
+ #
330
+ # This sits under `getAttribute` / `hasAttribute` / `setAttribute` /
331
+ # `removeAttribute`, so it runs on the hottest path in the library — every
332
+ # CSS match, every reflected IDL attribute — and Makiri does the scan
333
+ # natively (0.9.0). The value-returning spelling is what the two readers
334
+ # that only want the value ask for: it wraps no Attr at all.
335
+ def attr_by_qualified_name(node, qualified_name)
336
+ node.attribute_by_qualified_name(qualified_name.to_s)
337
+ end
338
+
339
+ def attr_value_by_qualified_name(node, qualified_name)
340
+ node.attribute_value_by_qualified_name(qualified_name.to_s)
341
+ end
342
+
333
343
  # Attribute node matching (namespace, local name) case-sensitively; a
334
344
  # null/empty namespace matches a null-namespace attribute.
335
345
  def attr_by_ns(node, namespace, local_name)
data/lib/dommy/backend.rb CHANGED
@@ -53,11 +53,6 @@ module Dommy
53
53
  current.clone_node(node, deep: deep)
54
54
  end
55
55
 
56
- # Deep copy of a whole document (DOM cloneNode on the document).
57
- def clone_document(doc)
58
- current.clone_document(doc)
59
- end
60
-
61
56
  # A fresh, empty HTML-backed document.
62
57
  def empty_document
63
58
  current.empty_document
@@ -233,6 +228,24 @@ module Dommy
233
228
  current.attribute_ns_info(attr_node)
234
229
  end
235
230
 
231
+ # The attribute node whose QUALIFIED name is `qualified_name`, or nil.
232
+ #
233
+ # WHATWG's by-name family ("get an attribute by name", `setAttribute`,
234
+ # `removeAttribute`) matches on the qualified name. A lookup by local name
235
+ # confuses `b` with a prefixed `xml:b`, so those paths must come through
236
+ # here rather than through `node[name]`.
237
+ def attr_by_qualified_name(node, qualified_name)
238
+ current.attr_by_qualified_name(node, qualified_name)
239
+ end
240
+
241
+ # That attribute's VALUE, or nil when there is no such attribute. The same
242
+ # match without an attribute node in hand, for the two readers that only
243
+ # ever wanted the value — `getAttribute` and `hasAttribute` — which run on
244
+ # every CSS match and every reflected IDL attribute.
245
+ def attr_value_by_qualified_name(node, qualified_name)
246
+ current.attr_value_by_qualified_name(node, qualified_name)
247
+ end
248
+
236
249
  # The element's attribute nodes (each readable via attribute_ns_info).
237
250
  # The single choke point so DOM code doesn't touch parser internals.
238
251
  def attribute_nodes(node)
data/lib/dommy/browser.rb CHANGED
@@ -279,9 +279,16 @@ module Dommy
279
279
  runtime.install_wasm_memory_shim if @wasm_memory_shim && runtime.respond_to?(:install_wasm_memory_shim)
280
280
  window.globals["__fetch_handler__"] = Resources::FetchHandler.new(@resources) if @resources
281
281
  @runtime = runtime
282
+ doc = window.document
283
+ # An `on*` attribute that arrived after boot (a cloned template, an
284
+ # innerHTML fragment) is compiled on first dispatch, which replays the scan.
285
+ # Installed whenever a runtime is attached — an embedder that drives script
286
+ # boot itself (`execute_scripts: false`) still needs inline handlers wired.
287
+ doc.inline_handler_wirer = lambda do
288
+ Js::ScriptBoot.wire_inline_handlers(runtime, on_error: ->(e) { @js_errors << e })
289
+ end
282
290
  return unless @execute_scripts
283
291
 
284
- doc = window.document
285
292
  # Dynamically-inserted `<script src>` (webpack/Vite on-demand chunks)
286
293
  # fetch + run through the same resources adapter, after boot.
287
294
  doc.external_script_runner = lambda do |element, src|
@@ -25,15 +25,23 @@ module Dommy
25
25
  # that order). A JS function listener's `this` must be the event's
26
26
  # currentTarget (the node the listener is attached to), so pass it through
27
27
  # when the bridge supports an explicit receiver.
28
- def invoke_listener(listener, event, current_target = nil)
28
+ # `args:` overrides the single-event argument list — the special error
29
+ # event handler (`window.onerror`) is called with (message, filename,
30
+ # lineno, colno, error) instead of the event. An EventListener object's
31
+ # handleEvent always receives the event.
32
+ def invoke_listener(listener, event, current_target = nil, args: nil)
33
+ args ||= [event]
29
34
  if listener.respond_to?(:handle_event)
30
35
  listener.handle_event(event)
31
36
  elsif listener.respond_to?(:call) && !listener.is_a?(Module)
32
- listener.call(event)
33
- elsif listener.respond_to?(:__js_call_with_this__)
34
- listener.__js_call_with_this__([event], current_target)
37
+ listener.call(*args)
38
+ elsif listener.respond_to?(:__js_invoke__)
39
+ # A JS function listener: `this` is the currentTarget, and a thrown value
40
+ # surfaces (as a ThrowValue) so the dispatch reports it as a window
41
+ # `error` event instead of swallowing it.
42
+ listener.__js_invoke__(args, this: current_target, raising: true)
35
43
  elsif listener.respond_to?(:__js_call__)
36
- listener.__js_call__("call", [event])
44
+ listener.__js_call__("call", args)
37
45
  end
38
46
  end
39
47
  end
data/lib/dommy/css.rb CHANGED
@@ -18,7 +18,9 @@ module Dommy
18
18
  class CSSStyleSheet
19
19
  attr_reader :owner_node, :css_rules
20
20
 
21
- def initialize(owner_node:, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
21
+ # A stylesheet constructed by `new CSSStyleSheet()` has no owner node.
22
+ # Element-owned sheets still pass their <style> / <link> node explicitly.
23
+ def initialize(owner_node: nil, href: nil, media: nil, title: nil, type: "text/css", source_text: nil)
22
24
  @owner_node = owner_node
23
25
  @href = href
24
26
  @media = media
@@ -291,21 +293,27 @@ module Dommy
291
293
  end
292
294
 
293
295
  def get_property_value(name)
294
- entry = @props[name.to_s]
296
+ entry = @props[property_key(name)]
295
297
  entry ? entry[:value] : ""
296
298
  end
297
299
 
298
300
  def get_property_priority(name)
299
- entry = @props[name.to_s]
301
+ entry = @props[property_key(name)]
300
302
  entry ? entry[:priority] : ""
301
303
  end
302
304
 
303
305
  def set_property(name, value, priority = nil)
304
- key = name.to_s
306
+ key = property_key(name)
305
307
  if value.nil? || value.to_s.empty?
308
+ # CSSOM step 3 — an empty value removes the declaration, and it runs
309
+ # before the priority check, so the priority is irrelevant here.
306
310
  @props.delete(key)
307
311
  else
308
- important = priority.to_s.downcase == "important" ? "important" : ""
312
+ # CSSOM step 4 — an invalid priority abandons the call, leaving the
313
+ # declaration block exactly as it was.
314
+ important = Internal::CssPriority.normalize(priority)
315
+ return nil if important.nil?
316
+
309
317
  @props[key] = {value: value.to_s, priority: important}
310
318
  end
311
319
  flush!
@@ -313,7 +321,7 @@ module Dommy
313
321
  end
314
322
 
315
323
  def remove_property(name)
316
- removed = @props.delete(name.to_s)
324
+ removed = @props.delete(property_key(name))
317
325
  flush!
318
326
  removed ? removed[:value] : ""
319
327
  end
@@ -403,6 +411,13 @@ module Dommy
403
411
 
404
412
  private
405
413
 
414
+ # CSSOM normalizes every property name it is handed (see
415
+ # Internal::CSS::Parser.property_name); `css_name` below is the separate
416
+ # camelCase / snake_case → kebab conversion for the IDL accessors.
417
+ def property_key(name)
418
+ Internal::CSS::Parser.property_name(name)
419
+ end
420
+
406
421
  def css_name(name)
407
422
  str = name.to_s
408
423
  return str if str.start_with?("--")
@@ -414,8 +429,8 @@ module Dommy
414
429
  # reusing the cascade's declaration parser (same normalization the cascade
415
430
  # sees) so reads agree with computed style.
416
431
  def parse(body_text)
417
- Internal::CSS::Parser.parse_declarations(body_text.to_s).each_with_object({}) do |decl, out|
418
- out[decl.name] = {value: decl.value, priority: decl.important ? "important" : ""}
432
+ Internal::CSS::Parser.parse_block(body_text).transform_values do |decl|
433
+ {value: decl.value, priority: decl.important ? "important" : ""}
419
434
  end
420
435
  end
421
436
 
@@ -458,8 +473,16 @@ module Dommy
458
473
  @parent_style_sheet = parent_style_sheet
459
474
  end
460
475
 
476
+ # CSSOM serializes a style rule as its selector plus its declaration block,
477
+ # so the text is rebuilt from the parsed declarations rather than echoed
478
+ # back: `#foo { color: red }` reads as `#foo { color: red; }`, and a block
479
+ # whose contents are not declarations at all serializes empty. An at-rule
480
+ # keeps its text as given.
461
481
  def css_text
462
- @css_text
482
+ return @css_text unless style_rule?
483
+
484
+ declarations = style&.css_text.to_s
485
+ declarations.empty? ? "#{selector_text} { }" : "#{selector_text} { #{declarations} }"
463
486
  end
464
487
 
465
488
  def css_text=(v)
@@ -553,7 +576,7 @@ module Dommy
553
576
 
554
577
  def __js_get__(key)
555
578
  case key
556
- when "cssText" then @css_text
579
+ when "cssText" then css_text
557
580
  when "type" then type
558
581
  when "selectorText" then selector_text
559
582
  when "style" then style
@@ -34,6 +34,15 @@ module Dommy
34
34
  @pending_promises = {}
35
35
  end
36
36
 
37
+ # Whether `name` is a valid custom element name. Also consulted by
38
+ # `element_class_for`: an unrecognized HTML-namespace name that is valid here
39
+ # is an *undefined custom element* (interface HTMLElement), not an unknown
40
+ # element (HTMLUnknownElement).
41
+ def self.valid_name?(name)
42
+ key = name.to_s
43
+ key.match?(NAME_RE) && !RESERVED_NAMES.include?(key)
44
+ end
45
+
37
46
  def define(name, klass, _options = nil)
38
47
  key = name.to_s
39
48
  unless key.match?(NAME_RE)
@@ -77,20 +86,23 @@ module Dommy
77
86
  promise
78
87
  end
79
88
 
80
- # Walk `root`'s subtree and re-wrap any nodes whose tag is now
89
+ # Walk `root`'s shadow-including subtree and re-wrap any nodes whose tag is now
81
90
  # registered; fires `connectedCallback` for each upgraded node
82
91
  # that's currently attached to a document tree.
83
92
  def upgrade(root)
84
93
  return nil unless root.respond_to?(:__dommy_backend_node__)
85
94
 
86
- walk_descendants(root.__dommy_backend_node__) do |nk|
87
- next unless nk.element?
95
+ @window.document.__internal_each_shadow_including_element__(root.__dommy_backend_node__) do |nk|
88
96
  next unless @definitions.key?(nk.name)
89
97
 
90
- # Force re-wrap by clearing the document's cached wrapper.
91
- @window.document.__internal_reset_wrapper__(nk)
92
- wrapped = @window.document.wrap_node(nk)
93
- @window.document.__internal_notify_connected__(wrapped) if wrapped
98
+ wrapped = rewrap_for_upgrade(@window.document, nk)
99
+ next unless wrapped
100
+
101
+ replay_observed_attributes(wrapped)
102
+ # connectedCallback is enqueued only for an element that is actually in
103
+ # a document tree — `customElements.upgrade()` on a detached subtree
104
+ # upgrades it without connecting it.
105
+ @window.document.__internal_notify_connected__(wrapped) if wrapped.is_connected?
94
106
  end
95
107
 
96
108
  nil
@@ -122,6 +134,22 @@ module Dommy
122
134
  list&.each { |p| p.fulfill(klass) }
123
135
  end
124
136
 
137
+ # Re-wrap `nk` as its definition's class, or nil when there is nothing to
138
+ # upgrade. An element that is already an instance of the class is left alone:
139
+ # upgrading an element that is already custom does nothing. The new wrapper
140
+ # is told which one it replaces, because a script may hold the element — a
141
+ # JS-defined element upgrades that reference in place.
142
+ def rewrap_for_upgrade(doc, nk)
143
+ previous = doc.__internal_peek_wrapper__(nk)
144
+ klass = @definitions[nk.name]
145
+ return nil if klass.is_a?(Module) && previous.is_a?(klass)
146
+
147
+ doc.__internal_reset_wrapper__(nk)
148
+ wrapped = doc.wrap_node(nk)
149
+ wrapped.__internal_upgraded_from__(previous) if previous && wrapped.respond_to?(:__internal_upgraded_from__)
150
+ wrapped
151
+ end
152
+
125
153
  # When define() lands after the matching element is already in
126
154
  # the document, those nodes need upgrading: re-wrap them with the
127
155
  # new class and fire connectedCallback.
@@ -130,20 +158,38 @@ module Dommy
130
158
  # Match by tag name rather than interpolating `name` into a CSS selector:
131
159
  # a spec-valid custom element name may contain "." (a CSS class selector
132
160
  # char) or other metacharacters, which would corrupt the query.
133
- doc.backend_doc.css("*").each do |nk|
161
+ # Shadow trees included, in shadow-including tree order (define step 18).
162
+ doc.__internal_each_shadow_including_element__(doc.backend_doc) do |nk|
134
163
  next unless nk.name == name
135
164
 
136
- doc.__internal_reset_wrapper__(nk)
137
- wrapped = doc.wrap_node(nk)
138
- doc.__internal_notify_connected__(wrapped) if wrapped
165
+ wrapped = rewrap_for_upgrade(doc, nk)
166
+ next unless wrapped
167
+
168
+ replay_observed_attributes(wrapped)
169
+ doc.__internal_notify_connected__(wrapped)
139
170
  end
140
171
  end
141
172
 
142
- def walk_descendants(node, &blk)
143
- yield node
144
- return unless node.respond_to?(:children)
145
-
146
- node.children.each { |c| walk_descendants(c, &blk) }
173
+ # "Upgrade an element" step 6: the element's *existing* attributes are
174
+ # replayed through attributeChangedCallback (oldValue null) before
175
+ # connectedCallback, so a definition registered after the markup was parsed
176
+ # still sees the attributes that were already there.
177
+ def replay_observed_attributes(element)
178
+ klass = element.class
179
+ return unless klass.respond_to?(:observed_attributes)
180
+
181
+ observed = Array(klass.observed_attributes).map { |a| a.to_s.downcase }
182
+ return if observed.empty?
183
+
184
+ element.get_attribute_names.each do |name|
185
+ next unless observed.include?(name.to_s.downcase)
186
+
187
+ # Routed through the coordinator so the callback's arity handling (the
188
+ # optional 4th namespace argument) stays in one place.
189
+ @window.document.__internal_notify_attribute_changed__(
190
+ element, name, nil, element.get_attribute(name)
191
+ )
192
+ end
147
193
  end
148
194
  end
149
195
  end