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.
@@ -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(it, 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(it, context) }
69
+ when MediaOr then node.operands.any? { evaluate_condition(it, 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
@@ -0,0 +1,374 @@
1
+ module CSS
2
+ module MediaQueries
3
+ # Parser for `<media-query-list>` per Media Queries Level 4 §3.
4
+ # https://drafts.csswg.org/mediaqueries-4/
5
+ #
6
+ # Accepts either a String (which is tokenized and re-component-valued
7
+ # so `(...)` becomes a `SimpleBlock`) or an Array of component values
8
+ # (for use against an `@media` rule's prelude from the main parser).
9
+ class Parser
10
+ include CSS::TokenCursor
11
+
12
+ MODIFIER_KEYWORDS = %w[not only].freeze
13
+ LOGICAL_KEYWORDS = %w[and or not].freeze
14
+
15
+ class << self
16
+ def parse(input)
17
+ new(items_from(input)).parse_media_query_list
18
+ end
19
+
20
+ private
21
+
22
+ def items_from(input)
23
+ input.is_a?(String) ? CSS::Parser.parse_component_values(input) : input.to_a
24
+ end
25
+ end
26
+
27
+ def initialize(items)
28
+ init_cursor(items)
29
+ end
30
+
31
+ def parse_media_query_list
32
+ skip_whitespace
33
+
34
+ queries = [parse_media_query]
35
+
36
+ loop do
37
+ skip_whitespace
38
+ break unless peek_token.type == :comma
39
+
40
+ consume
41
+ queries << parse_media_query
42
+ end
43
+
44
+ skip_whitespace
45
+
46
+ unless eof?
47
+ parse_error!("trailing tokens after media query list: #{describe(peek)}")
48
+ end
49
+
50
+ MediaQueryList.new(queries:)
51
+ end
52
+
53
+ def parse_media_query
54
+ skip_whitespace
55
+
56
+ saved = @pos
57
+
58
+ # Try the `[not | only]? <media-type> [and <condition-without-or>]?` form.
59
+ modifier = consume_modifier
60
+ skip_whitespace if modifier
61
+
62
+ if (item = peek).is_a?(Token) && item.type == :ident && !LOGICAL_KEYWORDS.include?(item.value.downcase)
63
+ type = consume.value.downcase
64
+ skip_whitespace
65
+
66
+ condition = nil
67
+
68
+ if keyword?('and')
69
+ consume
70
+ skip_whitespace
71
+ condition = parse_media_condition(allow_or: false)
72
+ end
73
+
74
+ return MediaQuery.new(modifier:, type:, condition:)
75
+ end
76
+
77
+ # Otherwise this is a pure media-condition (no type / modifier).
78
+ @pos = saved
79
+
80
+ MediaQuery.new(modifier: nil, type: nil, condition: parse_media_condition(allow_or: true))
81
+ end
82
+
83
+ private
84
+
85
+ def parse_media_condition(allow_or:)
86
+ skip_whitespace
87
+
88
+ if keyword?('not')
89
+ consume
90
+ skip_whitespace
91
+ return MediaNot.new(operand: parse_media_in_parens)
92
+ end
93
+
94
+ first = parse_media_in_parens
95
+
96
+ skip_whitespace
97
+
98
+ if keyword?('and')
99
+ operands = [first]
100
+ while keyword?('and')
101
+ consume
102
+ skip_whitespace
103
+ operands << parse_media_in_parens
104
+ skip_whitespace
105
+ end
106
+ MediaAnd.new(operands:)
107
+ elsif allow_or && keyword?('or')
108
+ operands = [first]
109
+ while keyword?('or')
110
+ consume
111
+ skip_whitespace
112
+ operands << parse_media_in_parens
113
+ skip_whitespace
114
+ end
115
+ MediaOr.new(operands:)
116
+ else
117
+ first
118
+ end
119
+ end
120
+
121
+ def parse_media_in_parens
122
+ item = peek
123
+
124
+ unless item.is_a?(Nodes::SimpleBlock) && item.parenthesized?
125
+ parse_error!("expected '(', got #{describe(item)}")
126
+ end
127
+
128
+ consume
129
+ inner = self.class.new(item.value)
130
+ inner.parse_in_parens_contents
131
+ end
132
+
133
+ protected
134
+
135
+ # Called on a sub-parser whose @items is the contents inside `(...)`.
136
+ # Returns a media-condition or a feature.
137
+ def parse_in_parens_contents
138
+ skip_whitespace
139
+
140
+ return GeneralEnclosed.new(tokens: []) if eof?
141
+
142
+ # Nested `(condition)`?
143
+ first = peek
144
+
145
+ if first.is_a?(Nodes::SimpleBlock) && first.parenthesized?
146
+ cond = parse_media_condition(allow_or: true)
147
+ skip_whitespace
148
+
149
+ unless eof?
150
+ return GeneralEnclosed.new(tokens: @items)
151
+ end
152
+
153
+ return cond
154
+ end
155
+
156
+ result = try_parse_feature
157
+
158
+ return result if result
159
+
160
+ GeneralEnclosed.new(tokens: @items)
161
+ end
162
+
163
+ private
164
+
165
+ def try_parse_feature
166
+ saved = @pos
167
+
168
+ starting_token = peek
169
+
170
+ if starting_token.is_a?(Token) && starting_token.type == :ident
171
+ feature = try_parse_feature_starting_with_ident
172
+
173
+ return feature if feature
174
+
175
+ @pos = saved
176
+ end
177
+
178
+ if value_starts?(starting_token)
179
+ feature = try_parse_feature_starting_with_value
180
+
181
+ return feature if feature
182
+
183
+ @pos = saved
184
+ end
185
+
186
+ nil
187
+ end
188
+
189
+ def try_parse_feature_starting_with_ident
190
+ name = consume.value.downcase
191
+
192
+ skip_whitespace
193
+
194
+ if eof?
195
+ return MediaFeature.new(name:, op: nil, value: nil)
196
+ end
197
+
198
+ if peek_token.type == :colon
199
+ consume
200
+ skip_whitespace
201
+ value = parse_mf_value
202
+
203
+ return nil if value.nil?
204
+
205
+ skip_whitespace
206
+
207
+ return nil unless eof?
208
+
209
+ return MediaFeature.new(name:, op: :eq, value:)
210
+ end
211
+
212
+ if (op = consume_comparison)
213
+ skip_whitespace
214
+ value = parse_mf_value
215
+
216
+ return nil if value.nil?
217
+
218
+ skip_whitespace
219
+
220
+ if eof?
221
+ return MediaFeature.new(name:, op:, value:)
222
+ end
223
+
224
+ # Bounded form: `<name> <op> <value> ... <op> <value>`
225
+ # Per spec, bounded form has the name in the middle, not here.
226
+ # Reject.
227
+ return nil
228
+ end
229
+
230
+ nil
231
+ end
232
+
233
+ def try_parse_feature_starting_with_value
234
+ first_value = parse_mf_value
235
+
236
+ return nil if first_value.nil?
237
+
238
+ skip_whitespace
239
+
240
+ first_op = consume_comparison
241
+
242
+ return nil unless first_op
243
+
244
+ skip_whitespace
245
+
246
+ return nil unless peek_token.type == :ident
247
+
248
+ name = consume.value.downcase
249
+
250
+ skip_whitespace
251
+
252
+ if eof?
253
+ return MediaFeature.new(name:, op: invert_op(first_op), value: first_value)
254
+ end
255
+
256
+ # Bounded form: <value> <op1> <name> <op2> <value>
257
+ second_op = consume_comparison
258
+
259
+ return nil unless second_op
260
+
261
+ skip_whitespace
262
+
263
+ second_value = parse_mf_value
264
+
265
+ return nil if second_value.nil?
266
+
267
+ skip_whitespace
268
+
269
+ return nil unless eof?
270
+
271
+ # Decompose into MediaAnd of two normalized features.
272
+ MediaAnd.new(operands: [
273
+ MediaFeature.new(name:, op: invert_op(first_op), value: first_value),
274
+ MediaFeature.new(name:, op: second_op, value: second_value)
275
+ ])
276
+ end
277
+
278
+ # `value op name` swaps to `name (inverted op) value`.
279
+ INVERSE_OP = {lt: :gt, le: :ge, gt: :lt, ge: :le, eq: :eq}.freeze
280
+
281
+ def invert_op(op) = INVERSE_OP.fetch(op, op)
282
+
283
+ def parse_mf_value
284
+ item = peek
285
+
286
+ return nil unless item.is_a?(Token)
287
+
288
+ case item.type
289
+ when :number
290
+ consume
291
+
292
+ if peek_token.type == :delim && peek_token.value == '/'
293
+ consume
294
+ skip_whitespace
295
+ denom = peek
296
+
297
+ return nil unless denom.is_a?(Token) && denom.type == :number
298
+
299
+ consume
300
+ return Ratio.new(numerator: item.value, denominator: denom.value)
301
+ end
302
+
303
+ item
304
+ when :dimension, :percentage, :ident, :string
305
+ consume
306
+ item
307
+ else
308
+ nil
309
+ end
310
+ end
311
+
312
+ def consume_comparison
313
+ item = peek
314
+
315
+ return nil unless item.is_a?(Token) && item.type == :delim
316
+
317
+ case item.value
318
+ when '='
319
+ consume
320
+ :eq
321
+ when '<'
322
+ consume
323
+
324
+ if peek_token.type == :delim && peek_token.value == '='
325
+ consume
326
+ :le
327
+ else
328
+ :lt
329
+ end
330
+ when '>'
331
+ consume
332
+
333
+ if peek_token.type == :delim && peek_token.value == '='
334
+ consume
335
+ :ge
336
+ else
337
+ :gt
338
+ end
339
+ end
340
+ end
341
+
342
+ def value_starts?(item)
343
+ item.is_a?(Token) && %i[number dimension percentage].include?(item.type)
344
+ end
345
+
346
+ def consume_modifier
347
+ item = peek
348
+
349
+ return nil unless item.is_a?(Token) && item.type == :ident
350
+
351
+ kw = item.value.downcase
352
+
353
+ return nil unless MODIFIER_KEYWORDS.include?(kw)
354
+
355
+ consume
356
+ kw.to_sym
357
+ end
358
+
359
+ def keyword?(kw)
360
+ item = peek
361
+ item.is_a?(Token) && item.type == :ident && item.value.downcase == kw
362
+ end
363
+
364
+ def describe(item)
365
+ case item
366
+ when Token then item.type
367
+ when Nodes::SimpleBlock then "#{item.open}-block"
368
+ when Nodes::Function then "#{item.name}()"
369
+ else item.class.name
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'media_queries/nodes'
2
+ require_relative 'media_queries/parser'
3
+ require_relative 'media_queries/context'
4
+ require_relative 'media_queries/evaluator'
5
+
6
+ module CSS
7
+ module MediaQueries
8
+ end
9
+ end