# frozen_string_literal: true require "set" require "nokogiri" require "uri" require "css_parser" module Roadie # @api private # The Inliner inlines stylesheets to the elements of the DOM. # # Inlining means that {StyleBlock}s and a DOM tree are combined: # # ```css # a { color: red; } # StyleBlock # ``` # ```html # # DOM # ``` # # becomes # # ```html # # ``` class Inliner # @param [Array] stylesheets the stylesheets to use in the inlining # @param [Nokogiri::HTML::Document] dom def initialize(stylesheets, dom) @stylesheets = stylesheets @dom = dom end # Start the inlining, mutating the DOM tree. # # @option options [true, false] :keep_uninlinable_css # @option options [:root, :head] :keep_uninlinable_in # @option options [true, false] :merge_media_queries # @return [nil] def inline(options = {}) keep_uninlinable_css = options.fetch(:keep_uninlinable_css, true) keep_uninlinable_in = options.fetch(:keep_uninlinable_in, :head) merge_media_queries = options.fetch(:merge_media_queries, true) style_map, extra_blocks = consume_stylesheets apply_style_map(style_map) if keep_uninlinable_css add_uninlinable_styles(keep_uninlinable_in, extra_blocks, merge_media_queries) end nil end protected attr_reader :stylesheets, :dom private def consume_stylesheets style_map = StyleMap.new extra_blocks = [] each_style_block do |stylesheet, block| if (elements = selector_elements(stylesheet, block)) style_map.add elements, block.properties else extra_blocks << block end end [style_map, extra_blocks] end def each_style_block stylesheets.each do |stylesheet| stylesheet.blocks.each do |block| yield stylesheet, block end end end def selector_elements(stylesheet, block) block.inlinable? && elements_matching_selector(stylesheet, block.selector) end def apply_style_map(style_map) style_map.each_element { |element, builder| apply_element_style(element, builder) } end def apply_element_style(element, builder) element["style"] = [builder.attribute_string, element["style"]].compact.join(";") end def elements_matching_selector(stylesheet, selector) dom.css(selector.to_s) # There's no way to get a list of supported pseudo selectors, so we're left # with having to rescue errors. # Pseudo selectors that are known to be bad are skipped automatically but # this will catch the rest. rescue Nokogiri::XML::XPath::SyntaxError, Nokogiri::CSS::SyntaxError Utils.warn( "Cannot inline #{selector.inspect} from \"#{stylesheet.name}\" " \ "stylesheet. If this is valid CSS, please report a bug." ) nil rescue => error Utils.warn( "Got error when looking for #{selector.inspect} " \ "(from \"#{stylesheet.name}\" stylesheet): #{error}" ) raise unless error.message.include?("XPath") nil end # Adds unlineable styles in the specified part of the document # either the head or in the document # @param [Symbol] parent Where to put the styles # @param [Array] blocks Non-inlineable style blocks # @param [Boolean] merge_media_queries Whether to group media queries def add_uninlinable_styles(parent, blocks, merge_media_queries) return if blocks.empty? parent_node = case parent when :head find_head when :root dom else raise ArgumentError, "Parent must be either :head or :root. Was #{parent.inspect}" end create_style_element(blocks, parent_node, merge_media_queries) end def find_head dom.at_xpath("html/head") end def create_style_element(style_blocks, parent, merge_media_queries) return unless parent element = Nokogiri::XML::Node.new("style", parent.document) element.content = if merge_media_queries styles_in_shared_media_queries(style_blocks).join("\n") else styles_in_individual_media_queries(style_blocks).join("\n") end parent.add_child(element) end # For performance reasons, we should group styles with the same media types within # one media query instead of creating thousands of media queries. # https://github.com/artifex404/media-queries-benchmark # # Example result: # # ```ruby # ["@media(max-width: 600px) { .col-12 { display: block; } }"] # ``` # # @param {Array} style_blocks Style blocks that could not be inlined # @return {Array} def styles_in_shared_media_queries(style_blocks) style_blocks.group_by(&:media).map do |media_types, blocks| css_rules = blocks.map(&:to_s).join("\n") if media_types == ["all"] css_rules else "@media #{media_types.join(", ")} {\n#{css_rules}\n}" end end end # Some users might prefer to not group rules within media queries because # it will result in rules getting reordered. # e.g. # # ```css # @media(max-width: 600px) { .col-6 { display: block; } } # @media(max-width: 400px) { .col-12 { display: inline-block; } } # @media(max-width: 600px) { .col-12 { display: block; } } # ```` # # will become # # ```css # @media(max-width: 600px) { .col-6 { display: block; } .col-12 { display: block; } } # @media(max-width: 400px) { .col-12 { display: inline-block; } } # ``` # # # which would change the styling on the page # (before it would've yielded display: block; for .col-12 at max-width: 600px # and now it yields inline-block;) # # If merge_media_queries is set to false, # we will generate `style_blocks.size` media queries, potentially # causing performance issues. # @param {Array} style_blocks All style blocks # @return {Array} def styles_in_individual_media_queries(style_blocks) style_blocks.map do |css_rule| if css_rule.media == ["all"] css_rule else "@media #{css_rule.media.join(", ")} {\n#{css_rule}\n}" end end end # @api private # StyleMap is a map between a DOM element and {StyleAttributeBuilder}. Basically, # it's an accumulator for properties, scoped on specific elements. class StyleMap def initialize @map = Hash.new do |hash, key| hash[key] = StyleAttributeBuilder.new end end def add(elements, new_properties) Array(elements).each do |element| new_properties.each do |property| @map[element] << property end end end def each_element(&block) @map.each_pair(&block) end end end end