Module: Dommy::Internal::CSS::MediaQuery

Defined in:
lib/dommy/internal/css/media_query.rb

Overview

A Media Queries Level 4 subset evaluator for matchMedia and friends. Queries are matched against an Environment snapshot rather than a real device.

Supported: media types (all/screen true, anything else false), not / only prefixes, and chains, comma-separated OR lists, not before a single condition, (feature: value) and (feature) boolean contexts, and the range syntax for width/height ((width >= 600px), (400px <= width <= 800px)).

Deliberately given up: the or combinator, nested parentheses ((not (width)), general <media-condition> grouping), calc(), and most discrete features beyond the ones listed in FEATURE_VALUES below. Any query we cannot parse — including one using or — evaluates to false, mirroring the spec's "unknown query becomes not all" rule; we never raise.

Defined Under Namespace

Classes: Environment

Constant Summary collapse

DEFAULT =
Environment.default.freeze
FEATURE_VALUES =

Valid values for the discrete features we support; anything else is treated as unparseable (query becomes false).

{
  "prefers-color-scheme" => %w[light dark],
  "prefers-reduced-motion" => %w[no-preference reduce],
  "hover" => %w[none hover],
  "any-hover" => %w[none hover],
  "pointer" => %w[none coarse fine],
  "any-pointer" => %w[none coarse fine]
}.freeze

Class Method Summary collapse

Class Method Details

.compare(left, op, right) ⇒ Object



254
255
256
257
258
259
260
261
262
# File 'lib/dommy/internal/css/media_query.rb', line 254

def compare(left, op, right)
  case op
  when "<" then left < right
  when "<=" then left <= right
  when ">" then left > right
  when ">=" then left >= right
  when "=" then left == right
  end
end

.compare_with_prefix(name, actual, target) ⇒ Object

min- prefix means "at least", max- means "at most", bare name means exact equality.



266
267
268
269
270
271
272
273
274
# File 'lib/dommy/internal/css/media_query.rb', line 266

def compare_with_prefix(name, actual, target)
  if name.start_with?("min-")
    actual >= target
  elsif name.start_with?("max-")
    actual <= target
  else
    actual == target
  end
end

.evaluate_aspect_ratio(name, value, env) ⇒ Object

(aspect-ratio: a/b) — compare width/height against a/b without floating point: width * b <=> a * height.



207
208
209
210
211
212
213
214
215
216
# File 'lib/dommy/internal/css/media_query.rb', line 207

def evaluate_aspect_ratio(name, value, env)
  match = value.match(%r{\A(\d+)\s*/\s*(\d+)\z})
  return nil unless match

  a = Integer(match[1], 10)
  b = Integer(match[2], 10)
  return nil if a.zero? || b.zero?

  compare_with_prefix(name, env.viewport_width * b, a * env.viewport_height)
end

.evaluate_boolean_feature(name, env) ⇒ Object



196
197
198
199
200
201
202
203
# File 'lib/dommy/internal/css/media_query.rb', line 196

def evaluate_boolean_feature(name, env)
  case name
  when "width" then env.viewport_width > 0
  when "height" then env.viewport_height > 0
  when "hover", "any-hover" then env.hover != "none"
  when "pointer", "any-pointer" then env.pointer != "none"
  end
end

.evaluate_condition(inner, env) ⇒ Object

inner is the lower-cased text between parentheses. Returns true/false, or nil when the condition cannot be interpreted.



154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/dommy/internal/css/media_query.rb', line 154

def evaluate_condition(inner, env)
  return nil if inner.empty?

  if inner.match?(/[<>]/) || (inner.include?("=") && !inner.include?(":"))
    evaluate_range(inner, env)
  elsif inner.include?(":")
    name, _, value = inner.partition(":")
    evaluate_feature(name.strip, value.strip, env)
  else
    evaluate_boolean_feature(inner, env)
  end
end

.evaluate_feature(name, value, env) ⇒ Object



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/dommy/internal/css/media_query.rb', line 167

def evaluate_feature(name, value, env)
  case name
  when "width", "min-width", "max-width"
    px = parse_length(value)
    px && compare_with_prefix(name, env.viewport_width, px)
  when "height", "min-height", "max-height"
    px = parse_length(value)
    px && compare_with_prefix(name, env.viewport_height, px)
  when "orientation"
    case value
    when "portrait" then env.viewport_height >= env.viewport_width
    when "landscape" then env.viewport_width > env.viewport_height
    end
  when "aspect-ratio", "min-aspect-ratio", "max-aspect-ratio"
    evaluate_aspect_ratio(name, value, env)
  when "prefers-color-scheme"
    FEATURE_VALUES[name].include?(value) ? env.prefers_color_scheme == value : nil
  when "prefers-reduced-motion"
    FEATURE_VALUES[name].include?(value) ? env.prefers_reduced_motion == value : nil
  when "hover", "any-hover"
    FEATURE_VALUES[name].include?(value) ? env.hover == value : nil
  when "pointer", "any-pointer"
    FEATURE_VALUES[name].include?(value) ? env.pointer == value : nil
  when "resolution", "min-resolution", "max-resolution"
    dppx = parse_resolution(value)
    dppx && compare_with_prefix(name, env.device_pixel_ratio, dppx)
  end
end

.evaluate_range(inner, env) ⇒ Object

Range syntax, single (width >= 600px, 600px <= width) or double (400px <= width <= 800px). width/height only.



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/dommy/internal/css/media_query.rb', line 220

def evaluate_range(inner, env)
  parts = inner.split(/(<=|>=|<|>|=)/).map(&:strip)
  case parts.size
  when 3
    left, op, right = parts
    if (actual = range_feature_value(left, env))
      value = parse_length(right)
      value && compare(actual, op, value)
    elsif (actual = range_feature_value(right, env))
      value = parse_length(left)
      value && compare(value, op, actual)
    end
  when 5
    low, op1, name, op2, high = parts
    actual = range_feature_value(name, env)
    return nil unless actual
    return nil unless (%w[< <=].include?(op1) && %w[< <=].include?(op2)) ||
                      (%w[> >=].include?(op1) && %w[> >=].include?(op2))

    low_px = parse_length(low)
    high_px = parse_length(high)
    return nil unless low_px && high_px

    compare(low_px, op1, actual) && compare(actual, op2, high_px)
  end
end

.match?(text, environment = DEFAULT) ⇒ Boolean

Evaluates a media query list. Comma-separated queries OR together: the list matches if any single query matches. The empty string is the empty query list, which matches (this is what matchMedia("") does in browsers).

Returns:

  • (Boolean)


54
55
56
57
58
59
60
61
# File 'lib/dommy/internal/css/media_query.rb', line 54

def match?(text, environment = DEFAULT)
  text = text.to_s.strip
  return true if text.empty?

  # Commas cannot appear inside our supported conditions, so a
  # plain split is safe.
  text.split(",", -1).any? { |query| query_match?(query.strip, environment) }
end

.parse_length(value) ⇒ Object

Lengths in px; em/rem are converted at the canonical 16px. A bare 0 (no unit) is a valid length; any other unitless number is not.



281
282
283
284
285
286
287
288
289
# File 'lib/dommy/internal/css/media_query.rb', line 281

def parse_length(value)
  return 0.0 if value.match?(/\A0+(?:\.0+)?\z/)

  match = value.match(/\A(\d+(?:\.\d+)?)(px|em|rem)\z/)
  return nil unless match

  number = Float(match[1])
  match[2] == "px" ? number : number * 16
end

.parse_resolution(value) ⇒ Object

Resolutions normalized to dppx; dpi divides by 96.



292
293
294
295
296
297
298
# File 'lib/dommy/internal/css/media_query.rb', line 292

def parse_resolution(value)
  match = value.match(/\A(\d+(?:\.\d+)?)(x|dppx|dpi)\z/)
  return nil unless match

  number = Float(match[1])
  match[2] == "dpi" ? number / 96 : number
end

.query_match?(query, env) ⇒ Boolean

--- single query ---------------------------------------------

Returns:

  • (Boolean)


65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/dommy/internal/css/media_query.rb', line 65

def query_match?(query, env)
  tokens = tokenize(query)
  return false if tokens.nil? || tokens.empty?

  i = 0
  negate_query = false
  if tokens[0] == "not"
    negate_query = true
    i = 1
  elsif tokens[0] == "only"
    # `only` is only valid immediately before a media type
    # (`only screen`); `only (condition)` is a parse error.
    return false unless tokens[1].is_a?(String)

    i = 1
  end
  return false if i >= tokens.size

  # First segment: a media type word, or a leading condition.
  first = tokens[i]
  if first.is_a?(String)
    return false if %w[and not only or].include?(first)

    result = first == "all" || first == "screen"
    i += 1
  else
    result = evaluate_condition(first[1], env)
    return false if result.nil?

    i += 1
  end

  # Remaining segments: `and [not] ( ... )` repeated. Anything
  # else (notably `or`) makes the whole query unparseable.
  while i < tokens.size
    return false unless tokens[i] == "and"

    i += 1
    negate_condition = false
    if tokens[i] == "not"
      negate_condition = true
      i += 1
    end
    token = tokens[i]
    return false unless token.is_a?(Array)

    value = evaluate_condition(token[1], env)
    return false if value.nil?

    value = !value if negate_condition
    result &&= value
    i += 1
  end

  negate_query ? !result : result
end

.range_feature_value(name, env) ⇒ Object



247
248
249
250
251
252
# File 'lib/dommy/internal/css/media_query.rb', line 247

def range_feature_value(name, env)
  case name
  when "width" then env.viewport_width
  when "height" then env.viewport_height
  end
end

.tokenize(query) ⇒ Object

Splits a query into lower-cased keyword strings and [:cond, inner] pairs for parenthesized conditions. Returns nil when the query contains anything else (stray characters, nested or unbalanced parentheses).



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/dommy/internal/css/media_query.rb', line 126

def tokenize(query)
  tokens = []
  rest = query
  until (rest = rest.lstrip).empty?
    if rest.start_with?("(")
      close = rest.index(")")
      return nil if close.nil?

      inner = rest[1...close]
      return nil if inner.include?("(") # nested parens unsupported

      tokens << [:cond, inner.strip.downcase]
      rest = rest[(close + 1)..]
    elsif (match = rest.match(/\A[a-z][a-z0-9-]*/i))
      tokens << match[0].downcase
      rest = rest[match[0].length..]
      return nil unless rest.empty? || rest.start_with?("(") || rest.match?(/\A\s/)
    else
      return nil
    end
  end
  tokens
end