lib/jekyll/commands/serve/servlet.rb
# frozen_string_literal: true require "webrick" module Jekyll module Commands class Serve # This class is used to determine if the Servlet should modify a served file # to insert the LiveReload script tags class SkipAnalyzer BAD_USER_AGENTS = [%r!MSIE!].freeze def self.skip_processing?(request, response, options) new(request, response, options).skip_processing? end def initialize(request, response, options) @options = options @request = request @response = response end def skip_processing? !html? || chunked? || inline? || bad_browser? end def chunked? @response["Transfer-Encoding"] == "chunked" end def inline? @response["Content-Disposition"].to_s.start_with?("inline") end def bad_browser? BAD_USER_AGENTS.any? { |pattern| pattern.match?(@request["User-Agent"]) } end def html? @response["Content-Type"].to_s.include?("text/html") end end # This class inserts the LiveReload script tags into HTML as it is served class BodyProcessor HEAD_TAG_REGEX = %r!<head>|<head[^(er)][^<]*>!.freeze attr_reader :content_length, :new_body, :livereload_added def initialize(body, options) @body = body @options = options @processed = false end def processed? @processed end # rubocop:disable Metrics/MethodLength def process! @new_body = [] # @body will usually be a File object but Strings occur in rare cases if @body.respond_to?(:each) begin @body.each { |line| @new_body << line.to_s } ensure @body.close end else @new_body = @body.lines end @content_length = 0 @livereload_added = false @new_body.each do |line| if !@livereload_added && line["<head"] line.gsub!(HEAD_TAG_REGEX) do |match| %(#{match}#{template.result(binding)}) end @livereload_added = true end @content_length += line.bytesize @processed = true end @new_body = @new_body.join end # rubocop:enable Metrics/MethodLength def template # Unclear what "snipver" does. Doc at # https://github.com/livereload/livereload-js states that the recommended # setting is 1. # Complicated JavaScript to ensure that livereload.js is loaded from the # same origin as the page. Mostly useful for dealing with the browser's # distinction between 'localhost' and 127.0.0.1 @template ||= ERB.new(<<~TEMPLATE) <script> document.write( '<script src="' + location.protocol + '//' + (location.host || 'localhost').split(':')[0] + ':<%=@options["livereload_port"] %>/livereload.js?snipver=1<%= livereload_args %>"' + '></' + 'script>'); </script> TEMPLATE end def livereload_args # XHTML standard requires ampersands to be encoded as entities when in # attributes. See http://stackoverflow.com/a/2190292 src = "" if @options["livereload_min_delay"] src += "&mindelay=#{@options["livereload_min_delay"]}" end if @options["livereload_max_delay"] src += "&maxdelay=#{@options["livereload_max_delay"]}" end src += "&port=#{@options["livereload_port"]}" if @options["livereload_port"] src end end class Servlet < WEBrick::HTTPServlet::FileHandler DEFAULTS = { "Cache-Control" => "private, max-age=0, proxy-revalidate, " \ "no-store, no-cache, must-revalidate", }.freeze def initialize(server, root, callbacks) # So we can access them easily. @jekyll_opts = server.config[:JekyllOptions] @mime_types_charset = server.config[:MimeTypesCharset] set_defaults super end def search_index_file(req, res) super || search_file(req, res, ".html") || search_file(req, res, ".xhtml") end # Add the ability to tap file.html the same way that Nginx does on our # Docker images (or on GitHub Pages.) The difference is that we might end # up with a different preference on which comes first. def search_file(req, res, basename) # /file.* > /file/index.html > /file.html super || super(req, res, "#{basename}.html") || super(req, res, "#{basename}.xhtml") end # rubocop:disable Naming/MethodName def do_GET(req, res) rtn = super if @jekyll_opts["livereload"] return rtn if SkipAnalyzer.skip_processing?(req, res, @jekyll_opts) processor = BodyProcessor.new(res.body, @jekyll_opts) processor.process! res.body = processor.new_body res.content_length = processor.content_length.to_s if processor.livereload_added # Add a header to indicate that the page content has been modified res["X-Rack-LiveReload"] = "1" end end conditionally_inject_charset(res) res.header.merge!(@headers) rtn end # rubocop:enable Naming/MethodName private # Inject charset based on Jekyll config only if our mime-types database contains # the charset metadata. # # Refer `script/vendor-mimes` in the repository for further details. def conditionally_inject_charset(res) typ = res.header["content-type"] return unless @mime_types_charset.key?(typ) return if %r!;\s*charset=!.match?(typ) res.header["content-type"] = "#{typ}; charset=#{@jekyll_opts["encoding"]}" end def set_defaults hash_ = @jekyll_opts.fetch("webrick", {}).fetch("headers", {}) DEFAULTS.each_with_object(@headers = hash_) do |(key, val), hash| hash[key] = val unless hash.key?(key) end end end end end end