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,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dommy
|
|
4
|
+
module Internal
|
|
5
|
+
module CSS
|
|
6
|
+
# A Media Queries Level 4 subset evaluator for `matchMedia` and
|
|
7
|
+
# friends. Queries are matched against an Environment snapshot
|
|
8
|
+
# rather than a real device.
|
|
9
|
+
#
|
|
10
|
+
# Supported: media types (all/screen true, anything else false),
|
|
11
|
+
# `not` / `only` prefixes, `and` chains, comma-separated OR lists,
|
|
12
|
+
# `not` before a single condition, `(feature: value)` and
|
|
13
|
+
# `(feature)` boolean contexts, and the range syntax for
|
|
14
|
+
# width/height (`(width >= 600px)`, `(400px <= width <= 800px)`).
|
|
15
|
+
#
|
|
16
|
+
# Deliberately given up: the `or` combinator, nested parentheses
|
|
17
|
+
# (`(not (width))`, general `<media-condition>` grouping), calc(),
|
|
18
|
+
# and most discrete features beyond the ones listed in
|
|
19
|
+
# FEATURE_VALUES below. Any query we cannot parse — including one
|
|
20
|
+
# using `or` — evaluates to false, mirroring the spec's "unknown
|
|
21
|
+
# query becomes `not all`" rule; we never raise.
|
|
22
|
+
module MediaQuery
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
Environment = Struct.new(
|
|
26
|
+
:viewport_width, :viewport_height, :device_pixel_ratio,
|
|
27
|
+
:prefers_color_scheme, :prefers_reduced_motion, :hover, :pointer,
|
|
28
|
+
keyword_init: true
|
|
29
|
+
) do
|
|
30
|
+
def self.default
|
|
31
|
+
new(viewport_width: 1280, viewport_height: 720, device_pixel_ratio: 1.0,
|
|
32
|
+
prefers_color_scheme: "light", prefers_reduced_motion: "no-preference",
|
|
33
|
+
hover: "hover", pointer: "fine")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
DEFAULT = Environment.default.freeze
|
|
38
|
+
|
|
39
|
+
# Valid values for the discrete features we support; anything
|
|
40
|
+
# else is treated as unparseable (query becomes false).
|
|
41
|
+
FEATURE_VALUES = {
|
|
42
|
+
"prefers-color-scheme" => %w[light dark],
|
|
43
|
+
"prefers-reduced-motion" => %w[no-preference reduce],
|
|
44
|
+
"hover" => %w[none hover],
|
|
45
|
+
"any-hover" => %w[none hover],
|
|
46
|
+
"pointer" => %w[none coarse fine],
|
|
47
|
+
"any-pointer" => %w[none coarse fine]
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Evaluates a media query list. Comma-separated queries OR
|
|
51
|
+
# together: the list matches if any single query matches. The
|
|
52
|
+
# empty string is the empty query list, which matches (this is
|
|
53
|
+
# what `matchMedia("")` does in browsers).
|
|
54
|
+
def match?(text, environment = DEFAULT)
|
|
55
|
+
text = text.to_s.strip
|
|
56
|
+
return true if text.empty?
|
|
57
|
+
|
|
58
|
+
# Commas cannot appear inside our supported conditions, so a
|
|
59
|
+
# plain split is safe.
|
|
60
|
+
text.split(",", -1).any? { |query| query_match?(query.strip, environment) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# --- single query ---------------------------------------------
|
|
64
|
+
|
|
65
|
+
def query_match?(query, env)
|
|
66
|
+
tokens = tokenize(query)
|
|
67
|
+
return false if tokens.nil? || tokens.empty?
|
|
68
|
+
|
|
69
|
+
i = 0
|
|
70
|
+
negate_query = false
|
|
71
|
+
if tokens[0] == "not"
|
|
72
|
+
negate_query = true
|
|
73
|
+
i = 1
|
|
74
|
+
elsif tokens[0] == "only"
|
|
75
|
+
# `only` is only valid immediately before a media type
|
|
76
|
+
# (`only screen`); `only (condition)` is a parse error.
|
|
77
|
+
return false unless tokens[1].is_a?(String)
|
|
78
|
+
|
|
79
|
+
i = 1
|
|
80
|
+
end
|
|
81
|
+
return false if i >= tokens.size
|
|
82
|
+
|
|
83
|
+
# First segment: a media type word, or a leading condition.
|
|
84
|
+
first = tokens[i]
|
|
85
|
+
if first.is_a?(String)
|
|
86
|
+
return false if %w[and not only or].include?(first)
|
|
87
|
+
|
|
88
|
+
result = first == "all" || first == "screen"
|
|
89
|
+
i += 1
|
|
90
|
+
else
|
|
91
|
+
result = evaluate_condition(first[1], env)
|
|
92
|
+
return false if result.nil?
|
|
93
|
+
|
|
94
|
+
i += 1
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Remaining segments: `and [not] ( ... )` repeated. Anything
|
|
98
|
+
# else (notably `or`) makes the whole query unparseable.
|
|
99
|
+
while i < tokens.size
|
|
100
|
+
return false unless tokens[i] == "and"
|
|
101
|
+
|
|
102
|
+
i += 1
|
|
103
|
+
negate_condition = false
|
|
104
|
+
if tokens[i] == "not"
|
|
105
|
+
negate_condition = true
|
|
106
|
+
i += 1
|
|
107
|
+
end
|
|
108
|
+
token = tokens[i]
|
|
109
|
+
return false unless token.is_a?(Array)
|
|
110
|
+
|
|
111
|
+
value = evaluate_condition(token[1], env)
|
|
112
|
+
return false if value.nil?
|
|
113
|
+
|
|
114
|
+
value = !value if negate_condition
|
|
115
|
+
result &&= value
|
|
116
|
+
i += 1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
negate_query ? !result : result
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Splits a query into lower-cased keyword strings and
|
|
123
|
+
# `[:cond, inner]` pairs for parenthesized conditions. Returns
|
|
124
|
+
# nil when the query contains anything else (stray characters,
|
|
125
|
+
# nested or unbalanced parentheses).
|
|
126
|
+
def tokenize(query)
|
|
127
|
+
tokens = []
|
|
128
|
+
rest = query
|
|
129
|
+
until (rest = rest.lstrip).empty?
|
|
130
|
+
if rest.start_with?("(")
|
|
131
|
+
close = rest.index(")")
|
|
132
|
+
return nil if close.nil?
|
|
133
|
+
|
|
134
|
+
inner = rest[1...close]
|
|
135
|
+
return nil if inner.include?("(") # nested parens unsupported
|
|
136
|
+
|
|
137
|
+
tokens << [:cond, inner.strip.downcase]
|
|
138
|
+
rest = rest[(close + 1)..]
|
|
139
|
+
elsif (match = rest.match(/\A[a-z][a-z0-9-]*/i))
|
|
140
|
+
tokens << match[0].downcase
|
|
141
|
+
rest = rest[match[0].length..]
|
|
142
|
+
return nil unless rest.empty? || rest.start_with?("(") || rest.match?(/\A\s/)
|
|
143
|
+
else
|
|
144
|
+
return nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
tokens
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# --- conditions ------------------------------------------------
|
|
151
|
+
|
|
152
|
+
# `inner` is the lower-cased text between parentheses. Returns
|
|
153
|
+
# true/false, or nil when the condition cannot be interpreted.
|
|
154
|
+
def evaluate_condition(inner, env)
|
|
155
|
+
return nil if inner.empty?
|
|
156
|
+
|
|
157
|
+
if inner.match?(/[<>]/) || (inner.include?("=") && !inner.include?(":"))
|
|
158
|
+
evaluate_range(inner, env)
|
|
159
|
+
elsif inner.include?(":")
|
|
160
|
+
name, _, value = inner.partition(":")
|
|
161
|
+
evaluate_feature(name.strip, value.strip, env)
|
|
162
|
+
else
|
|
163
|
+
evaluate_boolean_feature(inner, env)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def evaluate_feature(name, value, env)
|
|
168
|
+
case name
|
|
169
|
+
when "width", "min-width", "max-width"
|
|
170
|
+
px = parse_length(value)
|
|
171
|
+
px && compare_with_prefix(name, env.viewport_width, px)
|
|
172
|
+
when "height", "min-height", "max-height"
|
|
173
|
+
px = parse_length(value)
|
|
174
|
+
px && compare_with_prefix(name, env.viewport_height, px)
|
|
175
|
+
when "orientation"
|
|
176
|
+
case value
|
|
177
|
+
when "portrait" then env.viewport_height >= env.viewport_width
|
|
178
|
+
when "landscape" then env.viewport_width > env.viewport_height
|
|
179
|
+
end
|
|
180
|
+
when "aspect-ratio", "min-aspect-ratio", "max-aspect-ratio"
|
|
181
|
+
evaluate_aspect_ratio(name, value, env)
|
|
182
|
+
when "prefers-color-scheme"
|
|
183
|
+
FEATURE_VALUES[name].include?(value) ? env.prefers_color_scheme == value : nil
|
|
184
|
+
when "prefers-reduced-motion"
|
|
185
|
+
FEATURE_VALUES[name].include?(value) ? env.prefers_reduced_motion == value : nil
|
|
186
|
+
when "hover", "any-hover"
|
|
187
|
+
FEATURE_VALUES[name].include?(value) ? env.hover == value : nil
|
|
188
|
+
when "pointer", "any-pointer"
|
|
189
|
+
FEATURE_VALUES[name].include?(value) ? env.pointer == value : nil
|
|
190
|
+
when "resolution", "min-resolution", "max-resolution"
|
|
191
|
+
dppx = parse_resolution(value)
|
|
192
|
+
dppx && compare_with_prefix(name, env.device_pixel_ratio, dppx)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def evaluate_boolean_feature(name, env)
|
|
197
|
+
case name
|
|
198
|
+
when "width" then env.viewport_width > 0
|
|
199
|
+
when "height" then env.viewport_height > 0
|
|
200
|
+
when "hover", "any-hover" then env.hover != "none"
|
|
201
|
+
when "pointer", "any-pointer" then env.pointer != "none"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# `(aspect-ratio: a/b)` — compare width/height against a/b
|
|
206
|
+
# without floating point: width * b <=> a * height.
|
|
207
|
+
def evaluate_aspect_ratio(name, value, env)
|
|
208
|
+
match = value.match(%r{\A(\d+)\s*/\s*(\d+)\z})
|
|
209
|
+
return nil unless match
|
|
210
|
+
|
|
211
|
+
a = Integer(match[1], 10)
|
|
212
|
+
b = Integer(match[2], 10)
|
|
213
|
+
return nil if a.zero? || b.zero?
|
|
214
|
+
|
|
215
|
+
compare_with_prefix(name, env.viewport_width * b, a * env.viewport_height)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Range syntax, single (`width >= 600px`, `600px <= width`) or
|
|
219
|
+
# double (`400px <= width <= 800px`). width/height only.
|
|
220
|
+
def evaluate_range(inner, env)
|
|
221
|
+
parts = inner.split(/(<=|>=|<|>|=)/).map(&:strip)
|
|
222
|
+
case parts.size
|
|
223
|
+
when 3
|
|
224
|
+
left, op, right = parts
|
|
225
|
+
if (actual = range_feature_value(left, env))
|
|
226
|
+
value = parse_length(right)
|
|
227
|
+
value && compare(actual, op, value)
|
|
228
|
+
elsif (actual = range_feature_value(right, env))
|
|
229
|
+
value = parse_length(left)
|
|
230
|
+
value && compare(value, op, actual)
|
|
231
|
+
end
|
|
232
|
+
when 5
|
|
233
|
+
low, op1, name, op2, high = parts
|
|
234
|
+
actual = range_feature_value(name, env)
|
|
235
|
+
return nil unless actual
|
|
236
|
+
return nil unless (%w[< <=].include?(op1) && %w[< <=].include?(op2)) ||
|
|
237
|
+
(%w[> >=].include?(op1) && %w[> >=].include?(op2))
|
|
238
|
+
|
|
239
|
+
low_px = parse_length(low)
|
|
240
|
+
high_px = parse_length(high)
|
|
241
|
+
return nil unless low_px && high_px
|
|
242
|
+
|
|
243
|
+
compare(low_px, op1, actual) && compare(actual, op2, high_px)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def range_feature_value(name, env)
|
|
248
|
+
case name
|
|
249
|
+
when "width" then env.viewport_width
|
|
250
|
+
when "height" then env.viewport_height
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def compare(left, op, right)
|
|
255
|
+
case op
|
|
256
|
+
when "<" then left < right
|
|
257
|
+
when "<=" then left <= right
|
|
258
|
+
when ">" then left > right
|
|
259
|
+
when ">=" then left >= right
|
|
260
|
+
when "=" then left == right
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# min- prefix means "at least", max- means "at most", bare name
|
|
265
|
+
# means exact equality.
|
|
266
|
+
def compare_with_prefix(name, actual, target)
|
|
267
|
+
if name.start_with?("min-")
|
|
268
|
+
actual >= target
|
|
269
|
+
elsif name.start_with?("max-")
|
|
270
|
+
actual <= target
|
|
271
|
+
else
|
|
272
|
+
actual == target
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# --- value parsing ---------------------------------------------
|
|
277
|
+
|
|
278
|
+
# Lengths in px; em/rem are converted at the canonical 16px.
|
|
279
|
+
# A bare 0 (no unit) is a valid length; any other unitless
|
|
280
|
+
# number is not.
|
|
281
|
+
def parse_length(value)
|
|
282
|
+
return 0.0 if value.match?(/\A0+(?:\.0+)?\z/)
|
|
283
|
+
|
|
284
|
+
match = value.match(/\A(\d+(?:\.\d+)?)(px|em|rem)\z/)
|
|
285
|
+
return nil unless match
|
|
286
|
+
|
|
287
|
+
number = Float(match[1])
|
|
288
|
+
match[2] == "px" ? number : number * 16
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Resolutions normalized to dppx; dpi divides by 96.
|
|
292
|
+
def parse_resolution(value)
|
|
293
|
+
match = value.match(/\A(\d+(?:\.\d+)?)(x|dppx|dpi)\z/)
|
|
294
|
+
return nil unless match
|
|
295
|
+
|
|
296
|
+
number = Float(match[1])
|
|
297
|
+
match[2] == "dpi" ? number / 96 : number
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../selector_parser"
|
|
4
|
+
|
|
5
|
+
module Dommy
|
|
6
|
+
module Internal
|
|
7
|
+
module CSS
|
|
8
|
+
# The abstraction seam over the CSS stylesheet parser. The only
|
|
9
|
+
# implementation is makiri's lexbor binding
|
|
10
|
+
# (Makiri::Lexbor::CSS.parse_stylesheet) — see css-cascade.md: the
|
|
11
|
+
# tokenizer, error recovery, and specificity all come from lexbor;
|
|
12
|
+
# this module just normalizes its plain-Hash output into structs the
|
|
13
|
+
# cascade layer consumes. CSS parsing is independent of the DOM
|
|
14
|
+
# backend, so this works on the Nokogiri backend too as long as the
|
|
15
|
+
# makiri gem is installed.
|
|
16
|
+
module Parser
|
|
17
|
+
# One comma branch of a selector list. `specificity` is the
|
|
18
|
+
# Selectors L4 [A, B, C] triple computed from Dommy's AST.
|
|
19
|
+
Selector = Struct.new(:text, :ast, :specificity)
|
|
20
|
+
|
|
21
|
+
Declaration = Struct.new(:name, :value, :important)
|
|
22
|
+
|
|
23
|
+
StyleRule = Struct.new(:selectors, :declarations) do
|
|
24
|
+
def grouping? = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Grouping at-rules (CSS conditional rules): their block contributes
|
|
28
|
+
# its rules to the cascade only when the rule is "active" (RuleIndex
|
|
29
|
+
# decides — @media query matches, @supports condition holds, @layer
|
|
30
|
+
# always). The block is flattened in source order; @layer ordering and
|
|
31
|
+
# @supports/@media precedence beyond source order are out of scope.
|
|
32
|
+
MediaRule = Struct.new(:condition, :rules) do
|
|
33
|
+
def grouping? = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @layer name { ... } — a named (or anonymous, name nil) cascade layer.
|
|
37
|
+
# Its rules always participate; their precedence is decided by layer
|
|
38
|
+
# order (RuleIndex/Cascade), which sits between origin and specificity.
|
|
39
|
+
LayerRule = Struct.new(:name, :rules) do
|
|
40
|
+
def grouping? = true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @layer a, b, c; (statement form) or an empty @layer block — declares
|
|
44
|
+
# layer names (and thereby their order) without contributing rules.
|
|
45
|
+
# `names` entries are layer names; nil means an anonymous layer.
|
|
46
|
+
LayerStatement = Struct.new(:names) do
|
|
47
|
+
def grouping? = false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @supports (cond) { ... } — its rules participate when `condition`
|
|
51
|
+
# evaluates true (Supports.match?).
|
|
52
|
+
SupportsRule = Struct.new(:condition, :rules) do
|
|
53
|
+
def grouping? = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @scope (start) to (end) { ... } — `start`/`end` are selector-list texts
|
|
57
|
+
# (either may be nil: a missing `start` scopes to the document element,
|
|
58
|
+
# a missing `end` has no lower boundary). RuleIndex resolves the scoping
|
|
59
|
+
# roots and limits and scopes the block's rules to in-scope elements.
|
|
60
|
+
ScopeRule = Struct.new(:start, :end, :rules) do
|
|
61
|
+
def grouping? = true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @import url(...) [media] — the referenced sheet's rules are spliced
|
|
65
|
+
# in at this position (RuleIndex resolves the URL through the host).
|
|
66
|
+
# `media` (may be "") gates them like @media.
|
|
67
|
+
ImportRule = Struct.new(:url, :media) do
|
|
68
|
+
def grouping? = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Raised when CSS features are used without the makiri gem.
|
|
72
|
+
class Unavailable < StandardError
|
|
73
|
+
def initialize(msg = nil)
|
|
74
|
+
super(msg || "Dommy's CSS support requires the 'makiri' gem (its bundled " \
|
|
75
|
+
"lexbor provides the CSS parser). Add `gem \"makiri\"` to use " \
|
|
76
|
+
"stylesheet-aware computed styles.")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
module_function
|
|
81
|
+
|
|
82
|
+
def available?
|
|
83
|
+
return @available unless @available.nil?
|
|
84
|
+
|
|
85
|
+
@available = begin
|
|
86
|
+
require "makiri"
|
|
87
|
+
!!defined?(::Makiri::Lexbor::CSS)
|
|
88
|
+
rescue LoadError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parse a stylesheet into an Array of StyleRule / MediaRule / LayerRule
|
|
94
|
+
# / SupportsRule in source order (other at-rules are ignored). Lexbor's
|
|
95
|
+
# css-syntax-3 error recovery applies; selectors and at-rules it can't
|
|
96
|
+
# parse are surfaced for Dommy to re-validate, not silently dropped.
|
|
97
|
+
def parse(text)
|
|
98
|
+
raise Unavailable unless available?
|
|
99
|
+
|
|
100
|
+
rules = ::Makiri::Lexbor::CSS.parse_stylesheet(text.to_s)
|
|
101
|
+
normalize_rules(rules, collect_namespaces(rules))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_rules(rules, namespaces = {})
|
|
105
|
+
rules.filter_map do |rule|
|
|
106
|
+
case rule[:type]
|
|
107
|
+
when :style
|
|
108
|
+
build_style_rule(normalize_selectors(rule[:selectors], namespaces), rule[:declarations])
|
|
109
|
+
when :bad_style
|
|
110
|
+
# lexbor couldn't parse this selector list (most often a
|
|
111
|
+
# pseudo-element like ::before, which its parser predates).
|
|
112
|
+
# Re-validate the raw prelude with Dommy's Selectors L4 parser:
|
|
113
|
+
# pseudo-element rules survive, genuinely invalid ones drop
|
|
114
|
+
# (normalize_selectors returns []).
|
|
115
|
+
build_style_rule(normalize_selectors([{text: rule[:selector_text]}], namespaces), rule[:declarations])
|
|
116
|
+
when :at_rule
|
|
117
|
+
normalize_at_rule(rule, namespaces)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The stylesheet's @namespace prefix => URI map (with :default for the
|
|
123
|
+
# prefixless default namespace), so `svg|rect` resolves when the sheet
|
|
124
|
+
# declared `@namespace svg url(...)`. @namespace is top-level only.
|
|
125
|
+
def collect_namespaces(rules)
|
|
126
|
+
rules.each_with_object({}) do |rule, namespaces|
|
|
127
|
+
next unless rule[:type] == :at_rule && rule[:name].to_s.casecmp("namespace").zero?
|
|
128
|
+
|
|
129
|
+
prefix, uri = parse_namespace(rule[:prelude])
|
|
130
|
+
namespaces[prefix.nil? ? :default : prefix] = uri if uri
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @namespace prelude: `[prefix] (url(uri) | "uri")`. Returns
|
|
135
|
+
# [prefix_or_nil, uri_or_nil].
|
|
136
|
+
def parse_namespace(prelude)
|
|
137
|
+
rest = prelude.to_s.strip
|
|
138
|
+
prefix = nil
|
|
139
|
+
if (match = rest.match(/\A([A-Za-z_][\w-]*)\s+/))
|
|
140
|
+
prefix = match[1]
|
|
141
|
+
rest = rest[match.end(0)..]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
uri =
|
|
145
|
+
if (match = rest.match(/\Aurl\(\s*(.*?)\s*\)\s*\z/i))
|
|
146
|
+
match[1]
|
|
147
|
+
elsif (match = rest.match(/\A(?:"([^"]*)"|'([^']*)')\s*\z/))
|
|
148
|
+
match[1] || match[2]
|
|
149
|
+
end
|
|
150
|
+
[prefix, uri&.gsub(/\A["']|["']\z/, "")&.strip]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @media / @layer / @supports become grouping rules the cascade
|
|
154
|
+
# understands; every other at-rule (@font-face, @keyframes, @import,
|
|
155
|
+
# @page, ...) is ignored for now (returning nil drops it from the
|
|
156
|
+
# rule stream, but lexbor still parsed it, so nothing crashes).
|
|
157
|
+
def normalize_at_rule(rule, namespaces = {})
|
|
158
|
+
case rule[:name].to_s.downcase
|
|
159
|
+
when "media"
|
|
160
|
+
MediaRule.new(rule[:prelude], normalize_rules(rule[:rules], namespaces))
|
|
161
|
+
when "layer"
|
|
162
|
+
normalize_layer(rule, namespaces)
|
|
163
|
+
when "supports"
|
|
164
|
+
SupportsRule.new(rule[:prelude], normalize_rules(rule[:rules], namespaces))
|
|
165
|
+
when "scope"
|
|
166
|
+
scope_start, scope_end = parse_scope_prelude(rule[:prelude])
|
|
167
|
+
ScopeRule.new(scope_start, scope_end, normalize_rules(rule[:rules], namespaces))
|
|
168
|
+
when "import"
|
|
169
|
+
parse_import(rule[:prelude])
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @layer comes in two shapes (lexbor reports both as an at_rule):
|
|
174
|
+
# block form `@layer name { rules }` — one named/anonymous layer with
|
|
175
|
+
# content (an empty block carries no rules, like a statement);
|
|
176
|
+
# statement form `@layer a, b, c;` — declares several layers' order
|
|
177
|
+
# without content.
|
|
178
|
+
# An empty `rules` means the statement form (or empty block): it only
|
|
179
|
+
# fixes layer order. A nil name (empty prelude) is an anonymous layer.
|
|
180
|
+
def normalize_layer(rule, namespaces)
|
|
181
|
+
names = split_layer_names(rule[:prelude])
|
|
182
|
+
rules = rule[:rules] || []
|
|
183
|
+
if rules.empty?
|
|
184
|
+
LayerStatement.new(names.empty? ? [nil] : names)
|
|
185
|
+
else
|
|
186
|
+
LayerRule.new(names.first, normalize_rules(rules, namespaces))
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The comma-separated layer names of an @layer prelude ("" → [], so an
|
|
191
|
+
# anonymous layer yields no names).
|
|
192
|
+
def split_layer_names(prelude)
|
|
193
|
+
prelude.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @scope prelude: `(<start>) [to (<end>)]`. Returns [start_or_nil,
|
|
197
|
+
# end_or_nil] as selector-list texts (an empty prelude — bare @scope —
|
|
198
|
+
# yields [nil, nil]).
|
|
199
|
+
def parse_scope_prelude(prelude)
|
|
200
|
+
text = prelude.to_s.strip
|
|
201
|
+
return [nil, nil] if text.empty?
|
|
202
|
+
|
|
203
|
+
match = text.match(/\A\(\s*(.*?)\s*\)\s*(?:to\s*\(\s*(.*?)\s*\)\s*)?\z/m)
|
|
204
|
+
return [nil, nil] unless match
|
|
205
|
+
|
|
206
|
+
[presence(match[1]), presence(match[2])]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def presence(string)
|
|
210
|
+
string.nil? || string.empty? ? nil : string
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @import prelude: `url(x)` / `"x"` / `'x'`, optionally followed by a
|
|
214
|
+
# media query. Returns nil (the rule is dropped) when no URL is found.
|
|
215
|
+
def parse_import(prelude)
|
|
216
|
+
prelude = prelude.to_s.strip
|
|
217
|
+
if (match = prelude.match(/\Aurl\(\s*(.*?)\s*\)/i))
|
|
218
|
+
url = match[1]
|
|
219
|
+
elsif (match = prelude.match(/\A(?:"([^"]*)"|'([^']*)')/))
|
|
220
|
+
url = match[1] || match[2]
|
|
221
|
+
else
|
|
222
|
+
return nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
url = url.to_s.gsub(/\A["']|["']\z/, "").strip
|
|
226
|
+
return nil if url.empty?
|
|
227
|
+
|
|
228
|
+
ImportRule.new(url, prelude[match.end(0)..].to_s.strip)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def build_style_rule(selectors, declarations)
|
|
232
|
+
return nil if selectors.empty?
|
|
233
|
+
|
|
234
|
+
StyleRule.new(selectors, declarations.map { |d| Declaration.new(d[:name], d[:value], d[:important]) })
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def normalize_selectors(selectors, namespaces = {})
|
|
238
|
+
selectors.map do |selector|
|
|
239
|
+
ast = Internal::SelectorParser.parse!(selector[:text], namespaces: namespaces)
|
|
240
|
+
Selector.new(selector[:text], ast, ast.specificity.to_a)
|
|
241
|
+
end
|
|
242
|
+
rescue DOMException::SyntaxError
|
|
243
|
+
[]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Parse a bare declaration block (no selector / braces) into
|
|
247
|
+
# Declarations. Used by the CSSOM RuleStyleDeclaration to read a rule's
|
|
248
|
+
# `style`; the cascade reaches declarations through parse/normalize_rules.
|
|
249
|
+
def parse_declarations(text)
|
|
250
|
+
text.split(";").filter_map do |chunk|
|
|
251
|
+
name, value = chunk.split(":", 2)
|
|
252
|
+
next unless name && value
|
|
253
|
+
|
|
254
|
+
name = name.strip.downcase
|
|
255
|
+
value = value.strip
|
|
256
|
+
next if name.empty? || value.empty?
|
|
257
|
+
|
|
258
|
+
important = !value.sub!(/\s*!important\s*\z/i, "").nil?
|
|
259
|
+
Declaration.new(name, value.strip, important)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|