Mange / roadie
1
# frozen_string_literal: true
2

3 5
require 'set'
4 5
require 'nokogiri'
5 5
require 'uri'
6 5
require 'css_parser'
7

8 5
module Roadie
9
  # @api private
10
  # The Inliner inlines stylesheets to the elements of the DOM.
11
  #
12
  # Inlining means that {StyleBlock}s and a DOM tree are combined:
13
  #   a { color: red; } # StyleBlock
14
  #   <a href="/"></a>  # DOM
15
  #
16
  # becomes
17
  #
18
  #   <a href="/" style="color:red"></a>
19 5
  class Inliner
20
    # @param [Array<Stylesheet>] stylesheets the stylesheets to use in the inlining
21
    # @param [Nokogiri::HTML::Document] dom
22 5
    def initialize(stylesheets, dom)
23 5
      @stylesheets = stylesheets
24 5
      @dom = dom
25
    end
26

27
    # Start the inlining, mutating the DOM tree.
28
    #
29
    # @option options [true, false] :keep_uninlinable_css
30
    # @option options [:root, :head] :keep_uninlinable_in
31
    # @option options [true, false] :merge_media_queries
32
    # @return [nil]
33 5
    def inline(options = {})
34 5
      keep_uninlinable_css = options.fetch(:keep_uninlinable_css, true)
35 5
      keep_uninlinable_in = options.fetch(:keep_uninlinable_in, :head)
36 5
      merge_media_queries = options.fetch(:merge_media_queries, true)
37

38 5
      style_map, extra_blocks = consume_stylesheets
39

40 5
      apply_style_map(style_map)
41

42 5
      if keep_uninlinable_css
43 5
        add_uninlinable_styles(keep_uninlinable_in, extra_blocks, merge_media_queries)
44
      end
45

46 1
      nil
47
    end
48

49 5
    protected
50 5
    attr_reader :stylesheets, :dom
51

52 5
    private
53 5
    def consume_stylesheets
54 5
      style_map = StyleMap.new
55 5
      extra_blocks = []
56

57 5
      each_style_block do |stylesheet, block|
58 5
        if (elements = selector_elements(stylesheet, block))
59 5
          style_map.add elements, block.properties
60
        else
61 5
          extra_blocks << block
62
        end
63
      end
64

65 5
      [style_map, extra_blocks]
66
    end
67

68 5
    def each_style_block
69 5
      stylesheets.each do |stylesheet|
70 5
        stylesheet.blocks.each do |block|
71 5
          yield stylesheet, block
72
        end
73
      end
74
    end
75

76 5
    def selector_elements(stylesheet, block)
77 5
      block.inlinable? && elements_matching_selector(stylesheet, block.selector)
78
    end
79

80 5
    def apply_style_map(style_map)
81 5
      style_map.each_element { |element, builder| apply_element_style(element, builder) }
82
    end
83

84 5
    def apply_element_style(element, builder)
85 5
      element["style"] = [builder.attribute_string, element["style"]].compact.join(";")
86
    end
87

88 5
    def elements_matching_selector(stylesheet, selector)
89 5
      dom.css(selector.to_s)
90
    # There's no way to get a list of supported pseudo selectors, so we're left
91
    # with having to rescue errors.
92
    # Pseudo selectors that are known to be bad are skipped automatically but
93
    # this will catch the rest.
94
    rescue Nokogiri::XML::XPath::SyntaxError, Nokogiri::CSS::SyntaxError => error
95 5
      Utils.warn "Cannot inline #{selector.inspect} from \"#{stylesheet.name}\" stylesheet. If this is valid CSS, please report a bug."
96 5
      nil
97
    rescue => error
98 4
      Utils.warn "Got error when looking for #{selector.inspect} (from \"#{stylesheet.name}\" stylesheet): #{error}"
99 4
      raise unless error.message.include?('XPath')
100 4
      nil
101
    end
102

103
    # Adds unlineable styles in the specified part of the document
104
    # either the head or in the document
105
    # @param [Symbol] parent  Where to put the styles
106
    # @param [Array<StyleBlock>] blocks  Non-inlineable style blocks
107
    # @param [Boolean]  merge_media_queries  Whether to group media queries
108 5
    def add_uninlinable_styles(parent, blocks, merge_media_queries)
109 5
      return if blocks.empty?
110

111 3
      parent_node =
112 2
        case parent
113
        when :head
114 5
          find_head
115
        when :root
116 5
          dom
117
        else
118 5
          raise ArgumentError, "Parent must be either :head or :root. Was #{parent.inspect}"
119
        end
120

121 5
      create_style_element(blocks, parent_node, merge_media_queries)
122
    end
123

124 5
    def find_head
125 5
      dom.at_xpath('html/head')
126
    end
127

128 5
    def create_style_element(style_blocks, parent, merge_media_queries)
129 5
      return unless parent
130 5
      element = Nokogiri::XML::Node.new('style', parent.document)
131

132 4
      element.content =
133 5
        if merge_media_queries
134 5
          styles_in_shared_media_queries(style_blocks).join("\n")
135
        else
136 5
          styles_in_individual_media_queries(style_blocks).join("\n")
137
        end
138 5
      parent.add_child(element)
139
    end
140

141
    # For performance reasons, we should group styles with the same media types within
142
    # one media query instead of creating thousands of media queries.
143
    # https://github.com/artifex404/media-queries-benchmark
144
    # Example result: ["@media(max-width: 600px) { .col-12 { display: block; } }"]
145
    # @param {Array<StyleBlock>} style_blocks  Style blocks that could not be inlined
146
    # @return {Array<String>}
147 5
    def styles_in_shared_media_queries(style_blocks)
148 5
      style_blocks.group_by(&:media).map do |media_types, blocks|
149 5
        css_rules = blocks.map(&:to_s).join("\n")
150

151 5
        if media_types == ['all']
152 5
          css_rules
153
        else
154 5
          "@media #{media_types.join(', ')} {\n#{css_rules}\n}"
155
        end
156
      end
157
    end
158

159
    # Some users might prefer to not group rules within media queries because
160
    # it will result in rules getting reordered.
161
    # e.g.
162
    # @media(max-width: 600px) { .col-6 { display: block; } }
163
    # @media(max-width: 400px) { .col-12 { display: inline-block; } }
164
    # @media(max-width: 600px) { .col-12 { display: block; } }
165
    # will become
166
    # @media(max-width: 600px) { .col-6 { display: block; } .col-12 { display: block; } }
167
    # @media(max-width: 400px) { .col-12 { display: inline-block; } }
168
    # which would change the styling on the page
169
    # (before it would've yielded display: block; for .col-12 at max-width: 600px
170
    # and now it yields inline-block;)
171
    #
172
    # If merge_media_queries is set to false,
173
    # we will generate #{style_blocks.size} media queries, potentially
174
    # causing performance issues.
175
    # @param {Array<StyleBlock>} style_blocks  All style blocks
176
    # @return {Array<String>}
177 5
    def styles_in_individual_media_queries(style_blocks)
178 5
      style_blocks.map do |css_rule|
179 5
        if css_rule.media == ['all']
180 0
          css_rule
181
        else
182 5
          "@media #{css_rule.media.join(', ')} {\n#{css_rule}\n}"
183
        end
184
      end
185
    end
186

187
    # @api private
188
    # StyleMap is a map between a DOM element and {StyleAttributeBuilder}. Basically,
189
    # it's an accumulator for properties, scoped on specific elements.
190 5
    class StyleMap
191 5
      def initialize
192 5
        @map = Hash.new do |hash, key|
193 5
          hash[key] = StyleAttributeBuilder.new
194
        end
195
      end
196

197 5
      def add(elements, new_properties)
198 5
        Array(elements).each do |element|
199 5
          new_properties.each do |property|
200 5
            @map[element] << property
201
          end
202
        end
203
      end
204

205 5
      def each_element(&block)
206 5
        @map.each_pair(&block)
207
      end
208
    end
209
  end
210
end

Read our documentation on viewing source code .

Loading