lib/middleman-core/util/paths.rb



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

require 'middleman-core/contracts'

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 Any => String
    def normalize_path(path)
      return path unless path.is_a?(String)

      # 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
        !validator.match(value).nil?
      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?

    IGNORED_ASSET_EXTENSIONS = Set.new %i[images fonts]

    # 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_hash = ::Middleman::EMPTY_HASH)
      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 = IGNORED_ASSET_EXTENSIONS.include? kind # 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_hash)
    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_hash = ::Middleman::EMPTY_HASH)
      # Don't touch assets which already have a full path
      return path if path.include?('//') || path.start_with?('data:')

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

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

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

      resource = app.sitemap.by_path(dest_path) || app.sitemap.by_destination_path(dest_path)

      result = if resource
                 resource.url
               else
                 path = ::File.join(prefix, path)
                 resource = app.sitemap.by_path(path)

                 if resource
                   resource.url
                 else
                   ::File.join(app.config[:http_prefix], path)
                 end
               end

      final_result = ::Addressable::URI.encode(
        relative_path_from_resource(
          options_hash[:current_resource],
          result,
          options_hash[: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_hash = ::Middleman::EMPTY_HASH)
      if path_or_resource.is_a?(String) || path_or_resource.is_a?(Symbol)
        r = app.sitemap.by_page_id(path_or_resource)

        path_or_resource = 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_hash[: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_hash[: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.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.by_destination_path(url_path.to_s)
          resource_url = resource.url if resource
        end
      elsif options_hash[:find_resource] && uri.path && !uri.host
        resource = app.sitemap.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
      query = options_hash[:query]

      if 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_hash[:anchor] || options_hash[: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.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.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
        relative_path << '/' if resource_url.end_with?('/') && !relative_path.end_with?('/')

        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)
        !matcher.match(path).nil?
      elsif matcher.respond_to?(:call)
        matcher.call(path)
      else
        ::File.fnmatch(matcher.to_s, path)
      end
    end
    memoize :path_match
  end
end