dommy 0.8.0 → 0.9.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -3
  3. data/lib/dommy/animation.rb +4 -0
  4. data/lib/dommy/attr.rb +11 -5
  5. data/lib/dommy/backend/makiri_adapter.rb +330 -0
  6. data/lib/dommy/backend.rb +114 -33
  7. data/lib/dommy/blob.rb +2 -0
  8. data/lib/dommy/bridge.rb +11 -0
  9. data/lib/dommy/browser.rb +217 -0
  10. data/lib/dommy/compression_streams.rb +4 -0
  11. data/lib/dommy/crypto.rb +4 -0
  12. data/lib/dommy/css.rb +487 -50
  13. data/lib/dommy/custom_elements.rb +2 -2
  14. data/lib/dommy/data_transfer.rb +2 -0
  15. data/lib/dommy/data_uri.rb +35 -0
  16. data/lib/dommy/deferred_response.rb +59 -0
  17. data/lib/dommy/document.rb +386 -228
  18. data/lib/dommy/dom_exception.rb +2 -0
  19. data/lib/dommy/dom_parser.rb +7 -17
  20. data/lib/dommy/element.rb +502 -155
  21. data/lib/dommy/event.rb +240 -9
  22. data/lib/dommy/fetch.rb +152 -34
  23. data/lib/dommy/form_data.rb +2 -0
  24. data/lib/dommy/history.rb +2 -0
  25. data/lib/dommy/html_canvas_element.rb +230 -0
  26. data/lib/dommy/html_collection.rb +5 -6
  27. data/lib/dommy/html_elements.rb +304 -27
  28. data/lib/dommy/interaction/debug.rb +35 -0
  29. data/lib/dommy/interaction/dom_summary.rb +131 -0
  30. data/lib/dommy/interaction/driver.rb +244 -0
  31. data/lib/dommy/interaction/event_synthesis.rb +56 -0
  32. data/lib/dommy/interaction/field_interactor.rb +117 -0
  33. data/lib/dommy/interaction/form_submission.rb +268 -0
  34. data/lib/dommy/interaction/locator.rb +158 -0
  35. data/lib/dommy/interaction/role_query.rb +58 -0
  36. data/lib/dommy/interaction.rb +32 -0
  37. data/lib/dommy/internal/accessibility_tree.rb +215 -0
  38. data/lib/dommy/internal/accessible_description.rb +38 -0
  39. data/lib/dommy/internal/accessible_name.rb +301 -0
  40. data/lib/dommy/internal/aria_role.rb +252 -0
  41. data/lib/dommy/internal/aria_snapshot.rb +64 -0
  42. data/lib/dommy/internal/aria_state.rb +151 -0
  43. data/lib/dommy/internal/css/calc.rb +242 -0
  44. data/lib/dommy/internal/css/cascade.rb +430 -0
  45. data/lib/dommy/internal/css/color.rb +381 -0
  46. data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
  47. data/lib/dommy/internal/css/counters.rb +227 -0
  48. data/lib/dommy/internal/css/custom_properties.rb +183 -0
  49. data/lib/dommy/internal/css/media_query.rb +302 -0
  50. data/lib/dommy/internal/css/parser.rb +265 -0
  51. data/lib/dommy/internal/css/property_registry.rb +512 -0
  52. data/lib/dommy/internal/css/rule_index.rb +494 -0
  53. data/lib/dommy/internal/css/supports.rb +158 -0
  54. data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
  55. data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
  56. data/lib/dommy/internal/css_rule_text.rb +160 -0
  57. data/lib/dommy/internal/dom_matching.rb +80 -9
  58. data/lib/dommy/internal/element_matching.rb +109 -0
  59. data/lib/dommy/internal/global_functions.rb +33 -0
  60. data/lib/dommy/internal/mutation_coordinator.rb +95 -4
  61. data/lib/dommy/internal/namespaces.rb +49 -5
  62. data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
  63. data/lib/dommy/internal/parent_node.rb +82 -5
  64. data/lib/dommy/internal/selector_ast.rb +124 -0
  65. data/lib/dommy/internal/selector_index.rb +146 -0
  66. data/lib/dommy/internal/selector_matcher.rb +756 -0
  67. data/lib/dommy/internal/selector_parser.rb +283 -131
  68. data/lib/dommy/internal/shadow_root_registry.rb +9 -2
  69. data/lib/dommy/internal/template_content_registry.rb +26 -18
  70. data/lib/dommy/internal/xml_serialization.rb +344 -0
  71. data/lib/dommy/intersection_observer.rb +2 -0
  72. data/lib/dommy/js/bridge_conformance.rb +80 -0
  73. data/lib/dommy/js/constructor_resolver.rb +44 -0
  74. data/lib/dommy/js/custom_element_bridge.rb +90 -0
  75. data/lib/dommy/js/dom_interfaces.rb +162 -0
  76. data/lib/dommy/js/handle_table.rb +60 -0
  77. data/lib/dommy/js/host_bridge.rb +517 -0
  78. data/lib/dommy/js/host_runtime.js +1495 -0
  79. data/lib/dommy/js/import_map.rb +58 -0
  80. data/lib/dommy/js/marshaller.rb +240 -0
  81. data/lib/dommy/js/module_loader.rb +99 -0
  82. data/lib/dommy/js/observable_runtime.js +742 -0
  83. data/lib/dommy/js/runtime.rb +115 -0
  84. data/lib/dommy/js/script_boot.rb +221 -0
  85. data/lib/dommy/js/wire_tags.rb +62 -0
  86. data/lib/dommy/location.rb +2 -0
  87. data/lib/dommy/media_query_list.rb +50 -14
  88. data/lib/dommy/message_channel.rb +22 -6
  89. data/lib/dommy/minitest/assertions.rb +27 -0
  90. data/lib/dommy/mutation_observer.rb +89 -4
  91. data/lib/dommy/navigator.rb +34 -2
  92. data/lib/dommy/node.rb +24 -14
  93. data/lib/dommy/notification.rb +2 -0
  94. data/lib/dommy/parser.rb +1 -1
  95. data/lib/dommy/performance.rb +21 -1
  96. data/lib/dommy/promise.rb +94 -10
  97. data/lib/dommy/range.rb +173 -31
  98. data/lib/dommy/resources.rb +178 -0
  99. data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
  100. data/lib/dommy/scheduler.rb +149 -13
  101. data/lib/dommy/screen.rb +91 -0
  102. data/lib/dommy/shadow_root.rb +76 -13
  103. data/lib/dommy/storage.rb +2 -1
  104. data/lib/dommy/streams.rb +6 -0
  105. data/lib/dommy/text_codec.rb +7 -1
  106. data/lib/dommy/tree_walker.rb +33 -10
  107. data/lib/dommy/url.rb +13 -1
  108. data/lib/dommy/version.rb +1 -1
  109. data/lib/dommy/window.rb +199 -11
  110. data/lib/dommy/worker.rb +8 -4
  111. data/lib/dommy/xml_http_request.rb +47 -6
  112. data/lib/dommy.rb +36 -1
  113. metadata +96 -10
  114. data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
  115. data/lib/dommy/backend/nokolexbor_adapter.rb +0 -117
@@ -0,0 +1,494 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+ require_relative "media_query"
5
+ require_relative "supports"
6
+ require_relative "ua_stylesheet"
7
+ require_relative "../selector_ast"
8
+ require_relative "../selector_matcher"
9
+
10
+ module Dommy
11
+ module Internal
12
+ module CSS
13
+ # The document-wide "rule -> matched elements" index: every style
14
+ # rule's selector runs through SelectorMatcher exactly once, so
15
+ # per-element cascade work is a Hash lookup. Built lazily per style
16
+ # generation (see Cascade) and thrown away wholesale on invalidation —
17
+ # a viewport resize bumps the generation too, so @media re-evaluates.
18
+ class RuleIndex
19
+ Match = Struct.new(:origin, :specificity, :order, :declarations, :layer, :proximity)
20
+
21
+ # A resolved @scope: the scoping roots that scope-start matched, and the
22
+ # per-root scope limits (boundary elements from scope-end). An element is
23
+ # in scope for a root when it is an inclusive descendant of that root and
24
+ # not an inclusive descendant of any of the root's limits.
25
+ Scope = Struct.new(:roots, :ends)
26
+
27
+ def self.build(document)
28
+ new(document)
29
+ end
30
+
31
+ def initialize(document)
32
+ @document = document
33
+ @index = {}.compare_by_identity
34
+ @pseudo_index = Hash.new { |h, k| h[k] = {}.compare_by_identity }
35
+ @order = 0
36
+ @imported_urls = {}
37
+ # Cascade-layer order: full layer name => 0-based index, assigned on
38
+ # first declaration (statement or block), in source order across all
39
+ # sheets. Unlayered styles act as a final implicit layer at index
40
+ # #layer_count (see Cascade#layer_rank).
41
+ @layer_order = {}
42
+ @anon_layer_seq = 0
43
+ add_rules(UAStylesheet.rules, :ua)
44
+ author_sheets.each { |rules| add_rules(rules, :author) }
45
+ add_shadow_sheets
46
+ end
47
+
48
+ EMPTY = [].freeze
49
+
50
+ # A shadow-tree styling context: the ShadowRoot whose <style> a rule came
51
+ # from, and its host element (what `:host` targets).
52
+ Shadow = Struct.new(:root, :host)
53
+
54
+ def matches_for(element, pseudo_element = nil)
55
+ if pseudo_element
56
+ (@pseudo_index[pseudo_name(pseudo_element)] || EMPTY)[element] || EMPTY
57
+ else
58
+ @index[element] || EMPTY
59
+ end
60
+ end
61
+
62
+ # True when the document carries any author CSS at all — lets
63
+ # callers (visible? fast path) skip cascade work entirely.
64
+ def author_rules?
65
+ @author_rules ? true : false
66
+ end
67
+
68
+ # The number of explicit cascade layers — also the index of the
69
+ # implicit final layer that holds all unlayered styles.
70
+ def layer_count
71
+ @layer_order.size
72
+ end
73
+
74
+ # A match's layer position for cascade sorting: the explicit layer's
75
+ # 0-based order, or #layer_count for unlayered styles (and any layer
76
+ # name that was never declared). See Cascade#layer_rank.
77
+ def layer_index_of(layer)
78
+ (layer && @layer_order[layer]) || @layer_order.size
79
+ end
80
+
81
+ private
82
+
83
+ # Author sheets in document order. <style> and <link rel=stylesheet>
84
+ # are walked together so their relative order is preserved (it breaks
85
+ # cascade ties). A `media` attribute gates the whole sheet (same
86
+ # evaluator as @media); `disabled` mutes it.
87
+ def author_sheets
88
+ @document.query_selector_all("style, link").to_a.filter_map do |element|
89
+ media = element.get_attribute("media").to_s.strip
90
+ next nil unless media.empty? || MediaQuery.match?(media, environment)
91
+
92
+ link_element?(element) ? link_sheet_rules(element) : style_element_rules(element)
93
+ end
94
+ end
95
+
96
+ def link_element?(element)
97
+ element.local_name.to_s.casecmp("link").zero?
98
+ end
99
+
100
+ # A <link rel=stylesheet> contributes only once a host environment has
101
+ # filled its CSS in (Dommy fetches nothing). The filled sheet is read
102
+ # through `cascade_text`, exactly like a <style>'s CSSOM sheet.
103
+ def link_sheet_rules(element)
104
+ return nil unless element.respond_to?(:__internal_stylesheet_for_cascade__)
105
+
106
+ sheet = element.__internal_stylesheet_for_cascade__
107
+ return nil if sheet.nil? || sheet.disabled
108
+
109
+ safe_parse(sheet.cascade_text)
110
+ end
111
+
112
+ # When a CSSStyleSheet was instantiated for the <style> (and isn't
113
+ # stale), its CSSOM state wins: `cascade_text` carries insertRule edits
114
+ # and `disabled` mutes it. Otherwise the element's text is parsed.
115
+ def style_element_rules(element)
116
+ sheet = element.respond_to?(:__internal_instantiated_sheet__) && element.__internal_instantiated_sheet__
117
+ if sheet
118
+ return nil if sheet.disabled
119
+
120
+ safe_parse(sheet.cascade_text)
121
+ else
122
+ safe_parse(element.text_content.to_s)
123
+ end
124
+ end
125
+
126
+ def safe_parse(text)
127
+ Parser.parse(text)
128
+ rescue Parser::Unavailable
129
+ raise
130
+ rescue StandardError
131
+ []
132
+ end
133
+
134
+ def add_rules(rules, origin, layer: nil, scope: nil, shadow: nil)
135
+ rules.each do |rule|
136
+ case rule
137
+ when Parser::ImportRule
138
+ import_rules(rule, origin, layer: layer, scope: scope)
139
+ when Parser::LayerStatement
140
+ # `@layer a, b;` only fixes layer order; no rules to add.
141
+ rule.names.each { |name| register_layer(layer_full_name(layer, name)) }
142
+ when Parser::LayerRule
143
+ # `@layer name { ... }` — enter the (possibly nested) layer.
144
+ full = layer_full_name(layer, rule.name)
145
+ register_layer(full)
146
+ add_rules(rule.rules, origin, layer: full, scope: scope, shadow: shadow)
147
+ when Parser::ScopeRule
148
+ # `@scope (start) to (end) { ... }` — resolve the roots/limits and
149
+ # scope the block's rules. (A nested @scope replaces the outer one
150
+ # for its block; combining both is a documented limitation.)
151
+ resolved = build_scope(rule.start, rule.end)
152
+ add_rules(rule.rules, origin, layer: layer, scope: resolved, shadow: shadow) if resolved
153
+ else
154
+ if rule.grouping?
155
+ # @media / @supports: the block contributes (flattened, in
156
+ # source order, keeping the current layer/scope/shadow) only when active.
157
+ add_rules(rule.rules, origin, layer: layer, scope: scope, shadow: shadow) if grouping_active?(rule)
158
+ else
159
+ add_style_rule(rule, origin, layer, scope, shadow)
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ def add_style_rule(rule, origin, layer, scope, shadow)
166
+ @order += 1
167
+ @author_rules ||= origin == :author
168
+ rule.selectors.each do |selector|
169
+ # Classify per complex selector, not per list — `div, ::before`
170
+ # must index its branches separately (element vs pseudo).
171
+ selector.ast.selectors.each do |complex|
172
+ spec = complex.specificity.to_a
173
+ if shadow
174
+ index_shadow_complex(complex, spec, origin, layer, shadow, rule.declarations)
175
+ elsif complex.pseudo_element&.name == "part"
176
+ index_part_complex(complex, spec, origin, layer, rule.declarations)
177
+ else
178
+ pseudo = complex.pseudo_element&.name
179
+ target_index = pseudo ? @pseudo_index[pseudo] : @index
180
+ if scope
181
+ index_scoped(complex, spec, target_index, origin, layer, scope, rule.declarations)
182
+ else
183
+ query_complex(complex).each do |element|
184
+ (target_index[element] ||= []) << Match.new(origin, spec, @order, rule.declarations, layer, nil)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ # Each ShadowRoot's <style> sheets, scoped to that shadow tree. Document
193
+ # queries never cross into a shadow fragment, so a shadow tree's elements
194
+ # otherwise get no author rules — this is where encapsulated styles enter.
195
+ def add_shadow_sheets
196
+ return unless @document.respond_to?(:__internal_all_shadow_roots__)
197
+
198
+ @document.__internal_all_shadow_roots__.each do |root|
199
+ host = root.respond_to?(:host) ? root.host : nil
200
+ next unless host
201
+
202
+ shadow = Shadow.new(root, host)
203
+ shadow_style_rules(root).each { |rules| add_rules(rules, :author, shadow: shadow) }
204
+ end
205
+ end
206
+
207
+ def shadow_style_rules(root)
208
+ root.query_selector_all("style").to_a.filter_map { |style| safe_parse(style.text_content.to_s) }
209
+ end
210
+
211
+ # Index one complex selector of a shadow-tree style rule. Handles the
212
+ # shadow-only pseudos (`:host` / `:host()` targeting the host, and
213
+ # `::slotted()` targeting assigned light-DOM nodes) and scopes plain
214
+ # selectors to the shadow tree.
215
+ def index_shadow_complex(complex, spec, origin, layer, shadow, declarations)
216
+ if complex.pseudo_element&.name == "slotted"
217
+ slotted_targets(complex, shadow).each { |el| record(@index, el, origin, spec, declarations, layer) }
218
+ return
219
+ end
220
+
221
+ pseudo = complex.pseudo_element&.name
222
+ target_index = pseudo ? @pseudo_index[pseudo] : @index
223
+ shadow_targets(complex, shadow).each { |el| record(target_index, el, origin, spec, declarations, layer) }
224
+ end
225
+
226
+ # The elements a shadow-internal complex selector matches. A leading
227
+ # `:host` / `:host()` targets the host (when it is the whole selector) or
228
+ # acts as the scoping ancestor for the shadow-tree subject of the rest of
229
+ # the selector; otherwise the selector is matched within the shadow tree.
230
+ def shadow_targets(complex, shadow)
231
+ host_pseudo = leading_host_pseudo(complex)
232
+ unless host_pseudo
233
+ list = single_complex_list(complex.pseudo_element? ? complex.without_pseudo_element : complex)
234
+ return Internal::SelectorMatcher.query(shadow.root, list, scope: shadow.root)
235
+ end
236
+
237
+ return [] unless host_matches?(shadow.host, host_pseudo)
238
+ return [shadow.host] if complex.parts.length == 1
239
+
240
+ # `:host(...) <rest>` — every shadow element is conceptually a descendant
241
+ # of the host, so the remainder matches within the shadow tree. (The
242
+ # combinator after :host is treated as descendant; child/sibling refinement
243
+ # is a documented limitation.)
244
+ Internal::SelectorMatcher.query(shadow.root, host_remainder_list(complex), scope: shadow.root)
245
+ end
246
+
247
+ def leading_host_pseudo(complex)
248
+ complex.parts.first.compound.subclass_selectors.find do |selector|
249
+ selector.is_a?(Internal::SelectorAST::PseudoClass) && %w[host host-context].include?(selector.name)
250
+ end
251
+ end
252
+
253
+ # Whether `:host` / `:host(arg)` / `:host-context(arg)` matches the host.
254
+ def host_matches?(host, host_pseudo)
255
+ return false unless host
256
+
257
+ argument = host_pseudo.argument
258
+ if host_pseudo.name == "host-context"
259
+ return false if argument.nil?
260
+
261
+ return host_or_ancestor_matches?(host, argument)
262
+ end
263
+ argument.nil? || Internal::SelectorMatcher.matches?(host, argument)
264
+ end
265
+
266
+ def host_or_ancestor_matches?(element, argument)
267
+ current = element
268
+ while current.respond_to?(:parent_element)
269
+ return true if Internal::SelectorMatcher.matches?(current, argument)
270
+
271
+ current = current.parent_element
272
+ end
273
+ false
274
+ end
275
+
276
+ # Drop the leading `:host(...)` part, returning the remaining complex as a
277
+ # one-selector list (its new head loses the inherited combinator).
278
+ def host_remainder_list(complex)
279
+ rest = complex.parts[1..]
280
+ parts = [Internal::SelectorAST::Part.new(nil, rest.first.compound)] + rest[1..]
281
+ single_complex_list(Internal::SelectorAST::ComplexSelector.new(parts))
282
+ end
283
+
284
+ # `::slotted(<compound>)` — the light-DOM nodes assigned to a slot in this
285
+ # shadow tree whose flat-tree parent is the host, matching the compound.
286
+ def slotted_targets(complex, shadow)
287
+ compound = complex.pseudo_element.argument
288
+ list = compound.is_a?(Internal::SelectorAST::SelectorList) ? compound : single_complex_list(compound)
289
+ assigned_slottables(shadow).select { |el| Internal::SelectorMatcher.matches?(el, list) }
290
+ end
291
+
292
+ def assigned_slottables(shadow)
293
+ shadow.root.query_selector_all("slot").to_a.flat_map do |slot|
294
+ slot.respond_to?(:assigned_elements) ? slot.assigned_elements : []
295
+ end
296
+ end
297
+
298
+ # `<compound>::part(name)` from a document/outer style: the elements with a
299
+ # matching `part` token inside the shadow tree of a host the compound chain
300
+ # matches.
301
+ def index_part_complex(complex, spec, origin, layer, declarations)
302
+ names = complex.pseudo_element.argument
303
+ return unless names.is_a?(Array) && !names.empty?
304
+
305
+ host_list = single_complex_list(complex.without_pseudo_element)
306
+ Internal::SelectorMatcher.query(@document, host_list).each do |host|
307
+ part_elements(host, names).each { |el| record(@index, el, origin, spec, declarations, layer) }
308
+ end
309
+ end
310
+
311
+ def part_elements(host, names)
312
+ root = host.respond_to?(:shadow_root) ? host.shadow_root : nil
313
+ return [] unless root
314
+
315
+ root.query_selector_all("[part]").to_a.select do |el|
316
+ tokens = el.get_attribute("part").to_s.split(/\s+/)
317
+ names.all? { |name| tokens.include?(name) }
318
+ end
319
+ end
320
+
321
+ def single_complex_list(complex)
322
+ Internal::SelectorAST::SelectorList.new([complex])
323
+ end
324
+
325
+ def record(target_index, element, origin, spec, declarations, layer)
326
+ (target_index[element] ||= []) << Match.new(origin, spec, @order, declarations, layer, nil)
327
+ end
328
+
329
+ # Index a scoped style rule: for each scoping root, match the selector
330
+ # with `:scope` bound to that root, keep only in-scope subjects, and tag
331
+ # each match with its scope proximity (generations from the subject up to
332
+ # the root — the cascade's nearest-scope tiebreaker).
333
+ def index_scoped(complex, spec, target_index, origin, layer, scope, declarations)
334
+ scope.roots.each do |root|
335
+ ends = scope.ends[root] || EMPTY
336
+ query_complex(complex, scope: root).each do |element|
337
+ next unless in_scope?(element, root, ends)
338
+
339
+ (target_index[element] ||= []) <<
340
+ Match.new(origin, spec, @order, declarations, layer, generations(element, root))
341
+ end
342
+ end
343
+ end
344
+
345
+ # Record a (fully-qualified) layer's first appearance, idempotently —
346
+ # registering each ancestor prefix first so a parent layer always
347
+ # precedes its sublayers in layer order (`@layer a.b` declares `a` too,
348
+ # and earlier). Returns the name.
349
+ def register_layer(name)
350
+ parts = name.split(".")
351
+ parts.each_index do |i|
352
+ prefix = parts[0..i].join(".")
353
+ @layer_order[prefix] ||= @layer_order.size
354
+ end
355
+ name
356
+ end
357
+
358
+ # The full dotted name of layer `name` nested under `parent` (`outer` +
359
+ # `inner` -> `outer.inner`). A nil `name` is anonymous — minted unique
360
+ # each call so it can never be reopened (the leading space keeps it out
361
+ # of any real ident space).
362
+ def layer_full_name(parent, name)
363
+ if name.nil?
364
+ @anon_layer_seq += 1
365
+ name = " anon#{@anon_layer_seq}"
366
+ end
367
+
368
+ parent ? "#{parent}.#{name}" : name
369
+ end
370
+
371
+ # Splice an @import's referenced sheet in at this position: gate on its
372
+ # media query, resolve the URL through the host (Dommy fetches nothing),
373
+ # then parse and recurse. Each URL is imported once per build — a cheap
374
+ # cycle/duplicate guard.
375
+ def import_rules(rule, origin, layer: nil, scope: nil)
376
+ return unless rule.media.empty? || MediaQuery.match?(rule.media, environment)
377
+ return if @imported_urls[rule.url]
378
+
379
+ @imported_urls[rule.url] = true
380
+ css = resolve_import(rule.url)
381
+ add_rules(safe_parse(css), origin, layer: layer, scope: scope) if css
382
+ end
383
+
384
+ # Resolve an @scope's prelude into a Scope (roots + per-root limits), or
385
+ # nil when the scope-start selector is invalid (the block then matches
386
+ # nothing). A nil start scopes to the document element; a nil end has no
387
+ # lower boundary. scope-end is matched with `:scope` bound to the root
388
+ # and kept to that root's subtree (a "donut" lower boundary).
389
+ def build_scope(start_text, end_text)
390
+ roots =
391
+ if start_text
392
+ ast = parse_selector(start_text)
393
+ return nil unless ast
394
+
395
+ Internal::SelectorMatcher.query(@document, ast)
396
+ else
397
+ [@document.document_element].compact
398
+ end
399
+
400
+ end_ast = end_text ? parse_selector(end_text) : nil
401
+ ends = {}.compare_by_identity
402
+ roots.each do |root|
403
+ ends[root] = if end_ast
404
+ Internal::SelectorMatcher.query(@document, end_ast, scope: root).select { |limit| inclusive?(root, limit) }
405
+ else
406
+ EMPTY
407
+ end
408
+ end
409
+ Scope.new(roots, ends)
410
+ end
411
+
412
+ def parse_selector(text)
413
+ Internal::SelectorParser.parse!(text)
414
+ rescue DOMException::SyntaxError
415
+ nil
416
+ end
417
+
418
+ # In scope for `root`: an inclusive descendant of the root that is not an
419
+ # inclusive descendant of any of the root's scope limits.
420
+ def in_scope?(element, root, ends)
421
+ return false unless inclusive?(root, element)
422
+
423
+ ends.none? { |limit| inclusive?(limit, element) }
424
+ end
425
+
426
+ # Whether `descendant` is `ancestor` or contained by it (DOM contains? is
427
+ # inclusive, but the equal? guard keeps it correct for any backend).
428
+ def inclusive?(ancestor, descendant)
429
+ ancestor.equal?(descendant) ||
430
+ (ancestor.respond_to?(:contains?) && ancestor.contains?(descendant))
431
+ end
432
+
433
+ # Scope proximity: the number of generations from `element` up to `root`
434
+ # (0 when they are the same element). `root` is guaranteed to be an
435
+ # ancestor-or-self of `element` (in_scope? checked it).
436
+ def generations(element, root)
437
+ steps = 0
438
+ current = element
439
+ until current.nil? || current.equal?(root)
440
+ current = current.parent_element
441
+ steps += 1
442
+ end
443
+ steps
444
+ end
445
+
446
+ # The host's URL -> CSS resolver (dommy-rack wires it to a same-origin
447
+ # fetch); nil when no host supplied one, so @import then contributes
448
+ # nothing.
449
+ def resolve_import(url)
450
+ resolver = @document.respond_to?(:css_import_resolver) && @document.css_import_resolver
451
+ return nil unless resolver
452
+
453
+ text = resolver.call(url)
454
+ text&.to_s
455
+ rescue StandardError
456
+ nil
457
+ end
458
+
459
+ # Whether a grouping rule's block applies: @media gates on the viewport
460
+ # environment, @supports on its condition. (@layer is handled in
461
+ # add_rules, not here.) Unknown grouping kinds default to active.
462
+ def grouping_active?(rule)
463
+ case rule
464
+ when Parser::MediaRule then MediaQuery.match?(rule.condition, environment)
465
+ when Parser::SupportsRule then Supports.match?(rule.condition)
466
+ else true
467
+ end
468
+ end
469
+
470
+ # The media environment of the document's window — or the default
471
+ # (1280x720) for windowless documents (fragments, DOMParser output).
472
+ def environment
473
+ @environment ||= begin
474
+ view = @document.respond_to?(:default_view) ? @document.default_view : nil
475
+ (view && view.media_environment) || MediaQuery::DEFAULT
476
+ end
477
+ end
478
+
479
+ # Match one complex selector (its pseudo-element stripped — the
480
+ # matcher never matches pseudo-element subjects against elements;
481
+ # specificity stays that of the full selector).
482
+ def query_complex(complex, scope: nil)
483
+ complex = complex.without_pseudo_element if complex.pseudo_element?
484
+ list = Internal::SelectorAST::SelectorList.new([complex])
485
+ Internal::SelectorMatcher.query(@document, list, scope: scope)
486
+ end
487
+
488
+ def pseudo_name(pseudo_element)
489
+ pseudo_element.to_s.delete_prefix("::").delete_prefix(":")
490
+ end
491
+ end
492
+ end
493
+ end
494
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../selector_parser"
4
+
5
+ module Dommy
6
+ module Internal
7
+ module CSS
8
+ # Evaluator for an `@supports` <supports-condition>. Dommy has no
9
+ # feature-detection database, so a feature query `(prop: value)` is
10
+ # treated optimistically — supported when it is a well-formed declaration
11
+ # — and `selector(s)` is supported when `s` parses as a selector. The
12
+ # boolean combinators (`not` / `and` / `or`) and grouping compose these.
13
+ # This matches a browser for the common case (querying a feature that
14
+ # actually exists); the only divergence is a query about a genuinely
15
+ # unsupported property, which we lean towards "supported" rather than drop
16
+ # the block. Anything unparseable evaluates false. Never raises.
17
+ module Supports
18
+ module_function
19
+
20
+ def match?(condition)
21
+ condition = condition.to_s.strip
22
+ return false if condition.empty?
23
+
24
+ evaluate_condition(condition)
25
+ rescue StandardError
26
+ false
27
+ end
28
+
29
+ # The CSS.supports(property, value) two-argument form: is
30
+ # `property: value` a supported declaration? Optimistic, like a feature
31
+ # query in match? — a non-empty property + value is "supported".
32
+ def supports_declaration?(property, value)
33
+ property = property.to_s.strip
34
+ value = value.to_s.strip
35
+ return false if property.empty? || value.empty?
36
+
37
+ declaration_supported?("#{property}: #{value}")
38
+ rescue StandardError
39
+ false
40
+ end
41
+
42
+ # <supports-condition> = not <in-parens>
43
+ # | <in-parens> [and <in-parens>]*
44
+ # | <in-parens> [or <in-parens>]*
45
+ def evaluate_condition(str)
46
+ str = str.strip
47
+ rest = after_keyword(str, "not")
48
+ return !evaluate_in_parens(rest) if rest
49
+
50
+ operands, operator = split_operands(str)
51
+ values = operands.map { |operand| evaluate_in_parens(operand) }
52
+ return values.first if operator.nil?
53
+
54
+ operator == "or" ? values.any? : values.all?
55
+ end
56
+
57
+ # <in-parens> = ( <condition> ) | ( <declaration> ) | selector(<sel>)
58
+ def evaluate_in_parens(str)
59
+ str = str.strip
60
+ inner = function_argument(str, "selector")
61
+ return selector_supported?(inner) if inner
62
+
63
+ return false unless str.start_with?("(") && str.end_with?(")")
64
+
65
+ body = str[1...-1].strip
66
+ declaration?(body) ? declaration_supported?(body) : evaluate_condition(body)
67
+ end
68
+
69
+ # If `str` begins with `keyword` followed by whitespace, return the
70
+ # remainder; nil otherwise (so `notch` isn't read as `not`).
71
+ def after_keyword(str, keyword)
72
+ return nil unless str.length > keyword.length
73
+ return nil unless str[0, keyword.length].casecmp(keyword).zero?
74
+ return nil unless str[keyword.length].match?(/\s/)
75
+
76
+ str[keyword.length..].strip
77
+ end
78
+
79
+ # Split a condition into its top-level operands and the joining
80
+ # operator ("and"/"or"/nil). Mixing `and` and `or` at one level is
81
+ # invalid per spec — raise (match? maps it to false).
82
+ def split_operands(str)
83
+ operands = []
84
+ operators = []
85
+ depth = 0
86
+ start = 0
87
+ index = 0
88
+ length = str.length
89
+
90
+ while index < length
91
+ char = str[index]
92
+ if char == "("
93
+ depth += 1
94
+ index += 1
95
+ elsif char == ")"
96
+ depth -= 1
97
+ index += 1
98
+ elsif depth.zero? && (keyword = operator_at(str, index))
99
+ operands << str[start...index].strip
100
+ operators << keyword
101
+ index += keyword.length
102
+ start = index
103
+ else
104
+ index += 1
105
+ end
106
+ end
107
+ operands << str[start..].strip
108
+
109
+ distinct = operators.uniq
110
+ raise "mixed @supports combinators" if distinct.length > 1
111
+
112
+ [operands, distinct.first]
113
+ end
114
+
115
+ # "and"/"or" at `index` when it stands as a whole word (whitespace
116
+ # before, whitespace or `(` after).
117
+ def operator_at(str, index)
118
+ return nil unless index.positive? && str[index - 1].match?(/\s/)
119
+
120
+ %w[and or].each do |keyword|
121
+ next unless str[index, keyword.length]&.casecmp(keyword)&.zero?
122
+
123
+ after = str[index + keyword.length]
124
+ return keyword if after.nil? || after.match?(/\s/) || after == "("
125
+ end
126
+ nil
127
+ end
128
+
129
+ # The argument of `name(...)` when `str` is exactly that call, else nil.
130
+ def function_argument(str, name)
131
+ return nil unless str.downcase.start_with?("#{name}(") && str.end_with?(")")
132
+
133
+ str[(name.length + 1)...-1].strip
134
+ end
135
+
136
+ # A declaration body is `ident : value` (vs a nested condition, which
137
+ # starts with `(`, `not`, or a function).
138
+ def declaration?(body)
139
+ body.match?(/\A[-\w]+\s*:/)
140
+ end
141
+
142
+ # Optimistic: a declaration is "supported" when it has a property name
143
+ # and a non-empty value. (No feature database to consult.)
144
+ def declaration_supported?(body)
145
+ name, value = body.split(":", 2)
146
+ !name.to_s.strip.empty? && !value.to_s.strip.empty?
147
+ end
148
+
149
+ def selector_supported?(selector_text)
150
+ SelectorParser.parse!(selector_text)
151
+ true
152
+ rescue DOMException::SyntaxError
153
+ false
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end