Mange / roadie
1
# frozen_string_literal: true
2

3 5
module Roadie
4
  # The main entry point for Roadie. A document represents a working unit and
5
  # is built with the input HTML and the configuration options you need.
6
  #
7
  # A Document must never be used from two threads at the same time. Reusing
8
  # Documents is discouraged.
9
  #
10
  # Stylesheets are added to the HTML from three different sources:
11
  # 1. Stylesheets inside the document ( +<style>+ elements)
12
  # 2. Stylesheets referenced by the DOM ( +<link>+ elements)
13
  # 3. The internal stylesheet (see {#add_css})
14
  #
15
  # The internal stylesheet is used last and gets the highest priority. The
16
  # rest is used in the same order as browsers are supposed to use them.
17
  #
18
  # The execution methods are {#transform} and {#transform_partial}.
19
  #
20
  # @attr [#call] before_transformation Callback to call just before {#transform}ation begins. Will be called with the parsed DOM tree and the {Document} instance.
21
  # @attr [#call] after_transformation Callback to call just before {#transform}ation is completed. Will be called with the current DOM tree and the {Document} instance.
22 5
  class Document
23 5
    attr_reader :html, :asset_providers, :external_asset_providers
24

25
    # URL options. If none are given no URL rewriting will take place.
26
    # @see UrlGenerator#initialize
27 5
    attr_accessor :url_options
28

29 5
    attr_accessor :before_transformation, :after_transformation
30

31
    # Should CSS that cannot be inlined be kept in a new `<style>` element in `<head>`?
32 5
    attr_accessor :keep_uninlinable_css
33

34
    # Merge media queries to increase performance and reduce email size if enabled.
35
    # This will change specificity in some cases, like for example:
36
    #   @media(max-width: 600px) { .col-6 { display: block; } }
37
    #   @media(max-width: 400px) { .col-12 { display: inline-block; } }
38
    #   @media(max-width: 600px) { .col-12 { display: block; } }
39
    # will become
40
    #   @media(max-width: 600px) { .col-6 { display: block; } .col-12 { display: block; } }
41
    #   @media(max-width: 400px) { .col-12 { display: inline-block; } }
42
    # which would change the styling on the page
43 5
    attr_accessor :merge_media_queries
44

45
    # The mode to generate markup in. Valid values are `:html` (default) and `:xhtml`.
46 5
    attr_reader :mode
47

48
    # @param [String] html the input HTML
49 5
    def initialize(html)
50 5
      @keep_uninlinable_css = true
51 5
      @merge_media_queries = true
52 5
      @html = html
53 5
      @asset_providers = ProviderList.wrap(FilesystemProvider.new)
54 5
      @external_asset_providers = ProviderList.empty
55 5
      @css = +""
56 5
      @mode = :html
57
    end
58

59
    # Append additional CSS to the document's internal stylesheet.
60
    # @param [String] new_css
61 5
    def add_css(new_css)
62 5
      @css << "\n\n" << new_css
63
    end
64

65
    # Transform the input HTML as a full document and returns the processed
66
    # HTML.
67
    #
68
    # Before the transformation begins, the {#before_transformation} callback
69
    # will be called with the parsed HTML tree and the {Document} instance, and
70
    # after all work is complete the {#after_transformation} callback will be
71
    # invoked in the same way.
72
    #
73
    # Most of the work is delegated to other classes. A list of them can be
74
    # seen below.
75
    #
76
    # @see MarkupImprover MarkupImprover (improves the markup of the DOM)
77
    # @see Inliner Inliner (inlines the stylesheets)
78
    # @see UrlRewriter UrlRewriter (rewrites URLs and makes them absolute)
79
    # @see #transform_partial Transforms partial documents (fragments)
80
    #
81
    # @return [String] the transformed HTML
82 5
    def transform
83 5
      dom = Nokogiri::HTML.parse html
84

85 5
      callback before_transformation, dom
86

87 5
      improve dom
88 5
      inline dom, keep_uninlinable_in: :head
89 5
      rewrite_urls dom
90

91 5
      callback after_transformation, dom
92

93 5
      remove_ignore_markers dom
94 5
      serialize_document dom
95
    end
96

97
    # Transform the input HTML as a HTML fragment/partial and returns the
98
    # processed HTML.
99
    #
100
    # Before the transformation begins, the {#before_transformation} callback
101
    # will be called with the parsed HTML tree and the {Document} instance, and
102
    # after all work is complete the {#after_transformation} callback will be
103
    # invoked in the same way.
104
    #
105
    # The main difference between this and {#transform} is that this does not
106
    # treat the HTML as a full document and does not try to fix it by adding
107
    # doctypes, {<head>} elements, etc.
108
    #
109
    # Most of the work is delegated to other classes. A list of them can be
110
    # seen below.
111
    #
112
    # @see Inliner Inliner (inlines the stylesheets)
113
    # @see UrlRewriter UrlRewriter (rewrites URLs and makes them absolute)
114
    # @see #transform Transforms full documents
115
    #
116
    # @return [String] the transformed HTML
117 5
    def transform_partial
118 5
      dom = Nokogiri::HTML.fragment html
119

120 5
      callback before_transformation, dom
121

122 5
      inline dom, keep_uninlinable_in: :root
123 5
      rewrite_urls dom
124

125 5
      callback after_transformation, dom
126

127 5
      serialize_document dom
128
    end
129

130
    # Assign new normal asset providers. The supplied list will be wrapped in a {ProviderList} using {ProviderList.wrap}.
131 5
    def asset_providers=(list)
132 5
      @asset_providers = ProviderList.wrap(list)
133
    end
134

135
    # Assign new external asset providers. The supplied list will be wrapped in a {ProviderList} using {ProviderList.wrap}.
136 5
    def external_asset_providers=(list)
137 5
      @external_asset_providers = ProviderList.wrap(list)
138
    end
139

140
    # Change the mode. The mode affects how the resulting markup is generated.
141
    #
142
    # Valid modes:
143
    #   `:html` (default)
144
    #   `:xhtml`
145 5
    def mode=(mode)
146 5
      if VALID_MODES.include?(mode)
147 5
        @mode = mode
148
      else
149 5
        raise ArgumentError, "Invalid mode #{mode.inspect}. Valid modes are: #{VALID_MODES.inspect}"
150
      end
151
    end
152

153 5
    private
154 5
    VALID_MODES = %i[html xhtml].freeze
155 5
    private_constant :VALID_MODES
156

157 5
    def stylesheet
158 5
      Stylesheet.new "(Document styles)", @css
159
    end
160

161 5
    def improve(dom)
162 5
      MarkupImprover.new(dom, html).improve
163
    end
164

165 5
    def inline(dom, options = {})
166 5
      keep_uninlinable_in = options.fetch(:keep_uninlinable_in)
167 5
      dom_stylesheets = AssetScanner.new(dom, asset_providers, external_asset_providers).extract_css
168 5
      Inliner.new(dom_stylesheets + [stylesheet], dom).inline(
169 1
        keep_uninlinable_css: keep_uninlinable_css,
170
        keep_uninlinable_in: keep_uninlinable_in,
171 1
        merge_media_queries: merge_media_queries,
172
      )
173
    end
174

175 5
    def rewrite_urls(dom)
176 5
      make_url_rewriter.transform_dom(dom)
177
    end
178

179 5
    def serialize_document(dom)
180
      # #dup is called since it fixed a few segfaults in certain versions of Nokogiri
181 5
      save_options = Nokogiri::XML::Node::SaveOptions
182 2
      format = {
183 3
        html: save_options::AS_HTML,
184
        xhtml: save_options::AS_XHTML,
185 1
      }.fetch(mode)
186

187 5
      dom.dup.to_html(
188 1
        save_with: (
189 3
          save_options::NO_DECLARATION |
190 1
          save_options::NO_EMPTY_TAGS |
191 1
          format
192
        ),
193
      )
194
    end
195

196 5
    def make_url_rewriter
197 5
      if url_options
198 5
        UrlRewriter.new(UrlGenerator.new(url_options))
199
      else
200 5
        NullUrlRewriter.new
201
      end
202
    end
203

204 5
    def callback(callable, dom)
205 5
      if callable.respond_to?(:call)
206
        # Arity checking is to support the API without bumping a major version.
207
        # TODO: Remove on next major version (v4.0)
208 5
        if !callable.respond_to?(:parameters) || callable.parameters.size == 1
209 5
          callable.(dom)
210
        else
211 5
          callable.(dom, self)
212
        end
213
      end
214
    end
215

216 5
    def remove_ignore_markers(dom)
217 5
      dom.css("[data-roadie-ignore]").each do |node|
218 5
        node.remove_attribute "data-roadie-ignore"
219
      end
220
    end
221
  end
222
end

Read our documentation on viewing source code .

Loading