hooktstudios/sinatra-export

View on GitHub
lib/sinatra/export.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'sinatra/base'
require 'sinatra/advanced_routes'
require 'rack/test'
require 'term/ansicolor'
require 'pathname'

module Sinatra

  # Export a Sinatra app to static files!
  module Export

    # required for all Sinatra Extensions, see http://www.sinatrarb.com/extensions.html
    def self.registered(app)
      if app.extensions.nil? or !app.extensions.include?(Sinatra::AdvancedRoutes)
        app.register Sinatra::AdvancedRoutes
      end
      app.set :export_extensions, %w(css js xml json html csv)
      app.extend ClassMethods
      app.set :builder, nil
    end

    # These will get extended onto the Sinatra app
    module ClassMethods

      # The entry method. Run this to export the app to files.
      # @example
      #   # The default: Will use the paths from Sinatra Namespace
      #   app.export!
      #
      #   # Skip a path (or paths)
      #   app.export! skips: ["/admin"]
      #
      #   # Only visit the homepage and the site map
      #   app.export! paths: ["/", "/site-map"]
      #
      #   # Visit the 404 error page by supplying the expected
      #   # status code (so as not to trigger an error)
      #   app.export! paths: ["/", ["/404.html",404]]
      #
      #   # Filter out mentions of localhost:4567
      #   filter = ->(content){ content.gsub("localhost:4567, "example.org") }
      #   app.export! filters: [filter]
      #
      #   # Use routes found by Sinatra AdvancedRoutes *and*
      #   # ones supplied via `paths`
      #   app.export! paths: ["/crazy/deep/page/path"], use_routes: true
      #
      #   # Supply a path and scan the output for an internal link
      #   # adding it to the list of paths to be visited
      #   app.export! paths: "/" do |builder|
      #     if builder.last_response.body.include? "/echo-1"
      #       builder.paths << "/echo-1"
      #     end
      #   end
      #
      # @param [Array<String>,Array<URI>] paths Paths that will be requested by the exporter.
      # @param [Array<String>] skips: Paths that will be ignored by the exporter.
      # @param [TrueClass] use_routes Whether to use Sinatra AdvancedRoutes to look for paths to send to the builder.
      # @param [Array<#call>] filters Filters will be applied to every file as it is written in the order given.
      # @param [#call] error_handler Define your own error handling. Takes one argument, a description of the error.
      # @yield [builder] Gives a Builder instance to the block (see Builder) that is called for every path visited.
      # @note By default the output files with be written to the public folder. Set the EXPORT_BUILD_DIR env var to choose a different location.
      def export! paths: nil, skips: [], filters: [], use_routes: nil, error_handler: nil,  &block
        @builder ||= 
          if self.builder
            self.builder
          else
            Builder.new(self, paths: paths, skips: skips, filters: filters, use_routes: use_routes, error_handler: error_handler )
          end
        @builder.build! &block
      end
    end


    # Visits the paths and builds pages from the output
    class Builder
      include Rack::Test::Methods

      # Default error handler
      # @yieldparam [String] desc Description of the error.
      DEFAULT_ERROR_HANDLER = ->(desc) {
        puts ColorString.new("failed: #{desc}").red;
      }

      class ColorString < String
        include Term::ANSIColor
      end


      # @param [Sinatra::Base] app The Sinatra app
      # @param (see ClassMethods#export!)
      # @yield [builder] Gives a Builder instance to the block (see Builder) that is called for every path visited.
      def initialize(app, paths: nil, skips: nil, use_routes: nil, filters: [], error_handler: nil )
        @app = app
        @use_routes = paths.nil? && use_routes.nil? ? true : use_routes
        @paths  = paths || []
        @skips  = skips || []
        @enums  = []
        @filters  = filters
        @visited  = []
        @errored  = []
        @error_handler = error_handler || DEFAULT_ERROR_HANDLER
      end

      # @!attribute [r] last_response
      #   @return [Rack::MockResponse] The last page requested's response
      attr_reader :last_response

      # @!attribute [r] last_path
      #   @return [String] The last path requested
      attr_reader :last_path

      # @!attribute [r] visited
      #   @return [Array<String>] List of paths visited by the builder
      attr_reader :visited

      # @!attribute [r] errored
      #   @return [Array<String>] List of paths visited by the builder that called the error handler
      attr_reader :errored

      # @!attribute [w] error_handler
      # Error handler (see ClassMethods#export!)
      #   @return [nil]
      attr_writer :error_handler

      # @!attribute paths
      # Paths to visit (see ClassMethods#export!)
      #   @return [Array<String,URI>]
      attr_accessor :paths

      # @!attribute skips
      # Paths to be skipped (see ClassMethods#export!)
      #   @return [Array<String,URI>]
      attr_accessor :skips


      def app
        @app
      end


      # Processes the routes and builds the output files.
      # @yield [builder] Gives a Builder instance to the block (see Builder) that is called for every path visited.
      # @return [self]
      def build!(&block)
        dir = Pathname( ENV["EXPORT_BUILD_DIR"] || app.public_folder )
        handle_error_dir_not_found!(dir) unless dir.exist? && dir.directory?

        if @use_routes
          @enums.push self.send(:route_paths).to_enum
        end
        @enums.push @paths.to_enum

        catch(:no_more_paths) do
          enum = @enums.shift
          while true
            begin
              @last_path, status = enum.next
              @last_path = @last_path.respond_to?(:path) ?
                            @last_path.path :
                            @last_path.to_s
              next unless route_path_usable?(@last_path)
              next if @skips.include? @last_path
              @last_path = @last_path.chop if @last_path.end_with? "?"
              desc = catch(:status_error) {
                @last_response = get_path(@last_path, status)
                block.call self if block
                file_path = build_path(path: @last_path, dir: dir, response: last_response)
                nil
              }
              desc ?
                @errored |= [@last_path] :
                @visited |= [@last_path]
            rescue StopIteration
              retry if enum = @enums.shift
              throw(:no_more_paths)
            end
          end
        end
        self
      end

      private

        # a convenience wrapper for throwing status errors
        def status_error desc
          throw :status_error, desc
        end

        # A convenience method to keep this logic together
        # and reusable
        # @param [String,Regexp] path
        # @return [TrueClass] Whether the path is a straightforward path (i.e. usable) or it's a regex or path with named captures / wildcards (i.e. unusable).
        def route_path_usable? path
          res = path.respond_to?( :~ )  ||  # skip regex
                path =~ /(?:\:\w+)|\*/  ||  # keys and splats
                path =~ /[\%\\]/        ||  # special chars
                path[0..-2].include?("?") # an ending ? is acceptable, it'll be chomped
          !res
        end


        # Wrapper around Sinatra::AdvancedRoutes#each_route
        # to filter what comes through.
        # @return [Array<String>]
        def route_paths
          route_paths = []
          app.each_route do |route|
            next if route.verb != 'GET'
            next unless route_path_usable?(route.path)
            route_paths << route.path
          end
          route_paths
        end


        # Builds the output dirs and file
        # based on the response.
        # @param [String] path
        # @param [Pathname,String] dir
        # @param [Rack::MockResponse] response
        # @return [String] file_path
        def build_path(path: nil, dir: nil, response: nil)
          # These argument checks are for Ruby v2.0 as it
          # doesn't support required keyword args.
          fail ArgumentError, "'path' is a required argument to build_path" if path.nil?
          fail ArgumentError, "'dir' is a required argument to build_path" if dir.nil?
          fail ArgumentError, "'response' is a required argument to build_path" if response.nil?

          body = response.body
          mtime = response.headers.key?("Last-Modified") ?
            Time.httpdate(response.headers["Last-Modified"]) : Time.now

          pattern = %r{
            [^/\.]+
            \.
            (
              #{app.settings.export_extensions.join("|")}
            )
          $}x
          file_path = Pathname( File.join dir, path )
          file_path = file_path.join( 'index.html' ) unless  path.match(pattern)
          ::FileUtils.mkdir_p( file_path.dirname )
          write_path content: body, path: file_path
          ::FileUtils.touch(file_path, :mtime => mtime)
          file_path
        end


        # Write the response to file.
        # Uses whatever filters were set, on the content.
        # @param [String] content
        # @param [Pathname,String] path
        def write_path( content: nil, path: nil )
          # These argument checks are for Ruby v2.0 as it
          # doesn't support required keyword args.
          fail ArgumentError, "'content' is a required argument to write_path" if content.nil?
          fail ArgumentError, "'path' is a required argument to write_path" if path.nil?

          if @filters && !@filters.empty?
            content = @filters.inject(content) do |current_content,filter|
              filter.call current_content
            end
          end
          ::File.open(path, 'w+') do |f|
            f.write(content)
          end
        end


        # Wrapper around Rack::Test's `get`
        # @param [String] path
        # @param [Integer] status The expected response status code. Anything different and the error handler is called. Defaults to 200.
        # @return [Rack::MockResponse]
        def get_path(path, status=nil)
          status ||= 200
          get(path).tap do |resp|
            handle_error_incorrect_status!(path,expected: status, actual: resp.status) unless resp.status == status
          end
        end


        def handle_error_dir_not_found!(dir)
          @error_handler.call("can't find output directory: #{dir.to_s}")
        end


        # Handles the error caused by a mismatch in status code expectations.
        # @param [String] path The route path.
        # @param [#to_s] expected The status code that was expected.
        # @param [#to_s] actual The actual status code received.
        def handle_error_incorrect_status!(path, expected: nil,actual: nil)
          # These argument checks are for Ruby v2.0 as it
          # doesn't support required keyword args.
          fail ArgumentError, "'expected' is a required argument to handle_error_incorrect_status!" if expected.nil?
          fail ArgumentError, "'actual' is a required argument to handle_error_incorrect_status!" if actual.nil?

          desc = "GET #{path} returned #{actual} status code instead of #{expected}"
          @error_handler.call(desc)
          status_error desc
        end
    end
  end

  register Sinatra::Export
end