-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[F] Refactor stylesheet and tag validator
Moves configuration from constants to config/manifold.yml Fixes #246
- Loading branch information
Showing
9 changed files
with
570 additions
and
241 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,61 +1,219 @@ | ||
require "memoist" | ||
|
||
module Validator | ||
# This class takes an HTML string input and validates it. In doing so, it will parse the | ||
# HTML and transform it into a valid HTML structure that can be consumed by the Manifold | ||
# frontend. This mainly involves insuring proper nesting, and making sure that the | ||
# structure will work with ReactDom. | ||
class Stylesheet | ||
include Constants | ||
include Mixins::Css::StyleString | ||
|
||
extend Memoist | ||
|
||
def initialize | ||
@out = "" | ||
@config = Rails.configuration.manifold.css_validator | ||
end | ||
|
||
def validate(css_string) | ||
return "" unless css_string | ||
css = css_string.clone | ||
parser = CssParser::Parser.new | ||
@out = "" | ||
parser.load_string!(css) | ||
# @param css [String] the CSS to be validated | ||
# @return [String] parsed and validated CSS | ||
def validate(css) | ||
reset | ||
return output unless css?(css) | ||
@parser.load_string!(css.dup) | ||
parser.each_selector(&method(:visit_selector)) | ||
@out.split("\n").map(&:squish).join("\n").strip | ||
output | ||
end | ||
|
||
# Removes disallowed declarations from declarations | ||
# @param declarations [String] | ||
# @param selector [String, nil] | ||
# @return [String] | ||
def validate_declarations(declarations, selector = nil) | ||
cleaned = [] | ||
parse_declarations(declarations).each_declaration do |property, value, important| | ||
next unless allowed_property?(property, selector) | ||
mapped_value = map_value(property, value) | ||
cleaned.push compose_declaration(property, mapped_value, important) | ||
end | ||
cleaned | ||
end | ||
|
||
private | ||
|
||
def visit_selector(*args) | ||
selector, declarations, _specificity = *args | ||
return unless valid_selector?(selector) | ||
@out << "#{CSS_SCOPE_SELECTOR} " unless selector.start_with?(CSS_SCOPE_SELECTOR) | ||
@out << "#{selector} { " | ||
ruleset = CssParser::RuleSet.new(nil, declarations) | ||
ruleset.expand_shorthand! | ||
ruleset.each_declaration do |property, value, _important| | ||
unless css_property_blacklist_for_selector(selector).include?(property) || !value | ||
@out << " #{property}: #{css_value_map(value)}; " | ||
end | ||
# @return [CssParser::Parser] | ||
def parser | ||
@parser ||= CssParser::Parser.new | ||
end | ||
|
||
# @return [String] | ||
def output | ||
@out.split("\n").map(&:squish).join("\n").strip | ||
end | ||
|
||
# Reset the validator state | ||
def reset | ||
@out = "" | ||
@parser = CssParser::Parser.new | ||
end | ||
|
||
# Is the css param actually css? | ||
# @param css [String] | ||
# @return [Boolean] | ||
def css?(css) | ||
return false if css.blank? | ||
true | ||
end | ||
|
||
# @param selector [String] | ||
# @param declarations [String] | ||
# @return [nil] | ||
def visit_selector(selector, declarations, *_args) | ||
return unless allowed_selector?(selector) | ||
write_rule_set(selector, declarations) | ||
nil | ||
end | ||
|
||
# Appends to @out | ||
# @param selector [String] | ||
# @param declarations [String] | ||
# @return [nil] | ||
def write_rule_set(selector, declarations) | ||
scoped_selector = scope_selector(selector) | ||
cleaned_declarations = validate_declarations(declarations, selector) | ||
@out << compose_rule_set(scoped_selector, cleaned_declarations) | ||
nil | ||
end | ||
|
||
# Prepares declarations for enumeration | ||
# @param declarations [String] | ||
# @return [CssParser::RuleSet] | ||
def parse_declarations(declarations) | ||
parser = CssParser::RuleSet.new(nil, declarations) | ||
parser.expand_shorthand! | ||
parser | ||
end | ||
|
||
# Composes a declaration from parts | ||
# @param property [String] | ||
# @param value [String] | ||
# @param _important [Boolean] | ||
# @return [String] | ||
def compose_declaration(property, value, _important) | ||
"#{property}: #{value};" | ||
end | ||
|
||
# Composes a CSS rule set from a selector and declarations | ||
# @param selector [String] | ||
# @param declarations [Array] | ||
# @return [String] | ||
def compose_rule_set(selector, declarations) | ||
<<~END | ||
#{selector} { | ||
#{declarations.join("\n")} | ||
} | ||
END | ||
end | ||
|
||
# Scopes a selector | ||
# @param selector [String] | ||
# @return [String] | ||
def scope_selector(selector) | ||
return selector if scoped?(selector) | ||
"#{@config.scope} #{selector}" | ||
end | ||
|
||
# Maps a CSS declaration value if necessary | ||
# @param value [String] | ||
# @param property [String] | ||
# @return [String] | ||
def map_value(property, value) | ||
out = value | ||
@config.value_maps.each do |value_map| | ||
next if (property =~ value_map.match).nil? | ||
match = value_map[:entries].find { |kvp| out == kvp[0] } | ||
out = match[1] unless match.nil? | ||
end | ||
@out << "}\n" | ||
out | ||
end | ||
|
||
def css_property_blacklist_for_selector(selector) | ||
tag = rightmost_selection(selector) | ||
blacklist = Validator::Constants::CSS_PROPERTY_BLACKLIST | ||
return blacklist if tag.include?(".") || tag.include?("#") || tag.include?("@") | ||
tag_constant = "TAG_#{tag.upcase}_CSS_PROPERTY_BLACKLIST" | ||
if Validator::Constants.const_defined?(tag_constant) | ||
blacklist |= Validator::Constants.const_get(tag_constant) | ||
# Is a given property allowed generally and for the selector (if provided)? | ||
# @param property [String] | ||
# @param selector [String] | ||
# @return [Boolean] | ||
def allowed_property?(property, selector = nil) | ||
match = @config.exclusions.properties.find do |exclusion| | ||
exclusion_matches_property?(exclusion, property, selector) | ||
end | ||
blacklist | ||
match.nil? | ||
end | ||
|
||
# Is the property excluded from the exclusion config? | ||
# @param exclusion [Hash] | ||
# @param property [String] | ||
# @param selector [String] | ||
# @return [Boolean] | ||
def exclusion_matches_property?(exclusion, property, selector = nil) | ||
return false unless exclusion.key?(:exclude) | ||
covered = exclusion.exclude.include? property | ||
return covered unless exclusion.key?(:condition) | ||
return covered if selector && exclusion_matches_selector?(exclusion, selector) | ||
false | ||
end | ||
|
||
# Does the match condition in the exclusion match the given selector? | ||
# @param exclusion [Hash] | ||
# @param selector [String] | ||
# @return [Boolean] | ||
# @raise InvalidCondition if the exclusions configuration is invalid. | ||
def exclusion_matches_selector?(exclusion, selector) | ||
return true unless exclusion.key?(:condition) | ||
raise InvalidCondition unless valid_selector_condition?(exclusion.condition) | ||
compare = selector | ||
compare = tag_from_selector(selector) if exclusion.condition.type == "tag" | ||
return false if compare.blank? | ||
!(compare =~ exclusion.condition.match).nil? | ||
end | ||
|
||
# Is the condition property on an exclusion configuration valid? | ||
# @param condition [Hash] | ||
# @return [Boolean] | ||
def valid_selector_condition?(condition) | ||
condition.key?(:match) && condition.key?(:type) | ||
end | ||
|
||
def rightmost_selection(selector) | ||
selector.split(/[, >\+~]/).last.split(/[\[:]/).first | ||
# Makes a rough guess at what tag is covered by a given selector. This is not a full | ||
# CSS selector parser. We're using regular expressions and making a best effort to | ||
# determine if the rule is applied globally to a tag, rather than to a tag scoped | ||
# by class or ID. We do this because Manifold limits what global styles can be | ||
# applied to tags, to improve the reading experience. | ||
# @param selector [String] | ||
# @return [String] | ||
def tag_from_selector(selector) | ||
clean = selector.gsub(/\[.*\]/, "") | ||
return clean if clean.blank? | ||
# Find last element in combinatory selectors, strip psuedo selectors. | ||
tag = clean.split(/(\s?[~>+]\s?|\s)/).last.split(/[:]/).first | ||
tag | ||
end | ||
memoize :tag_from_selector | ||
|
||
def valid_selector?(selector) | ||
tag = rightmost_selection(selector) | ||
!CSS_SELECTOR_BLACKLIST.include?(tag) | ||
# Has the selector already been scoped? | ||
# @param selector [String] | ||
# @return [Boolean] | ||
def scoped?(selector) | ||
selector.start_with? @config.scope | ||
end | ||
|
||
# Is the selector allowed? | ||
# @param selector [String] | ||
# @return [Boolean] | ||
def allowed_selector?(selector) | ||
sel = selector.downcase.strip | ||
pattern = Regexp.union(@config.exclusions.selectors) | ||
(sel =~ pattern).nil? | ||
end | ||
|
||
end | ||
|
||
class InvalidCondition < KeyError | ||
end | ||
end |
Oops, something went wrong.