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.
- checksums.yaml +4 -4
- data/README.md +3 -3
- data/lib/dommy/animation.rb +4 -0
- data/lib/dommy/attr.rb +11 -5
- data/lib/dommy/backend/makiri_adapter.rb +330 -0
- data/lib/dommy/backend.rb +114 -33
- data/lib/dommy/blob.rb +2 -0
- data/lib/dommy/bridge.rb +11 -0
- data/lib/dommy/browser.rb +217 -0
- data/lib/dommy/compression_streams.rb +4 -0
- data/lib/dommy/crypto.rb +4 -0
- data/lib/dommy/css.rb +487 -50
- data/lib/dommy/custom_elements.rb +2 -2
- data/lib/dommy/data_transfer.rb +2 -0
- data/lib/dommy/data_uri.rb +35 -0
- data/lib/dommy/deferred_response.rb +59 -0
- data/lib/dommy/document.rb +386 -228
- data/lib/dommy/dom_exception.rb +2 -0
- data/lib/dommy/dom_parser.rb +7 -17
- data/lib/dommy/element.rb +502 -155
- data/lib/dommy/event.rb +240 -9
- data/lib/dommy/fetch.rb +152 -34
- data/lib/dommy/form_data.rb +2 -0
- data/lib/dommy/history.rb +2 -0
- data/lib/dommy/html_canvas_element.rb +230 -0
- data/lib/dommy/html_collection.rb +5 -6
- data/lib/dommy/html_elements.rb +304 -27
- data/lib/dommy/interaction/debug.rb +35 -0
- data/lib/dommy/interaction/dom_summary.rb +131 -0
- data/lib/dommy/interaction/driver.rb +244 -0
- data/lib/dommy/interaction/event_synthesis.rb +56 -0
- data/lib/dommy/interaction/field_interactor.rb +117 -0
- data/lib/dommy/interaction/form_submission.rb +268 -0
- data/lib/dommy/interaction/locator.rb +158 -0
- data/lib/dommy/interaction/role_query.rb +58 -0
- data/lib/dommy/interaction.rb +32 -0
- data/lib/dommy/internal/accessibility_tree.rb +215 -0
- data/lib/dommy/internal/accessible_description.rb +38 -0
- data/lib/dommy/internal/accessible_name.rb +301 -0
- data/lib/dommy/internal/aria_role.rb +252 -0
- data/lib/dommy/internal/aria_snapshot.rb +64 -0
- data/lib/dommy/internal/aria_state.rb +151 -0
- data/lib/dommy/internal/css/calc.rb +242 -0
- data/lib/dommy/internal/css/cascade.rb +430 -0
- data/lib/dommy/internal/css/color.rb +381 -0
- data/lib/dommy/internal/css/computed_style_declaration.rb +130 -0
- data/lib/dommy/internal/css/counters.rb +227 -0
- data/lib/dommy/internal/css/custom_properties.rb +183 -0
- data/lib/dommy/internal/css/media_query.rb +302 -0
- data/lib/dommy/internal/css/parser.rb +265 -0
- data/lib/dommy/internal/css/property_registry.rb +512 -0
- data/lib/dommy/internal/css/rule_index.rb +494 -0
- data/lib/dommy/internal/css/supports.rb +158 -0
- data/lib/dommy/internal/css/ua_stylesheet.rb +53 -0
- data/lib/dommy/internal/css_pseudo_handlers.rb +283 -42
- data/lib/dommy/internal/css_rule_text.rb +160 -0
- data/lib/dommy/internal/dom_matching.rb +80 -9
- data/lib/dommy/internal/element_matching.rb +109 -0
- data/lib/dommy/internal/global_functions.rb +33 -0
- data/lib/dommy/internal/mutation_coordinator.rb +95 -4
- data/lib/dommy/internal/namespaces.rb +49 -5
- data/lib/dommy/internal/node_wrapper_cache.rb +163 -26
- data/lib/dommy/internal/parent_node.rb +82 -5
- data/lib/dommy/internal/selector_ast.rb +124 -0
- data/lib/dommy/internal/selector_index.rb +146 -0
- data/lib/dommy/internal/selector_matcher.rb +756 -0
- data/lib/dommy/internal/selector_parser.rb +283 -131
- data/lib/dommy/internal/shadow_root_registry.rb +9 -2
- data/lib/dommy/internal/template_content_registry.rb +26 -18
- data/lib/dommy/internal/xml_serialization.rb +344 -0
- data/lib/dommy/intersection_observer.rb +2 -0
- data/lib/dommy/js/bridge_conformance.rb +80 -0
- data/lib/dommy/js/constructor_resolver.rb +44 -0
- data/lib/dommy/js/custom_element_bridge.rb +90 -0
- data/lib/dommy/js/dom_interfaces.rb +162 -0
- data/lib/dommy/js/handle_table.rb +60 -0
- data/lib/dommy/js/host_bridge.rb +517 -0
- data/lib/dommy/js/host_runtime.js +1495 -0
- data/lib/dommy/js/import_map.rb +58 -0
- data/lib/dommy/js/marshaller.rb +240 -0
- data/lib/dommy/js/module_loader.rb +99 -0
- data/lib/dommy/js/observable_runtime.js +742 -0
- data/lib/dommy/js/runtime.rb +115 -0
- data/lib/dommy/js/script_boot.rb +221 -0
- data/lib/dommy/js/wire_tags.rb +62 -0
- data/lib/dommy/location.rb +2 -0
- data/lib/dommy/media_query_list.rb +50 -14
- data/lib/dommy/message_channel.rb +22 -6
- data/lib/dommy/minitest/assertions.rb +27 -0
- data/lib/dommy/mutation_observer.rb +89 -4
- data/lib/dommy/navigator.rb +34 -2
- data/lib/dommy/node.rb +24 -14
- data/lib/dommy/notification.rb +2 -0
- data/lib/dommy/parser.rb +1 -1
- data/lib/dommy/performance.rb +21 -1
- data/lib/dommy/promise.rb +94 -10
- data/lib/dommy/range.rb +173 -31
- data/lib/dommy/resources.rb +178 -0
- data/lib/dommy/rspec/capy_style_matchers.rb +126 -0
- data/lib/dommy/scheduler.rb +149 -13
- data/lib/dommy/screen.rb +91 -0
- data/lib/dommy/shadow_root.rb +76 -13
- data/lib/dommy/storage.rb +2 -1
- data/lib/dommy/streams.rb +6 -0
- data/lib/dommy/text_codec.rb +7 -1
- data/lib/dommy/tree_walker.rb +33 -10
- data/lib/dommy/url.rb +13 -1
- data/lib/dommy/version.rb +1 -1
- data/lib/dommy/window.rb +199 -11
- data/lib/dommy/worker.rb +8 -4
- data/lib/dommy/xml_http_request.rb +47 -6
- data/lib/dommy.rb +36 -1
- metadata +96 -10
- data/lib/dommy/backend/nokogiri_adapter.rb +0 -127
- 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
|