p_css 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +302 -0
- data/lib/css/cascade.rb +168 -0
- data/lib/css/code_points.rb +36 -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/nesting.rb +229 -0
- data/lib/css/nodes.rb +42 -0
- data/lib/css/parser.rb +430 -0
- data/lib/css/selectors/anb_parser.rb +174 -0
- data/lib/css/selectors/matcher.rb +449 -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 +78 -0
- data/lib/css/token_cursor.rb +49 -0
- data/lib/css/tokenizer.rb +441 -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
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bc053cb635310340632520bdf6ef0771e1003b5d2f8582434078667cc9aeb7b4
|
|
4
|
+
data.tar.gz: b5e3c26f959dfb92e86126aab069b293796e762fb5388baef6be7c98b7b7499e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6803cd828b9d6b2ffeaf7c3c82e75345e7b1165dff0e93e4390ebf9eb4ea7a74bcd45453582562f44bcdb45cc614389c41e60bfc94ba2865421c1cc2ea9399b6
|
|
7
|
+
data.tar.gz: e6acedb22e9a43e81014dfff4d65378d88eda70f515936fd9f0962778e2c3aa4786cf608c8713fc0d3985e10dfb6919d77fcfe61cdd85b1263603b290fcbadb2
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keita Urashima
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# p CSS
|
|
2
|
+
|
|
3
|
+
A CSS toolkit for Ruby — tokenizer, parser, serializer, selector matcher, and
|
|
4
|
+
cascade resolver. Targets CSS Syntax Level 4 (with nesting), Selectors Level 4,
|
|
5
|
+
and Media Queries Level 4.
|
|
6
|
+
|
|
7
|
+
The name reads as **p CSS** — Ruby's `p` method (puts-inspect) applied to CSS.
|
|
8
|
+
Installed under the gem name `p_css`; the top-level module is `CSS`.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require 'p_css'
|
|
12
|
+
|
|
13
|
+
CSS.parse_stylesheet('.foo { color: red }')
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why this exists
|
|
17
|
+
|
|
18
|
+
The Ruby ecosystem already has a few CSS parsers (crass, sass-rb's grammar,
|
|
19
|
+
nokogiri's selector-to-XPath compiler), but each stops at a different layer
|
|
20
|
+
and none of them currently:
|
|
21
|
+
|
|
22
|
+
- parse modern CSS nesting (`& .child { ... }`),
|
|
23
|
+
- expose a Selectors Level 4 AST you can inspect,
|
|
24
|
+
- match selectors against a DOM in pure Ruby,
|
|
25
|
+
- resolve the cascade so `display: none` in a `<style>` tag actually
|
|
26
|
+
influences a visibility judgement.
|
|
27
|
+
|
|
28
|
+
p CSS fills that gap. The first concrete user is
|
|
29
|
+
[capybara-simulated](https://github.com/ursm/capybara-simulated) (a
|
|
30
|
+
Nokogiri + QuickJS Capybara driver that needs to know whether an element
|
|
31
|
+
is hidden without a real browser), but the gem is intentionally general —
|
|
32
|
+
no DOM library is hardwired in.
|
|
33
|
+
|
|
34
|
+
## What's in the box
|
|
35
|
+
|
|
36
|
+
| Layer | Entry point | Spec |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| Tokenizer | `CSS.tokenize` | Syntax 4 §4 |
|
|
39
|
+
| Parser (with nesting) | `CSS.parse_stylesheet` and §5.3 entry points | Syntax 4 §5, Nesting 1 |
|
|
40
|
+
| Serializer (round-trip) | `CSS.serialize` | Syntax 4 §9 |
|
|
41
|
+
| `urange` | `CSS.parse_urange` | Syntax 4 §6 |
|
|
42
|
+
| Selector parser | `CSS.parse_selector_list`, `CSS.parse_selector` | Selectors 4 |
|
|
43
|
+
| AnB microsyntax | `CSS.parse_anb` | Syntax 4 §6.7 |
|
|
44
|
+
| Specificity | `CSS.specificity` | Selectors §16 |
|
|
45
|
+
| Selector matcher | `CSS.matches?` | Selectors 4 |
|
|
46
|
+
| Nesting de-sugar | `CSS.desugar` | Nesting 1 |
|
|
47
|
+
| Media query parser | `CSS.parse_media_query_list` | Media Queries 4 |
|
|
48
|
+
| Media query evaluator | `CSS.media_matches?` | Media Queries 4 |
|
|
49
|
+
| Cascade resolver | `CSS.cascade(...).resolve(element)` | Cascade & Inheritance 4 (subset) |
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Gemfile
|
|
55
|
+
gem 'p_css'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
bundle add p_css
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Ruby 3.4+ is required. The matcher works against any object that quacks like
|
|
65
|
+
a DOM element (`Nokogiri::XML::Element` works out of the box); Nokogiri is not
|
|
66
|
+
a hard dependency.
|
|
67
|
+
|
|
68
|
+
## Quick tour
|
|
69
|
+
|
|
70
|
+
### Parse a stylesheet
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
ss = CSS.parse_stylesheet(<<~CSS)
|
|
74
|
+
.card, .panel {
|
|
75
|
+
color: red;
|
|
76
|
+
& .title { font-weight: 700; }
|
|
77
|
+
@media (min-width: 600px) { padding: 2rem; }
|
|
78
|
+
}
|
|
79
|
+
CSS
|
|
80
|
+
|
|
81
|
+
ss.rules.size # => 1
|
|
82
|
+
ss.rules.first.block.items.count # => 3 (1 declaration + 1 nested rule + 1 nested at-rule)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`CSS.parse` is an alias of `CSS.parse_stylesheet`.
|
|
86
|
+
|
|
87
|
+
### Round-trip
|
|
88
|
+
|
|
89
|
+
`CSS.serialize` accepts any AST node, Token, or array of component values, and
|
|
90
|
+
emits CSS that re-parses to the same AST.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
src = '.foo { color: #abc; & .x { font-weight: 700 !important; } }'
|
|
94
|
+
CSS.serialize(CSS.parse_stylesheet(src))
|
|
95
|
+
# => ".foo {\n color: #abc;\n & .x {\n font-weight: 700 !important;\n }\n}"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Spec entry points
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
CSS.parse_rule('@charset "UTF-8";') # one rule
|
|
102
|
+
CSS.parse_declaration('color: red !important') # one declaration
|
|
103
|
+
CSS.parse_block_contents('color: red; padding: 1em') # for `style="..."` etc.
|
|
104
|
+
CSS.parse_component_value('rgb(1, 2, 3)') # one component value
|
|
105
|
+
CSS.parse_component_values('1px solid red') # array of component values
|
|
106
|
+
CSS.parse_comma_separated_values('1px, 2px, 3px') # array of arrays
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Comments and source positions
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
ts = CSS.tokenize("a /* hi */ b\n c", preserve_comments: true)
|
|
113
|
+
ts.map { [it.type, it.value, it.position.to_s] }
|
|
114
|
+
# => [[:ident, "a", "1:1"],
|
|
115
|
+
# [:whitespace, nil, "1:2"],
|
|
116
|
+
# [:comment, " hi ", "1:3"],
|
|
117
|
+
# ...]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`Token#position` is set during tokenization (`line`, `column`, `offset`,
|
|
121
|
+
`end_offset`). Equality on `Token` ignores position, so hand-built tokens still
|
|
122
|
+
compare equal to parsed ones.
|
|
123
|
+
|
|
124
|
+
`ParseError#position` carries the same information, and the message is prefixed
|
|
125
|
+
`line:col:` when available.
|
|
126
|
+
|
|
127
|
+
### Selectors
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
sl = CSS.parse_selector_list('.card > a:hover, [data-x="y" i]:nth-child(2n+1)')
|
|
131
|
+
sl.selectors.size # => 2
|
|
132
|
+
sl.selectors[0].combinators # => [:child]
|
|
133
|
+
|
|
134
|
+
compound = sl.selectors[1].compounds[0]
|
|
135
|
+
attr = compound.components[0]
|
|
136
|
+
attr.matcher # => :exact
|
|
137
|
+
attr.case_flag # => :i
|
|
138
|
+
|
|
139
|
+
nth = compound.components[1]
|
|
140
|
+
nth.argument # => CSS::Selectors::AnB(step: 2, offset: 1)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The selector parser also accepts the prelude of a parsed rule directly (the
|
|
144
|
+
prelude can contain `Function` / `SimpleBlock` nodes from the main parser; they
|
|
145
|
+
are flattened back into a token stream automatically):
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
ss = CSS.parse_stylesheet('.x { ... }')
|
|
149
|
+
CSS.parse_selector_list(ss.rules.first.prelude)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Specificity
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
CSS.specificity(CSS.parse_selector_list('div.a#b')) # => Specificity(1, 1, 1)
|
|
156
|
+
CSS.specificity(CSS.parse_selector_list(':where(#x)')) # => Specificity(0, 0, 0)
|
|
157
|
+
CSS.specificity(CSS.parse_selector_list(':is(.a, #b)')) # => Specificity(1, 0, 0)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`Specificity` is `Comparable`, so `>`, `<`, `==` work as expected.
|
|
161
|
+
|
|
162
|
+
### Matcher
|
|
163
|
+
|
|
164
|
+
`CSS.matches?(element, selector)` checks whether a duck-typed element matches a
|
|
165
|
+
selector. The element must respond to `name` (or `tag_name`), `[]`, `parent`,
|
|
166
|
+
sibling navigation (`previous_element` / `next_element` if defined; otherwise
|
|
167
|
+
`previous_sibling` / `next_sibling`), and `children`. Nokogiri elements satisfy
|
|
168
|
+
this without any wrapping.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
require 'nokogiri'
|
|
172
|
+
|
|
173
|
+
doc = Nokogiri::HTML(<<~HTML)
|
|
174
|
+
<ul>
|
|
175
|
+
<li>one</li>
|
|
176
|
+
<li class="active">two</li>
|
|
177
|
+
<li>three</li>
|
|
178
|
+
</ul>
|
|
179
|
+
HTML
|
|
180
|
+
|
|
181
|
+
active = doc.at_css('li.active')
|
|
182
|
+
CSS.matches?(active, 'li:nth-child(2n)') # => true
|
|
183
|
+
CSS.matches?(active, ':is(.active, .selected)') # => true
|
|
184
|
+
CSS.matches?(active, 'ul > li:not(:first-child)') # => true
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Stateful pseudo-classes (`:hover`, `:focus`, `:visited`, validity API states,
|
|
188
|
+
etc.) deliberately return `false` — this matcher is intended for stateless
|
|
189
|
+
analysis. `:has()` is not yet implemented (its argument is kept as opaque
|
|
190
|
+
component values).
|
|
191
|
+
|
|
192
|
+
### Nesting de-sugar
|
|
193
|
+
|
|
194
|
+
`CSS.desugar` returns a flat Stylesheet with `&` substituted by the parent
|
|
195
|
+
selector. Single-compound parents inline directly; multi-selector parents
|
|
196
|
+
collapse to `:is(...)`.
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
src = <<~CSS
|
|
200
|
+
.card, .panel {
|
|
201
|
+
color: red;
|
|
202
|
+
& .title { font-weight: 700; }
|
|
203
|
+
}
|
|
204
|
+
CSS
|
|
205
|
+
|
|
206
|
+
CSS.serialize(CSS.desugar(CSS.parse_stylesheet(src)))
|
|
207
|
+
# .card, .panel {
|
|
208
|
+
# color: red;
|
|
209
|
+
# }
|
|
210
|
+
# :is(.card, .panel) .title {
|
|
211
|
+
# font-weight: 700;
|
|
212
|
+
# }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Media queries
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
ql = CSS.parse_media_query_list('screen and (600px <= width < 1200px)')
|
|
219
|
+
|
|
220
|
+
ctx = CSS::MediaQueries::Context.default('width' => 800)
|
|
221
|
+
CSS.media_matches?(ql, ctx) # => true
|
|
222
|
+
|
|
223
|
+
ctx = CSS::MediaQueries::Context.default('width' => 1500)
|
|
224
|
+
CSS.media_matches?(ql, ctx) # => false
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`Context` is a feature-name-keyed Hash with sensible defaults (1024×768
|
|
228
|
+
landscape light-mode screen). Override per call:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
ctx = CSS::MediaQueries::Context.default(
|
|
232
|
+
'width' => 1200,
|
|
233
|
+
'prefers-color-scheme' => 'dark'
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Length units (px, em, rem, pt, pc, in, cm, mm, Q) are converted to CSS px
|
|
238
|
+
against a 16-px root assumption; resolution units (dppx, x, dpi, dpcm) to
|
|
239
|
+
dppx.
|
|
240
|
+
|
|
241
|
+
### Cascade
|
|
242
|
+
|
|
243
|
+
`Cascade` resolves the winning declaration per property for one element.
|
|
244
|
+
Construct once per stylesheet (selectors, media queries, and specificities are
|
|
245
|
+
pre-computed); call `resolve(element)` cheaply per element.
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
ss = CSS.parse_stylesheet(<<~CSS)
|
|
249
|
+
p { color: black; }
|
|
250
|
+
.lead { color: blue; }
|
|
251
|
+
p.special { color: red !important; }
|
|
252
|
+
@media (max-width: 600px) {
|
|
253
|
+
.lead { font-size: 0.875rem; }
|
|
254
|
+
}
|
|
255
|
+
CSS
|
|
256
|
+
|
|
257
|
+
ctx = CSS::MediaQueries::Context.default('width' => 1024)
|
|
258
|
+
cascade = CSS.cascade(ss, context: ctx)
|
|
259
|
+
|
|
260
|
+
el = Nokogiri::HTML('<p class="lead special">…</p>').at_css('p')
|
|
261
|
+
winners = cascade.resolve(el, inline_style: el['style'])
|
|
262
|
+
|
|
263
|
+
CSS.serialize(winners['color'].value) # => "red"
|
|
264
|
+
winners['color'].important # => true
|
|
265
|
+
winners['font-size'] # => nil (only fires for max-width: 600px)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The cascade sort follows: `!important` > inline > stylesheet > specificity >
|
|
269
|
+
source order. Cascade layers, `@scope` proximity, and Shadow DOM
|
|
270
|
+
encapsulation are not modeled — `@layer` / `@supports` / `@scope` /
|
|
271
|
+
`@container` / `@starting-style` blocks are descended into unconditionally.
|
|
272
|
+
|
|
273
|
+
### `urange`
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
r = CSS.parse_urange('U+10??')
|
|
277
|
+
r.first # => 0x1000
|
|
278
|
+
r.last # => 0x10FF
|
|
279
|
+
r.cover?(0x10AB) # => true
|
|
280
|
+
r.to_s # => "U+1000-10FF"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Out of scope
|
|
284
|
+
|
|
285
|
+
These are deliberate omissions; pull requests welcome:
|
|
286
|
+
|
|
287
|
+
- Selectors Level 4 namespace prefixes (`ns|*`)
|
|
288
|
+
- The column combinator `||`
|
|
289
|
+
- `:has()` (relative selector list — needs a small AST extension)
|
|
290
|
+
- Strict/forgiving selector list distinction
|
|
291
|
+
- `@scope` proximity and the rest of the Cascade Layers spec
|
|
292
|
+
- Layout calculations (`display: block` vs flex sizing, `overflow: hidden`
|
|
293
|
+
clipping). p CSS reports the resolved property values; deciding whether
|
|
294
|
+
those values produce a zero-sized box is outside its scope.
|
|
295
|
+
|
|
296
|
+
## Compatibility
|
|
297
|
+
|
|
298
|
+
Ruby 3.4+. Tested on the current MRI. No mandatory runtime dependencies.
|
|
299
|
+
|
|
300
|
+
## License
|
|
301
|
+
|
|
302
|
+
MIT.
|
data/lib/css/cascade.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
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, and `@media` chains are
|
|
12
|
+
# evaluated against the supplied context up-front so non-matching rules
|
|
13
|
+
# are dropped). `resolve(element)` is then cheap to call per node.
|
|
14
|
+
#
|
|
15
|
+
# Cascade layers, `@scope` proximity, and Shadow DOM encapsulation are
|
|
16
|
+
# not modeled — `@layer`, `@supports`, `@container`, `@scope`, and
|
|
17
|
+
# `@starting-style` blocks are descended into unconditionally.
|
|
18
|
+
class Cascade
|
|
19
|
+
Match = Data.define(:declaration, :specificity, :inline, :order)
|
|
20
|
+
|
|
21
|
+
RuleEntry = Data.define(:selector_pairs, :declarations)
|
|
22
|
+
|
|
23
|
+
TRANSPARENT_AT_RULES = %w[supports layer scope starting-style container].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(stylesheet, context: MediaQueries::Context.default)
|
|
26
|
+
@context = context
|
|
27
|
+
@entries = compile(stylesheet)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns Hash<String, Declaration> of winning declarations.
|
|
31
|
+
def resolve(element, inline_style: nil)
|
|
32
|
+
order = 0
|
|
33
|
+
matches = []
|
|
34
|
+
|
|
35
|
+
@entries.each do |entry|
|
|
36
|
+
spec = best_matching_specificity(element, entry.selector_pairs)
|
|
37
|
+
next if spec.nil?
|
|
38
|
+
|
|
39
|
+
entry.declarations.each do |decl|
|
|
40
|
+
order += 1
|
|
41
|
+
matches << Match.new(declaration: decl, specificity: spec, inline: false, order: order)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if inline_style
|
|
46
|
+
inline_declarations(inline_style).each do |decl|
|
|
47
|
+
order += 1
|
|
48
|
+
matches << Match.new(declaration: decl, specificity: Selectors::Specificity::ZERO, inline: true, order: order)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
pick_winners(matches)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def compile(stylesheet)
|
|
58
|
+
out = []
|
|
59
|
+
walk(stylesheet.rules, [], out)
|
|
60
|
+
out
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Filters the stylesheet down to rules whose `@media` chain (if any)
|
|
64
|
+
# matches the cascade's context, pre-parsing every selector list and
|
|
65
|
+
# caching its specificity per selector.
|
|
66
|
+
def walk(rules, media_chain, out)
|
|
67
|
+
rules.each do |rule|
|
|
68
|
+
case rule
|
|
69
|
+
when Nodes::QualifiedRule
|
|
70
|
+
register_qualified_rule(rule, media_chain, out)
|
|
71
|
+
when Nodes::AtRule
|
|
72
|
+
dispatch_at_rule(rule, media_chain, out)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def register_qualified_rule(rule, media_chain, out)
|
|
78
|
+
return unless media_chain.all? { MediaQueries::Evaluator.evaluate(it, @context) }
|
|
79
|
+
|
|
80
|
+
sl = Selectors::Parser.parse_selector_list(rule.prelude)
|
|
81
|
+
pairs = sl.selectors.map { [it, Selectors::SpecificityCalculator.calculate(it)] }
|
|
82
|
+
decls = rule.block.items.select { it.is_a?(Nodes::Declaration) }
|
|
83
|
+
|
|
84
|
+
out << RuleEntry.new(selector_pairs: pairs, declarations: decls)
|
|
85
|
+
rescue ParseError
|
|
86
|
+
# Browsers drop a rule whose prelude doesn't parse as a selector
|
|
87
|
+
# list rather than poisoning the whole stylesheet; do the same.
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dispatch_at_rule(rule, media_chain, out)
|
|
91
|
+
return unless rule.block
|
|
92
|
+
|
|
93
|
+
case rule.name.downcase
|
|
94
|
+
when 'media'
|
|
95
|
+
ql = MediaQueries::Parser.parse(rule.prelude)
|
|
96
|
+
walk(rule.block.items, [*media_chain, ql], out)
|
|
97
|
+
when *TRANSPARENT_AT_RULES
|
|
98
|
+
walk(rule.block.items, media_chain, out)
|
|
99
|
+
end
|
|
100
|
+
rescue ParseError
|
|
101
|
+
# Bad media prelude → skip this @media block; rules outside it
|
|
102
|
+
# remain unaffected.
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def best_matching_specificity(element, selector_pairs)
|
|
106
|
+
best = nil
|
|
107
|
+
|
|
108
|
+
selector_pairs.each do |sel, spec|
|
|
109
|
+
next unless Selectors::Matcher.matches?(element, sel)
|
|
110
|
+
|
|
111
|
+
best = spec if best.nil? || spec > best
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
best
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Single-pass: keep the running winner per property name. Cheaper than
|
|
118
|
+
# group_by + max_by, and more importantly avoids allocating a fresh
|
|
119
|
+
# comparison key per declaration.
|
|
120
|
+
def pick_winners(matches)
|
|
121
|
+
winners = {}
|
|
122
|
+
winner_matches = {}
|
|
123
|
+
|
|
124
|
+
matches.each do |m|
|
|
125
|
+
name = m.declaration.name
|
|
126
|
+
incumbent = winner_matches[name]
|
|
127
|
+
|
|
128
|
+
if incumbent.nil? || better?(m, incumbent)
|
|
129
|
+
winners[name] = m.declaration
|
|
130
|
+
winner_matches[name] = m
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
winners
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# `m` outranks `incumbent` when its priority class is higher, or — at
|
|
138
|
+
# the same priority class — its specificity is greater, or — at equal
|
|
139
|
+
# specificity — it appeared later in source order.
|
|
140
|
+
def better?(m, incumbent)
|
|
141
|
+
a = priority(m)
|
|
142
|
+
b = priority(incumbent)
|
|
143
|
+
return a > b unless a == b
|
|
144
|
+
|
|
145
|
+
cmp = m.specificity <=> incumbent.specificity
|
|
146
|
+
return cmp.positive? unless cmp.zero?
|
|
147
|
+
|
|
148
|
+
m.order > incumbent.order
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# !important and inline style each bump the rule into a higher
|
|
152
|
+
# priority class. Encoded so that `priority(a) <=> priority(b)`
|
|
153
|
+
# captures the cascade's origin/importance ordering.
|
|
154
|
+
def priority(m)
|
|
155
|
+
(m.declaration.important ? 2 : 0) + (m.inline ? 1 : 0)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def inline_declarations(style)
|
|
159
|
+
case style
|
|
160
|
+
when String then CSS.parse_block_contents(style).items.select { it.is_a?(Nodes::Declaration) }
|
|
161
|
+
when Nodes::Block then style.items.select { it.is_a?(Nodes::Declaration) }
|
|
162
|
+
when Array then style.select { it.is_a?(Nodes::Declaration) }
|
|
163
|
+
else
|
|
164
|
+
raise ArgumentError, "cannot derive inline declarations from #{style.class}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
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. Implemented with char comparisons rather than regex to
|
|
5
|
+
# avoid pattern-match overhead in the tokenizer's inner loop.
|
|
6
|
+
module CodePoints
|
|
7
|
+
REPLACEMENT = "�".freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def digit?(c)
|
|
12
|
+
!c.nil? && c >= '0' && c <= '9'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def hex_digit?(c)
|
|
16
|
+
return false if c.nil?
|
|
17
|
+
|
|
18
|
+
(c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ident_start_code_point?(c)
|
|
22
|
+
return false if c.nil?
|
|
23
|
+
return true if c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|
|
24
|
+
|
|
25
|
+
c.ord >= 0x80
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ident_code_point?(c)
|
|
29
|
+
return false if c.nil?
|
|
30
|
+
return true if c == '_' || c == '-' || (c >= '0' && c <= '9')
|
|
31
|
+
return true if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|
|
32
|
+
|
|
33
|
+
c.ord >= 0x80
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
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
|