Module: Dommy::Internal::CSS::Parser

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

Overview

The abstraction seam over the CSS stylesheet parser. The only implementation is makiri's lexbor binding (Makiri::Lexbor::CSS.parse_stylesheet) — see css-cascade.md: the tokenizer, error recovery, and specificity all come from lexbor; this module just normalizes its plain-Hash output into structs the cascade layer consumes. CSS parsing is independent of the DOM backend, so this works on the Nokogiri backend too as long as the makiri gem is installed.

Defined Under Namespace

Classes: Declaration, ImportRule, LayerRule, LayerStatement, MediaRule, ScopeRule, Selector, StyleRule, SupportsRule, Unavailable

Class Method Summary collapse

Class Method Details

.available?Boolean

Returns:

  • (Boolean)


82
83
84
85
86
87
88
89
90
91
# File 'lib/dommy/internal/css/parser.rb', line 82

def available?
  return @available unless @available.nil?

  @available = begin
    require "makiri"
    !!defined?(::Makiri::Lexbor::CSS)
  rescue LoadError
    false
  end
end

.build_style_rule(selectors, declarations) ⇒ Object



231
232
233
234
235
# File 'lib/dommy/internal/css/parser.rb', line 231

def build_style_rule(selectors, declarations)
  return nil if selectors.empty?

  StyleRule.new(selectors, declarations.map { |d| Declaration.new(d[:name], d[:value], d[:important]) })
end

.collect_namespaces(rules) ⇒ Object

The stylesheet's @namespace prefix => URI map (with :default for the prefixless default namespace), so svg|rect resolves when the sheet declared @namespace svg url(...). @namespace is top-level only.



125
126
127
128
129
130
131
132
# File 'lib/dommy/internal/css/parser.rb', line 125

def collect_namespaces(rules)
  rules.each_with_object({}) do |rule, namespaces|
    next unless rule[:type] == :at_rule && rule[:name].to_s.casecmp("namespace").zero?

    prefix, uri = parse_namespace(rule[:prelude])
    namespaces[prefix.nil? ? :default : prefix] = uri if uri
  end
end

.normalize_at_rule(rule, namespaces = {}) ⇒ Object

understands; every other at-rule (@font-face, @keyframes, @import, @page, ...) is ignored for now (returning nil drops it from the rule stream, but lexbor still parsed it, so nothing crashes).



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/dommy/internal/css/parser.rb', line 157

def normalize_at_rule(rule, namespaces = {})
  case rule[:name].to_s.downcase
  when "media"
    MediaRule.new(rule[:prelude], normalize_rules(rule[:rules], namespaces))
  when "layer"
    normalize_layer(rule, namespaces)
  when "supports"
    SupportsRule.new(rule[:prelude], normalize_rules(rule[:rules], namespaces))
  when "scope"
    scope_start, scope_end = parse_scope_prelude(rule[:prelude])
    ScopeRule.new(scope_start, scope_end, normalize_rules(rule[:rules], namespaces))
  when "import"
    parse_import(rule[:prelude])
  end
end

.normalize_layer(rule, namespaces) ⇒ Object

An empty rules means the statement form (or empty block): it only fixes layer order. A nil name (empty prelude) is an anonymous layer.



180
181
182
183
184
185
186
187
188
# File 'lib/dommy/internal/css/parser.rb', line 180

def normalize_layer(rule, namespaces)
  names = split_layer_names(rule[:prelude])
  rules = rule[:rules] || []
  if rules.empty?
    LayerStatement.new(names.empty? ? [nil] : names)
  else
    LayerRule.new(names.first, normalize_rules(rules, namespaces))
  end
end

.normalize_rules(rules, namespaces = {}) ⇒ Object



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/dommy/internal/css/parser.rb', line 104

def normalize_rules(rules, namespaces = {})
  rules.filter_map do |rule|
    case rule[:type]
    when :style
      build_style_rule(normalize_selectors(rule[:selectors], namespaces), rule[:declarations])
    when :bad_style
      # lexbor couldn't parse this selector list (most often a
      # pseudo-element like ::before, which its parser predates).
      # Re-validate the raw prelude with Dommy's Selectors L4 parser:
      # pseudo-element rules survive, genuinely invalid ones drop
      # (normalize_selectors returns []).
      build_style_rule(normalize_selectors([{text: rule[:selector_text]}], namespaces), rule[:declarations])
    when :at_rule
      normalize_at_rule(rule, namespaces)
    end
  end
end

.normalize_selectors(selectors, namespaces = {}) ⇒ Object



237
238
239
240
241
242
243
244
# File 'lib/dommy/internal/css/parser.rb', line 237

def normalize_selectors(selectors, namespaces = {})
  selectors.map do |selector|
    ast = Internal::SelectorParser.parse!(selector[:text], namespaces: namespaces)
    Selector.new(selector[:text], ast, ast.specificity.to_a)
  end
rescue DOMException::SyntaxError
  []
end

.parse(text) ⇒ Object

Parse a stylesheet into an Array of StyleRule / MediaRule / LayerRule / SupportsRule in source order (other at-rules are ignored). Lexbor's css-syntax-3 error recovery applies; selectors and at-rules it can't parse are surfaced for Dommy to re-validate, not silently dropped.

Raises:



97
98
99
100
101
102
# File 'lib/dommy/internal/css/parser.rb', line 97

def parse(text)
  raise Unavailable unless available?

  rules = ::Makiri::Lexbor::CSS.parse_stylesheet(text.to_s)
  normalize_rules(rules, collect_namespaces(rules))
end

.parse_declarations(text) ⇒ Object

Parse a bare declaration block (no selector / braces) into Declarations. Used by the CSSOM RuleStyleDeclaration to read a rule's style; the cascade reaches declarations through parse/normalize_rules.



249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/dommy/internal/css/parser.rb', line 249

def parse_declarations(text)
  text.split(";").filter_map do |chunk|
    name, value = chunk.split(":", 2)
    next unless name && value

    name = name.strip.downcase
    value = value.strip
    next if name.empty? || value.empty?

    important = !value.sub!(/\s*!important\s*\z/i, "").nil?
    Declaration.new(name, value.strip, important)
  end
end

.parse_import(prelude) ⇒ Object

media query. Returns nil (the rule is dropped) when no URL is found.



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/dommy/internal/css/parser.rb', line 215

def parse_import(prelude)
  prelude = prelude.to_s.strip
  if (match = prelude.match(/\Aurl\(\s*(.*?)\s*\)/i))
    url = match[1]
  elsif (match = prelude.match(/\A(?:"([^"]*)"|'([^']*)')/))
    url = match[1] || match[2]
  else
    return nil
  end

  url = url.to_s.gsub(/\A["']|["']\z/, "").strip
  return nil if url.empty?

  ImportRule.new(url, prelude[match.end(0)..].to_s.strip)
end

.parse_namespace(prelude) ⇒ Object

[prefix_or_nil, uri_or_nil].



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/dommy/internal/css/parser.rb', line 136

def parse_namespace(prelude)
  rest = prelude.to_s.strip
  prefix = nil
  if (match = rest.match(/\A([A-Za-z_][\w-]*)\s+/))
    prefix = match[1]
    rest = rest[match.end(0)..]
  end

  uri =
    if (match = rest.match(/\Aurl\(\s*(.*?)\s*\)\s*\z/i))
      match[1]
    elsif (match = rest.match(/\A(?:"([^"]*)"|'([^']*)')\s*\z/))
      match[1] || match[2]
    end
  [prefix, uri&.gsub(/\A["']|["']\z/, "")&.strip]
end

.parse_scope_prelude(prelude) ⇒ Object

end_or_nil] as selector-list texts (an empty prelude — bare @scope — yields [nil, nil]).



199
200
201
202
203
204
205
206
207
# File 'lib/dommy/internal/css/parser.rb', line 199

def parse_scope_prelude(prelude)
  text = prelude.to_s.strip
  return [nil, nil] if text.empty?

  match = text.match(/\A\(\s*(.*?)\s*\)\s*(?:to\s*\(\s*(.*?)\s*\)\s*)?\z/m)
  return [nil, nil] unless match

  [presence(match[1]), presence(match[2])]
end

.presence(string) ⇒ Object



209
210
211
# File 'lib/dommy/internal/css/parser.rb', line 209

def presence(string)
  string.nil? || string.empty? ? nil : string
end

.split_layer_names(prelude) ⇒ Object

The comma-separated layer names of an @layer prelude ("" → [], so an anonymous layer yields no names).



192
193
194
# File 'lib/dommy/internal/css/parser.rb', line 192

def split_layer_names(prelude)
  prelude.to_s.split(",").map(&:strip).reject(&:empty?)
end