p_css 0.2.0.beta1-x86_64-linux
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 +7 -0
- data/Cargo.lock +282 -0
- data/Cargo.toml +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +357 -0
- data/ext/css_native/Cargo.toml +12 -0
- data/ext/css_native/extconf.rb +4 -0
- data/ext/css_native/src/lib.rs +117 -0
- data/ext/css_native/src/matcher.rs +356 -0
- data/ext/css_native/src/selectors.rs +411 -0
- data/ext/css_native/src/snapshot.rs +370 -0
- data/ext/css_native/src/state.rs +174 -0
- data/ext/css_native/src/tokenizer.rs +596 -0
- data/lib/css/3.3/css_native.so +0 -0
- data/lib/css/3.4/css_native.so +0 -0
- data/lib/css/4.0/css_native.so +0 -0
- data/lib/css/cascade.rb +277 -0
- data/lib/css/code_points.rb +59 -0
- data/lib/css/escape.rb +82 -0
- data/lib/css/media_queries/context.rb +60 -0
- data/lib/css/media_queries/evaluator.rb +157 -0
- data/lib/css/media_queries/nodes.rb +41 -0
- data/lib/css/media_queries/parser.rb +374 -0
- data/lib/css/media_queries.rb +9 -0
- data/lib/css/native.rb +179 -0
- data/lib/css/nesting.rb +229 -0
- data/lib/css/nodes.rb +42 -0
- data/lib/css/parser.rb +429 -0
- data/lib/css/selectors/anb_parser.rb +174 -0
- data/lib/css/selectors/matcher.rb +545 -0
- data/lib/css/selectors/nodes.rb +61 -0
- data/lib/css/selectors/parser.rb +395 -0
- data/lib/css/selectors/serializer.rb +102 -0
- data/lib/css/selectors/specificity.rb +81 -0
- data/lib/css/selectors.rb +11 -0
- data/lib/css/serializer.rb +167 -0
- data/lib/css/token.rb +107 -0
- data/lib/css/token_cursor.rb +49 -0
- data/lib/css/tokenizer.rb +447 -0
- data/lib/css/urange.rb +45 -0
- data/lib/css/version.rb +3 -0
- data/lib/css.rb +73 -0
- data/lib/p_css.rb +1 -0
- data/sig/css/cascade.rbs +22 -0
- data/sig/css/media_queries.rbs +107 -0
- data/sig/css/nodes.rbs +76 -0
- data/sig/css/selectors.rbs +164 -0
- data/sig/css/token.rbs +33 -0
- data/sig/css.rbs +99 -0
- metadata +113 -0
data/lib/css/cascade.rb
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
# Resolves the cascade for a Stylesheet against a single element. Returns
|
|
3
|
+
# `Hash<String, Declaration>` keyed by property name with the winning
|
|
4
|
+
# declaration after applying:
|
|
5
|
+
#
|
|
6
|
+
# - `@media` filtering (against a `MediaQueries::Context`)
|
|
7
|
+
# - selector matching (`Selectors::Matcher`)
|
|
8
|
+
# - cascade sort: `!important` > origin / inline > specificity > source order
|
|
9
|
+
#
|
|
10
|
+
# The Stylesheet is compiled once on construction (selectors are
|
|
11
|
+
# pre-parsed, specificities pre-computed, `@media` chains are
|
|
12
|
+
# evaluated against the supplied context up-front, and rules are
|
|
13
|
+
# indexed by the rightmost compound's strongest anchor — id > class >
|
|
14
|
+
# tag > universal). `resolve(element)` then visits only the rules whose
|
|
15
|
+
# anchor could match the element.
|
|
16
|
+
#
|
|
17
|
+
# Cascade layers, `@scope` proximity, and Shadow DOM encapsulation are
|
|
18
|
+
# not modeled — `@layer`, `@supports`, `@container`, `@scope`, and
|
|
19
|
+
# `@starting-style` blocks are descended into unconditionally.
|
|
20
|
+
class Cascade
|
|
21
|
+
Match = Data.define(:declaration, :specificity, :inline, :order)
|
|
22
|
+
|
|
23
|
+
RuleEntry = Data.define(:selector_pairs, :declarations)
|
|
24
|
+
|
|
25
|
+
TRANSPARENT_AT_RULES = %w[supports layer scope starting-style container].freeze
|
|
26
|
+
|
|
27
|
+
def initialize(stylesheet, context: MediaQueries::Context.default)
|
|
28
|
+
@context = context
|
|
29
|
+
@entries = compile(stylesheet)
|
|
30
|
+
@index = build_index(@entries)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns Hash<String, Declaration> of winning declarations.
|
|
34
|
+
#
|
|
35
|
+
# `state:` opts into stateful-pseudo matching — see
|
|
36
|
+
# `Selectors::Matcher#matches?` for the shape. Defaults to the
|
|
37
|
+
# stateless behavior (`:hover`, `:focus`, etc. never match).
|
|
38
|
+
#
|
|
39
|
+
# `cache:` lets callers share a per-element context cache across many
|
|
40
|
+
# resolves. The default `{}` is local to one call. Pass a persistent
|
|
41
|
+
# Hash when the DOM is stable across many resolves — Context (tag, id,
|
|
42
|
+
# classes) computation runs once per element instead of per resolve.
|
|
43
|
+
# The caller is responsible for clearing/replacing the cache on DOM
|
|
44
|
+
# mutation.
|
|
45
|
+
def resolve(element, inline_style: nil, state: nil, cache: nil)
|
|
46
|
+
cache ||= {}
|
|
47
|
+
candidates = collect_candidate_indexes(element, cache)
|
|
48
|
+
order = 0
|
|
49
|
+
matches = []
|
|
50
|
+
|
|
51
|
+
candidates.each do |idx|
|
|
52
|
+
entry = @entries[idx]
|
|
53
|
+
spec = best_matching_specificity(element, entry.selector_pairs, cache, state)
|
|
54
|
+
|
|
55
|
+
next if spec.nil?
|
|
56
|
+
|
|
57
|
+
entry.declarations.each do |decl|
|
|
58
|
+
order += 1
|
|
59
|
+
matches << Match.new(declaration: decl, specificity: spec, inline: false, order: order)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if inline_style
|
|
64
|
+
inline_declarations(inline_style).each do |decl|
|
|
65
|
+
order += 1
|
|
66
|
+
matches << Match.new(declaration: decl, specificity: Selectors::Specificity::ZERO, inline: true, order: order)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
pick_winners(matches)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Compile
|
|
76
|
+
# ----------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def compile(stylesheet)
|
|
79
|
+
out = []
|
|
80
|
+
walk(stylesheet.rules, [], out)
|
|
81
|
+
out
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Filters the stylesheet down to rules whose `@media` chain (if any)
|
|
85
|
+
# matches the cascade's context, pre-parsing every selector list and
|
|
86
|
+
# caching its specificity per selector.
|
|
87
|
+
def walk(rules, media_chain, out)
|
|
88
|
+
rules.each do |rule|
|
|
89
|
+
case rule
|
|
90
|
+
when Nodes::QualifiedRule
|
|
91
|
+
register_qualified_rule(rule, media_chain, out)
|
|
92
|
+
when Nodes::AtRule
|
|
93
|
+
dispatch_at_rule(rule, media_chain, out)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def register_qualified_rule(rule, media_chain, out)
|
|
99
|
+
return unless media_chain.all? { MediaQueries::Evaluator.evaluate(_1, @context) }
|
|
100
|
+
|
|
101
|
+
sl = Selectors::Parser.parse_selector_list(rule.prelude)
|
|
102
|
+
pairs = sl.selectors.map { [_1, Selectors::SpecificityCalculator.calculate(_1)] }
|
|
103
|
+
decls = rule.block.items.select { _1.is_a?(Nodes::Declaration) }
|
|
104
|
+
|
|
105
|
+
out << RuleEntry.new(selector_pairs: pairs, declarations: decls)
|
|
106
|
+
rescue ParseError
|
|
107
|
+
# Browsers drop a rule whose prelude doesn't parse as a selector
|
|
108
|
+
# list rather than poisoning the whole stylesheet; do the same.
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def dispatch_at_rule(rule, media_chain, out)
|
|
112
|
+
return unless rule.block
|
|
113
|
+
|
|
114
|
+
case rule.name.downcase
|
|
115
|
+
when 'media'
|
|
116
|
+
ql = MediaQueries::Parser.parse(rule.prelude)
|
|
117
|
+
walk(rule.block.items, [*media_chain, ql], out)
|
|
118
|
+
when *TRANSPARENT_AT_RULES
|
|
119
|
+
walk(rule.block.items, media_chain, out)
|
|
120
|
+
end
|
|
121
|
+
rescue ParseError
|
|
122
|
+
# Bad media prelude → skip this @media block; rules outside it
|
|
123
|
+
# remain unaffected.
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Index
|
|
127
|
+
# ----------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
Index = Data.define(:by_id, :by_class, :by_tag, :universal)
|
|
130
|
+
AnchorKey = Data.define(:kind, :name)
|
|
131
|
+
|
|
132
|
+
def build_index(entries)
|
|
133
|
+
by_id = {}
|
|
134
|
+
by_class = {}
|
|
135
|
+
by_tag = {}
|
|
136
|
+
universal = []
|
|
137
|
+
|
|
138
|
+
entries.each_with_index do |entry, idx|
|
|
139
|
+
seen = Set.new
|
|
140
|
+
|
|
141
|
+
entry.selector_pairs.each do |sel, _spec|
|
|
142
|
+
key = anchor_key(sel)
|
|
143
|
+
|
|
144
|
+
next if seen.include?(key)
|
|
145
|
+
|
|
146
|
+
seen << key
|
|
147
|
+
|
|
148
|
+
case key.kind
|
|
149
|
+
when :id then (by_id[key.name] ||= []) << idx
|
|
150
|
+
when :class then (by_class[key.name] ||= []) << idx
|
|
151
|
+
when :tag then (by_tag[key.name] ||= []) << idx
|
|
152
|
+
when :universal then universal << idx
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Index.new(
|
|
158
|
+
by_id: by_id.freeze,
|
|
159
|
+
by_class: by_class.freeze,
|
|
160
|
+
by_tag: by_tag.freeze,
|
|
161
|
+
universal: universal.freeze
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Picks the strongest anchor in the rightmost compound: id > class >
|
|
166
|
+
# tag > universal. Compounds whose only simple selectors are pseudos
|
|
167
|
+
# (e.g. `:hover`) or attribute matchers fall through to universal —
|
|
168
|
+
# they will be tested against every element, but real-world
|
|
169
|
+
# stylesheets rarely have many such rules.
|
|
170
|
+
def anchor_key(complex_selector)
|
|
171
|
+
class_name = nil
|
|
172
|
+
tag_name = nil
|
|
173
|
+
|
|
174
|
+
complex_selector.compounds.last.components.each do |c|
|
|
175
|
+
case c
|
|
176
|
+
when Selectors::IdSelector then return AnchorKey.new(kind: :id, name: c.name)
|
|
177
|
+
when Selectors::ClassSelector then class_name ||= c.name
|
|
178
|
+
when Selectors::TypeSelector then tag_name ||= c.name.downcase
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
return AnchorKey.new(kind: :class, name: class_name) if class_name
|
|
183
|
+
return AnchorKey.new(kind: :tag, name: tag_name) if tag_name
|
|
184
|
+
|
|
185
|
+
AnchorKey.new(kind: :universal, name: nil)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Resolve helpers
|
|
189
|
+
# ----------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
# Buckets are appended in source-order at compile time, so each is
|
|
192
|
+
# already sorted ascending and unique. Concatenating them and using
|
|
193
|
+
# `sort! + uniq!` is cheaper than going through a `Set`: integers
|
|
194
|
+
# sort in C, and `uniq!` on a sorted array only removes adjacent
|
|
195
|
+
# duplicates.
|
|
196
|
+
def collect_candidate_indexes(element, cache)
|
|
197
|
+
out = []
|
|
198
|
+
|
|
199
|
+
el_id = Selectors::Matcher.id_of(element, cache)
|
|
200
|
+
bucket = @index.by_id[el_id] if el_id
|
|
201
|
+
|
|
202
|
+
out.concat(bucket) if bucket
|
|
203
|
+
|
|
204
|
+
Selectors::Matcher.classes_of(element, cache).each do |cls|
|
|
205
|
+
bucket = @index.by_class[cls]
|
|
206
|
+
out.concat(bucket) if bucket
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
bucket = @index.by_tag[Selectors::Matcher.tag_of(element, cache)]
|
|
210
|
+
out.concat(bucket) if bucket
|
|
211
|
+
|
|
212
|
+
out.concat(@index.universal)
|
|
213
|
+
out.sort!
|
|
214
|
+
out.uniq!
|
|
215
|
+
out
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def best_matching_specificity(element, selector_pairs, cache, state)
|
|
219
|
+
best = nil
|
|
220
|
+
|
|
221
|
+
selector_pairs.each do |sel, spec|
|
|
222
|
+
next unless Selectors::Matcher.matches?(element, sel, cache: cache, state: state)
|
|
223
|
+
|
|
224
|
+
best = spec if best.nil? || spec > best
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
best
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Single-pass running max per property name. Cheaper than group_by +
|
|
231
|
+
# max_by, and avoids allocating a fresh comparison key per
|
|
232
|
+
# declaration.
|
|
233
|
+
def pick_winners(matches)
|
|
234
|
+
best = {}
|
|
235
|
+
|
|
236
|
+
matches.each do |m|
|
|
237
|
+
name = m.declaration.name
|
|
238
|
+
incumbent = best[name]
|
|
239
|
+
|
|
240
|
+
best[name] = m if incumbent.nil? || better?(m, incumbent)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
best.transform_values(&:declaration)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# `m` outranks `incumbent` when its priority class is higher, or — at
|
|
247
|
+
# the same priority class — its specificity is greater, or — at equal
|
|
248
|
+
# specificity — it appeared later in source order.
|
|
249
|
+
def better?(m, incumbent)
|
|
250
|
+
a = priority(m)
|
|
251
|
+
b = priority(incumbent)
|
|
252
|
+
return a > b unless a == b
|
|
253
|
+
|
|
254
|
+
cmp = m.specificity <=> incumbent.specificity
|
|
255
|
+
return cmp.positive? unless cmp.zero?
|
|
256
|
+
|
|
257
|
+
m.order > incumbent.order
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# !important and inline style each bump the rule into a higher
|
|
261
|
+
# priority class. Encoded so that `priority(a) <=> priority(b)`
|
|
262
|
+
# captures the cascade's origin/importance ordering.
|
|
263
|
+
def priority(m)
|
|
264
|
+
(m.declaration.important ? 2 : 0) + (m.inline ? 1 : 0)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def inline_declarations(style)
|
|
268
|
+
case style
|
|
269
|
+
when String then CSS.parse_block_contents(style).items.select { _1.is_a?(Nodes::Declaration) }
|
|
270
|
+
when Nodes::Block then style.items.select { _1.is_a?(Nodes::Declaration) }
|
|
271
|
+
when Array then style.select { _1.is_a?(Nodes::Declaration) }
|
|
272
|
+
else
|
|
273
|
+
raise ArgumentError, "cannot derive inline declarations from #{style.class}"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
# Character class predicates from CSS Syntax §4.2 Definitions, plus the
|
|
3
|
+
# U+FFFD replacement character used both during tokenization and
|
|
4
|
+
# serialization.
|
|
5
|
+
#
|
|
6
|
+
# ASCII bytes are looked up in a precomputed boolean table (one Array
|
|
7
|
+
# access + one branch); non-ASCII code points (>= 0x80) are always
|
|
8
|
+
# ident-cp / ident-start per spec, so the helpers fall back to a single
|
|
9
|
+
# `c.ord >= 0x80` check. Avoids the chain of `String#<=>` calls a
|
|
10
|
+
# range-style predicate would dispatch.
|
|
11
|
+
module CodePoints
|
|
12
|
+
REPLACEMENT = "�".freeze
|
|
13
|
+
|
|
14
|
+
def self.build_table(*ranges_or_ints)
|
|
15
|
+
Array.new(128, false).tap {|a|
|
|
16
|
+
ranges_or_ints.each {|r|
|
|
17
|
+
if r.is_a?(Range) then r.each { a[_1] = true }
|
|
18
|
+
else a[r] = true
|
|
19
|
+
end
|
|
20
|
+
}
|
|
21
|
+
}.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
DIGIT_TABLE = build_table(0x30..0x39)
|
|
25
|
+
HEX_DIGIT_TABLE = build_table(0x30..0x39, 0x41..0x46, 0x61..0x66)
|
|
26
|
+
IDENT_START_TABLE = build_table(0x41..0x5A, 0x61..0x7A, 0x5F)
|
|
27
|
+
IDENT_CP_TABLE = build_table(0x30..0x39, 0x41..0x5A, 0x61..0x7A, 0x5F, 0x2D)
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def digit?(c)
|
|
32
|
+
return false if c.nil?
|
|
33
|
+
|
|
34
|
+
o = c.ord
|
|
35
|
+
o < 128 && DIGIT_TABLE[o]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def hex_digit?(c)
|
|
39
|
+
return false if c.nil?
|
|
40
|
+
|
|
41
|
+
o = c.ord
|
|
42
|
+
o < 128 && HEX_DIGIT_TABLE[o]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ident_start_code_point?(c)
|
|
46
|
+
return false if c.nil?
|
|
47
|
+
|
|
48
|
+
o = c.ord
|
|
49
|
+
o >= 128 || IDENT_START_TABLE[o]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ident_code_point?(c)
|
|
53
|
+
return false if c.nil?
|
|
54
|
+
|
|
55
|
+
o = c.ord
|
|
56
|
+
o >= 128 || IDENT_CP_TABLE[o]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/css/escape.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
# CSS Syntax §9.3 escape primitives — `serialize an identifier`,
|
|
3
|
+
# `serialize a name`, and `serialize a string`. Reused by both the main
|
|
4
|
+
# serializer and the selector serializer.
|
|
5
|
+
module Escape
|
|
6
|
+
extend self
|
|
7
|
+
extend CodePoints
|
|
8
|
+
|
|
9
|
+
# §9.3.1.
|
|
10
|
+
def ident(ident)
|
|
11
|
+
buf = +''
|
|
12
|
+
lone_dash = ident.length == 1 && ident == '-'
|
|
13
|
+
hyphen0 = ident.start_with?('-')
|
|
14
|
+
|
|
15
|
+
ident.each_char.with_index {|c, i|
|
|
16
|
+
cp = c.ord
|
|
17
|
+
|
|
18
|
+
if (esc = control_or_nul(cp))
|
|
19
|
+
buf << esc
|
|
20
|
+
elsif i.zero? && lone_dash
|
|
21
|
+
buf << '\\-'
|
|
22
|
+
elsif (i.zero? && digit?(c)) || (i == 1 && hyphen0 && digit?(c))
|
|
23
|
+
buf << format('\\%x ', cp)
|
|
24
|
+
elsif ident_code_point?(c)
|
|
25
|
+
buf << c
|
|
26
|
+
else
|
|
27
|
+
buf << "\\#{c}"
|
|
28
|
+
end
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
buf
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# §9.3 "Serialize a name". Like an ident but allows leading digits
|
|
35
|
+
# and hyphens — used for unrestricted hash tokens.
|
|
36
|
+
def name(name)
|
|
37
|
+
buf = +''
|
|
38
|
+
|
|
39
|
+
name.each_char {|c|
|
|
40
|
+
cp = c.ord
|
|
41
|
+
|
|
42
|
+
if (esc = control_or_nul(cp))
|
|
43
|
+
buf << esc
|
|
44
|
+
elsif ident_code_point?(c)
|
|
45
|
+
buf << c
|
|
46
|
+
else
|
|
47
|
+
buf << "\\#{c}"
|
|
48
|
+
end
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
buf
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# §9.3.2. Always uses double quotes.
|
|
55
|
+
def string(s)
|
|
56
|
+
buf = +'"'
|
|
57
|
+
|
|
58
|
+
s.each_char {|c|
|
|
59
|
+
cp = c.ord
|
|
60
|
+
|
|
61
|
+
if (esc = control_or_nul(cp))
|
|
62
|
+
buf << esc
|
|
63
|
+
elsif c == '"' || c == '\\'
|
|
64
|
+
buf << "\\#{c}"
|
|
65
|
+
else
|
|
66
|
+
buf << c
|
|
67
|
+
end
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
buf << '"'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# NUL collapses to U+FFFD; controls (0x01..0x1F, 0x7F) get hex
|
|
74
|
+
# escapes. Returns nil for non-control code points.
|
|
75
|
+
def control_or_nul(cp)
|
|
76
|
+
return CodePoints::REPLACEMENT if cp.zero?
|
|
77
|
+
return format('\\%x ', cp) if (0x01..0x1F).cover?(cp) || cp == 0x7F
|
|
78
|
+
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
module MediaQueries
|
|
3
|
+
# Holds the user-agent context against which a MediaQueryList is
|
|
4
|
+
# evaluated. Stored as a feature → value Hash; values follow Media
|
|
5
|
+
# Queries Level 4 conventions:
|
|
6
|
+
#
|
|
7
|
+
# - lengths in CSS pixels (Numeric)
|
|
8
|
+
# - resolution in dots-per-CSS-px (`dppx`, Numeric)
|
|
9
|
+
# - identifier-valued features as Strings ("landscape", "dark", ...)
|
|
10
|
+
# - boolean-style features as 1 / 0 or true / false
|
|
11
|
+
#
|
|
12
|
+
# `Context.default(**overrides)` returns a sensible desktop preset.
|
|
13
|
+
Context = Data.define(:features) do
|
|
14
|
+
def [](name) = features[name.to_s]
|
|
15
|
+
|
|
16
|
+
def media_type = self['media-type']
|
|
17
|
+
|
|
18
|
+
def with(**overrides)
|
|
19
|
+
Context.new(features: features.merge(overrides.transform_keys(&:to_s)))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.default(**overrides)
|
|
23
|
+
new(features: DEFAULTS.merge(overrides.transform_keys(&:to_s)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
DEFAULTS = {
|
|
27
|
+
'media-type' => 'screen',
|
|
28
|
+
|
|
29
|
+
'width' => 1024,
|
|
30
|
+
'height' => 768,
|
|
31
|
+
'device-width' => 1024,
|
|
32
|
+
'device-height' => 768,
|
|
33
|
+
'aspect-ratio' => 1024.0 / 768,
|
|
34
|
+
'device-aspect-ratio' => 1024.0 / 768,
|
|
35
|
+
'orientation' => 'landscape',
|
|
36
|
+
|
|
37
|
+
'resolution' => 1, # dppx
|
|
38
|
+
'color' => 8,
|
|
39
|
+
'color-gamut' => 'srgb',
|
|
40
|
+
'color-index' => 0,
|
|
41
|
+
'monochrome' => 0,
|
|
42
|
+
'grid' => 0,
|
|
43
|
+
'scan' => 'progressive',
|
|
44
|
+
'update' => 'fast',
|
|
45
|
+
'overflow-block' => 'scroll',
|
|
46
|
+
'overflow-inline' => 'scroll',
|
|
47
|
+
|
|
48
|
+
'pointer' => 'fine',
|
|
49
|
+
'hover' => 'hover',
|
|
50
|
+
'any-pointer' => 'fine',
|
|
51
|
+
'any-hover' => 'hover',
|
|
52
|
+
|
|
53
|
+
'prefers-color-scheme' => 'light',
|
|
54
|
+
'prefers-reduced-motion' => 'no-preference',
|
|
55
|
+
'prefers-contrast' => 'no-preference',
|
|
56
|
+
'forced-colors' => 'none'
|
|
57
|
+
}.freeze
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
module MediaQueries
|
|
3
|
+
# Evaluates a MediaQueryList against a Context, returning true if at
|
|
4
|
+
# least one media-query in the list matches.
|
|
5
|
+
module Evaluator
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
# Length conversion to CSS px assumes 1em = 1rem = 16px. Per Media
|
|
9
|
+
# Queries Level 4 §1.3 this is the conventional fallback when the
|
|
10
|
+
# font-size of the root is unknown.
|
|
11
|
+
EM_PX = 16.0
|
|
12
|
+
|
|
13
|
+
LENGTH_UNITS_PX = {
|
|
14
|
+
'px' => 1.0,
|
|
15
|
+
'em' => EM_PX,
|
|
16
|
+
'rem' => EM_PX,
|
|
17
|
+
'ex' => EM_PX * 0.5,
|
|
18
|
+
'ch' => EM_PX * 0.5,
|
|
19
|
+
'pt' => 96.0 / 72,
|
|
20
|
+
'pc' => 16.0,
|
|
21
|
+
'in' => 96.0,
|
|
22
|
+
'cm' => 96.0 / 2.54,
|
|
23
|
+
'mm' => 96.0 / 25.4,
|
|
24
|
+
'q' => 96.0 / 25.4 / 4
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
RESOLUTION_UNITS_DPPX = {
|
|
28
|
+
'dppx' => 1.0,
|
|
29
|
+
'x' => 1.0,
|
|
30
|
+
'dpi' => 1.0 / 96,
|
|
31
|
+
'dpcm' => 2.54 / 96
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
RESOLUTION_FEATURES = %w[resolution].freeze
|
|
35
|
+
|
|
36
|
+
INVERSE_OP = {lt: :gt, le: :ge, gt: :lt, ge: :le, eq: :eq}.freeze
|
|
37
|
+
|
|
38
|
+
PREFIX_OP = {min: :ge, max: :le}.freeze
|
|
39
|
+
|
|
40
|
+
def evaluate(query_list, context)
|
|
41
|
+
query_list.queries.any? { evaluate_query(_1, context) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def evaluate_query(query, context)
|
|
47
|
+
result = evaluate_query_main(query, context)
|
|
48
|
+
query.modifier == :not ? !result : result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def evaluate_query_main(query, context)
|
|
52
|
+
if query.type
|
|
53
|
+
return false unless type_matches?(query.type, context.media_type)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return true if query.condition.nil?
|
|
57
|
+
|
|
58
|
+
evaluate_condition(query.condition, context)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def type_matches?(type, ctx_type)
|
|
62
|
+
type == 'all' || type == ctx_type.to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def evaluate_condition(node, context)
|
|
66
|
+
case node
|
|
67
|
+
when MediaNot then !evaluate_condition(node.operand, context)
|
|
68
|
+
when MediaAnd then node.operands.all? { evaluate_condition(_1, context) }
|
|
69
|
+
when MediaOr then node.operands.any? { evaluate_condition(_1, context) }
|
|
70
|
+
when MediaFeature then evaluate_feature(node, context)
|
|
71
|
+
when GeneralEnclosed then false
|
|
72
|
+
else false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def evaluate_feature(feature, context)
|
|
77
|
+
ctx_name, prefix = strip_prefix(feature.name)
|
|
78
|
+
ctx_value = context[ctx_name]
|
|
79
|
+
|
|
80
|
+
return evaluate_boolean(ctx_value) if feature.op.nil?
|
|
81
|
+
|
|
82
|
+
compare(prefix, feature.op, ctx_value, feature.value, ctx_name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def evaluate_boolean(ctx_value)
|
|
86
|
+
return false if ctx_value.nil?
|
|
87
|
+
return false if ctx_value == 0 || ctx_value == false || ctx_value == '' || ctx_value == 'none'
|
|
88
|
+
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def strip_prefix(name)
|
|
93
|
+
case name
|
|
94
|
+
when /\Amin-(.+)/ then [$1, :min]
|
|
95
|
+
when /\Amax-(.+)/ then [$1, :max]
|
|
96
|
+
else [name, nil]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def compare(prefix, op, ctx_value, feature_value, ctx_name)
|
|
101
|
+
op = PREFIX_OP[prefix] || op
|
|
102
|
+
|
|
103
|
+
return string_op_apply(op, ctx_value.to_s, feature_value.value.to_s) if ident_compare?(feature_value)
|
|
104
|
+
|
|
105
|
+
a = numeric_for(ctx_name, ctx_value)
|
|
106
|
+
b = numeric_for(ctx_name, feature_value)
|
|
107
|
+
|
|
108
|
+
return false if a.nil? || b.nil?
|
|
109
|
+
|
|
110
|
+
numeric_op_apply(op, a, b)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ident_compare?(feature_value)
|
|
114
|
+
feature_value.is_a?(Token) && feature_value.type == :ident
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def string_op_apply(op, a, b)
|
|
118
|
+
op == :eq && a.casecmp?(b)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def numeric_op_apply(op, a, b)
|
|
122
|
+
case op
|
|
123
|
+
when :eq then a == b
|
|
124
|
+
when :lt then a < b
|
|
125
|
+
when :le then a <= b
|
|
126
|
+
when :gt then a > b
|
|
127
|
+
when :ge then a >= b
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Converts both context value and feature value to a comparable
|
|
132
|
+
# numeric in the canonical unit for the named feature.
|
|
133
|
+
def numeric_for(ctx_name, value)
|
|
134
|
+
case value
|
|
135
|
+
when Numeric then value.to_f
|
|
136
|
+
when Ratio then value.to_f
|
|
137
|
+
when Token
|
|
138
|
+
case value.type
|
|
139
|
+
when :number then value.value.to_f
|
|
140
|
+
when :percentage then value.value.to_f / 100
|
|
141
|
+
when :dimension then dimension_to_canonical(value, ctx_name)
|
|
142
|
+
else nil
|
|
143
|
+
end
|
|
144
|
+
else nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def dimension_to_canonical(token, ctx_name)
|
|
149
|
+
unit = token.unit.downcase
|
|
150
|
+
table = RESOLUTION_FEATURES.include?(ctx_name) ? RESOLUTION_UNITS_DPPX : LENGTH_UNITS_PX
|
|
151
|
+
|
|
152
|
+
factor = table[unit]
|
|
153
|
+
factor && token.value.to_f * factor
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module CSS
|
|
2
|
+
module MediaQueries
|
|
3
|
+
# Marker module for media-query AST nodes; lets the main serializer
|
|
4
|
+
# dispatch into MediaQueries::Serializer when it ever exists.
|
|
5
|
+
module Node; end
|
|
6
|
+
|
|
7
|
+
MediaQueryList = Data.define(:queries) do
|
|
8
|
+
include Node
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# `modifier` is `nil`, `:not`, or `:only`.
|
|
12
|
+
# `type` is `nil` or a downcased string ('screen', 'print', 'all', ...).
|
|
13
|
+
# `condition` is `nil` or a media-condition node.
|
|
14
|
+
MediaQuery = Data.define(:modifier, :type, :condition) do
|
|
15
|
+
include Node
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
MediaNot = Data.define(:operand) { include Node }
|
|
19
|
+
MediaAnd = Data.define(:operands) { include Node }
|
|
20
|
+
MediaOr = Data.define(:operands) { include Node }
|
|
21
|
+
|
|
22
|
+
# `op` is `nil` (boolean form, e.g. `(color)`), `:eq` (plain form,
|
|
23
|
+
# `(min-width: 600px)`, or range `=`), `:lt`, `:le`, `:gt`, or `:ge`.
|
|
24
|
+
# `value` is `nil` (boolean), a Token, or a Ratio.
|
|
25
|
+
MediaFeature = Data.define(:name, :op, :value) do
|
|
26
|
+
include Node
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Catch-all for `(...)` content the parser couldn't recognize as a
|
|
30
|
+
# feature or condition. Preserved so downstream tools can still see it.
|
|
31
|
+
GeneralEnclosed = Data.define(:tokens) do
|
|
32
|
+
include Node
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Numeric ratio used in `aspect-ratio` / `device-aspect-ratio` features.
|
|
36
|
+
Ratio = Data.define(:numerator, :denominator) do
|
|
37
|
+
include Node
|
|
38
|
+
def to_f = numerator.to_f / denominator
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|