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
- .available? ⇒ Boolean
- .build_style_rule(selectors, declarations) ⇒ Object
-
.collect_namespaces(rules) ⇒ Object
The stylesheet's @namespace prefix => URI map (with :default for the prefixless default namespace), so
svg|rectresolves when the sheet declared@namespace svg url(...). -
.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).
-
.normalize_layer(rule, namespaces) ⇒ Object
An empty
rulesmeans the statement form (or empty block): it only fixes layer order. - .normalize_rules(rules, namespaces = {}) ⇒ Object
- .normalize_selectors(selectors, namespaces = {}) ⇒ Object
-
.parse(text) ⇒ Object
Parse a stylesheet into an Array of StyleRule / MediaRule / LayerRule / SupportsRule in source order (other at-rules are ignored).
-
.parse_declarations(text) ⇒ Object
Parse a bare declaration block (no selector / braces) into Declarations.
-
.parse_import(prelude) ⇒ Object
media query.
-
.parse_namespace(prelude) ⇒ Object
[prefix_or_nil, uri_or_nil].
-
.parse_scope_prelude(prelude) ⇒ Object
end_or_nil] as selector-list texts (an empty prelude — bare @scope — yields [nil, nil]).
- .presence(string) ⇒ Object
-
.split_layer_names(prelude) ⇒ Object
The comma-separated layer names of an @layer prelude ("" → [], so an anonymous layer yields no names).
Class Method Details
.available? ⇒ 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.
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 |