lib/middleman-core/util/paths.rb



# Core Pathname library used for traversal
require 'pathname'
require 'uri'
require 'addressable/uri'
require 'memoist'
require 'tilt'

require 'middleman-core/contracts'

# rubocop:disable ModuleLength
module Middleman
  module Util
    extend Memoist
    include Contracts

    module_function

    Contract String => ::Addressable::URI
    def parse_uri(uri)
      ::Addressable::URI.parse(uri)
    end
    memoize :parse_uri

    Contract String => Any
    def tilt_class(path)
      ::Tilt[path]
    end
    memoize :tilt_class

    # Normalize a path to not include a leading slash
    # @param [String] path
    # @return [String]
    Contract String => String
    def normalize_path(path)
      # The tr call works around a bug in Ruby's Unicode handling
      ::URI.decode(path).sub(%r{^/}, '').tr('', '')
    end
    memoize :normalize_path

    # This is a separate method from normalize_path in case we
    # change how we normalize paths
    Contract String => String
    def strip_leading_slash(path)
      path.sub(%r{^/}, '')
    end
    memoize :strip_leading_slash

    IGNORE_DESCRIPTOR = Or[Regexp, RespondTo[:call], String]
    Contract IGNORE_DESCRIPTOR, String => Bool
    def should_ignore?(validator, value)
      if validator.is_a? Regexp
        # Treat as Regexp
        !!(value =~ validator)
      elsif validator.respond_to? :call
        # Treat as proc
        validator.call(value)
      elsif validator.is_a? String
        # Treat as glob
        File.fnmatch(value, validator)
      else
        # If some unknown thing, don't ignore
        false
      end
    end
    memoize :should_ignore?

    # Get the path of a file of a given type
    #
    # @param [Middleman::Application] app The app.
    # @param [Symbol] kind The type of file
    # @param [String, Symbol] source The path to the file
    # @param [Hash] options Data to pass through.
    # @return [String]
    Contract ::Middleman::Application, Symbol, Or[String, Symbol], Hash => String
    def asset_path(app, kind, source, options={})
      return source if source.to_s.include?('//') || source.to_s.start_with?('data:')

      asset_folder = case kind
      when :css
        app.config[:css_dir]
      when :js
        app.config[:js_dir]
      when :images
        app.config[:images_dir]
      when :fonts
        app.config[:fonts_dir]
      else
        kind.to_s
      end

      source = source.to_s.tr(' ', '')
      ignore_extension = (kind == :images || kind == :fonts) # don't append extension
      source << ".#{kind}" unless ignore_extension || source.end_with?(".#{kind}")
      asset_folder = '' if source.start_with?('/') # absolute path

      asset_url(app, source, asset_folder, options)
    end

    # Get the URL of an asset given a type/prefix
    #
    # @param [String] path The path (such as "photo.jpg")
    # @param [String] prefix The type prefix (such as "images")
    # @param [Hash] options Data to pass through.
    # @return [String] The fully qualified asset url
    Contract ::Middleman::Application, String, String, Hash => String
    def asset_url(app, path, prefix='', options={})
      # Don't touch assets which already have a full path
      return path if path.include?('//') || path.start_with?('data:')

      if options[:relative] && !options[:current_resource]
        raise ArgumentError, '#asset_url must be run in a context with current_resource if relative: true'
      end

      uri = ::Middleman::Util.parse_uri(path)
      path = uri.path

      # Ensure the url we pass into find_resource_by_destination_path is not a
      # relative path, since it only takes absolute url paths.
      dest_path = url_for(app, path, options.merge(relative: false))

      result = if resource = app.sitemap.find_resource_by_path(dest_path)
        resource.url
      elsif resource = app.sitemap.find_resource_by_destination_path(dest_path)
        resource.url
      else
        path = ::File.join(prefix, path)
        if resource = app.sitemap.find_resource_by_path(path)
          resource.url
        else
          ::File.join(app.config[:http_prefix], path)
        end
      end

      final_result = ::Addressable::URI.encode(
        relative_path_from_resource(
          options[:current_resource],
          result,
          options[:relative]
        )
      )

      result_uri = ::Middleman::Util.parse_uri(final_result)
      result_uri.query = uri.query
      result_uri.fragment = uri.fragment
      result_uri.to_s
    end

    # Given a source path (referenced either absolutely or relatively)
    # or a Resource, this will produce the nice URL configured for that
    # path, respecting :relative_links, directory indexes, etc.
    Contract ::Middleman::Application, Or[String, Symbol, ::Middleman::Sitemap::Resource], Hash => String
    def url_for(app, path_or_resource, options={})
      if path_or_resource.is_a?(String) || path_or_resource.is_a?(Symbol)
        r = app.sitemap.find_resource_by_page_id(path_or_resource)

        path_or_resource = r ? r : path_or_resource.to_s
      end

      # Handle Resources and other things which define their own url method
      url = if path_or_resource.respond_to?(:url)
        path_or_resource.url
      else
        path_or_resource.dup
      end

      # Try to parse URL
      begin
        uri = ::Middleman::Util.parse_uri(url)
      rescue ::Addressable::URI::InvalidURIError
        # Nothing we can do with it, it's not really a URI
        return url
      end

      relative = options[:relative]
      raise "Can't use the relative option with an external URL" if relative && uri.host

      # Allow people to turn on relative paths for all links with
      # set :relative_links, true
      # but still override on a case by case basis with the :relative parameter.
      effective_relative = relative || false
      effective_relative = true if relative.nil? && app.config[:relative_links]

      # Try to find a sitemap resource corresponding to the desired path
      this_resource = options[:current_resource]

      if path_or_resource.is_a?(::Middleman::Sitemap::Resource)
        resource = path_or_resource
        resource_url = url
      elsif this_resource && uri.path && !uri.host
        # Handle relative urls
        url_path = Pathname(uri.path)
        current_source_dir = Pathname('/' + this_resource.path).dirname
        url_path = current_source_dir.join(url_path) if url_path.relative?
        resource = app.sitemap.find_resource_by_path(url_path.to_s)
        if resource
          resource_url = resource.url
        else
          # Try to find a resource relative to destination paths
          url_path = Pathname(uri.path)
          current_source_dir = Pathname('/' + this_resource.destination_path).dirname
          url_path = current_source_dir.join(url_path) if url_path.relative?
          resource = app.sitemap.find_resource_by_destination_path(url_path.to_s)
          resource_url = resource.url if resource
        end
      elsif options[:find_resource] && uri.path && !uri.host
        resource = app.sitemap.find_resource_by_path(uri.path)
        resource_url = resource.url if resource
      end

      if resource
        uri.path = if this_resource
          ::Addressable::URI.encode(
            relative_path_from_resource(
              this_resource,
              resource_url,
              effective_relative
            )
          )
        else
          resource_url
        end
      end

      # Support a :query option that can be a string or hash
      if query = options[:query]
        uri.query = query.respond_to?(:to_param) ? query.to_param : query.to_s
      end

      # Support a :fragment or :anchor option just like Padrino
      fragment = options[:anchor] || options[:fragment]
      uri.fragment = fragment.to_s if fragment

      # Finally make the URL back into a string
      uri.to_s
    end

    # Expand a path to include the index file if it's a directory
    #
    # @param [String] path Request path/
    # @param [Middleman::Application] app The requesting app.
    # @return [String] Path with index file if necessary.
    Contract String, ::Middleman::Application => String
    def full_path(path, app)
      resource = app.sitemap.find_resource_by_destination_path(path)

      unless resource
        # Try it with /index.html at the end
        indexed_path = ::File.join(path.sub(%r{/$}, ''), app.config[:index_file])
        resource = app.sitemap.find_resource_by_destination_path(indexed_path)
      end

      if resource
        '/' + resource.destination_path
      else
        '/' + normalize_path(path)
      end
    end

    # Get a relative path to a resource.
    #
    # @param [Middleman::Sitemap::Resource] curr_resource The resource.
    # @param [String] resource_url The target url.
    # @param [Boolean] relative If the path should be relative.
    # @return [String]
    Contract ::Middleman::Sitemap::Resource, String, Bool => String
    def relative_path_from_resource(curr_resource, resource_url, relative)
      # Switch to the relative path between resource and the given resource
      # if we've been asked to.
      if relative
        # Output urls relative to the destination path, not the source path
        current_dir = Pathname('/' + curr_resource.destination_path).dirname
        relative_path = Pathname(resource_url).relative_path_from(current_dir).to_s

        # Put back the trailing slash to avoid unnecessary Apache redirects
        if resource_url.end_with?('/') && !relative_path.end_with?('/')
          relative_path << '/'
        end

        relative_path
      else
        resource_url
      end
    end

    # Takes a matcher, which can be a literal string
    # or a string containing glob expressions, or a
    # regexp, or a proc, or anything else that responds
    # to #match or #call, and returns whether or not the
    # given path matches that matcher.
    #
    # @param [String, #match, #call] matcher A matcher String, RegExp, Proc, etc.
    # @param [String] path A path as a string
    # @return [Boolean] Whether the path matches the matcher
    Contract PATH_MATCHER, String => Bool
    def path_match(matcher, path)
      if matcher.is_a?(String)
        if matcher.include? '*'
          ::File.fnmatch(matcher, path)
        else
          path == matcher
        end
      elsif matcher.respond_to?(:match)
        !!(path =~ matcher)
      elsif matcher.respond_to?(:call)
        matcher.call(path)
      else
        ::File.fnmatch(matcher.to_s, path)
      end
    end
    memoize :path_match
  end
end