diff --git a/Gemfile b/Gemfile index 81bd9e5e..288cd699 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ gem 'fivemat', '~> 1.3.1' gem 'aruba', '~> 0.6.0' gem 'rspec', '~> 3.0' gem 'simplecov' +gem 'contracts', require: false # Optional middleman dependencies, included for tests gem 'sinatra', require: false diff --git a/middleman-core/lib/middleman-core/contracts.rb b/middleman-core/lib/middleman-core/contracts.rb new file mode 100644 index 00000000..3eec15d2 --- /dev/null +++ b/middleman-core/lib/middleman-core/contracts.rb @@ -0,0 +1,95 @@ +if ENV['TEST'] || ENV['CONTRACTS'] == 'true' + require 'contracts' + + class IsA + def self.[](val) + @lookup ||= {} + @lookup[val] ||= new(val) + end + + def initialize(val) + @val = val + end + + def valid?(val) + val.is_a? @val.constantize + end + end + + ResourceList = Contracts::ArrayOf[IsA['Middleman::Sitemap::Resource']] +else + module Contracts + def self.included(base) + base.extend self + end + + # rubocop:disable MethodName + def Contract(*) + end + + class Callable + def self.[](*) + end + end + + class Bool + end + + class Num + end + + class Pos + end + + class Neg + end + + class Any + end + + class None + end + + class Or < Callable + end + + class Xor < Callable + end + + class And < Callable + end + + class Not < Callable + end + + class RespondTo < Callable + end + + class Send < Callable + end + + class Exactly < Callable + end + + class ArrayOf < Callable + end + + class ResourceList < Callable + end + + class Args < Callable + end + + class HashOf < Callable + end + + class Bool + end + + class Maybe < Callable + end + + class IsA < Callable + end + end +end diff --git a/middleman-core/lib/middleman-core/core_extensions.rb b/middleman-core/lib/middleman-core/core_extensions.rb index ff7b0088..36fdb509 100644 --- a/middleman-core/lib/middleman-core/core_extensions.rb +++ b/middleman-core/lib/middleman-core/core_extensions.rb @@ -1,3 +1,5 @@ +require 'middleman-core/extensions' + # File Change Notifier Middleman::Extensions.register :file_watcher, auto_activate: :before_sitemap do require 'middleman-core/core_extensions/file_watcher' diff --git a/middleman-core/lib/middleman-core/core_extensions/data.rb b/middleman-core/lib/middleman-core/core_extensions/data.rb index 346d64f1..02af521a 100644 --- a/middleman-core/lib/middleman-core/core_extensions/data.rb +++ b/middleman-core/lib/middleman-core/core_extensions/data.rb @@ -1,5 +1,6 @@ require 'yaml' require 'active_support/json' +require 'middleman-core/contracts' module Middleman module CoreExtensions @@ -33,11 +34,24 @@ module Middleman # The core logic behind the data extension. class DataStore + include Contracts + + # Setup data store + # + # @param [Middleman::Application] app The current instance of Middleman + def initialize(app) + @app = app + @local_data = {} + @local_sources = {} + @callback_sources = {} + end + # Store static data hash # # @param [Symbol] name Name of the data, used for namespacing # @param [Hash] content The content for this data # @return [Hash] + Contract Symbol, Hash => Hash def store(name=nil, content=nil) @local_sources[name.to_s] = content unless name.nil? || content.nil? @local_sources @@ -48,21 +62,12 @@ module Middleman # @param [Symbol] name Name of the data, used for namespacing # @param [Proc] proc The callback which will return data # @return [Hash] + Contract Symbol, Proc => Hash def callbacks(name=nil, proc=nil) @callback_sources[name.to_s] = proc unless name.nil? || proc.nil? @callback_sources end - # Setup data store - # - # @param [Middleman::Application] app The current instance of Middleman - def initialize(app) - @app = app - @local_data = {} - @local_sources = {} - @callback_sources = {} - end - # Update the internal cache for a given file path # # @param [String] file The file to be re-parsed @@ -91,7 +96,7 @@ module Middleman data_branch = data_branch[dir] end - data_branch[basename] = ::Middleman::Util.recursively_enhance(data) + data_branch[basename] = data && ::Middleman::Util.recursively_enhance(data) end # Remove a given file from the internal cache @@ -120,6 +125,7 @@ module Middleman # # @param [String, Symbol] path The name of the data namespace # @return [Hash, nil] + Contract Or[String, Symbol] => Maybe[Hash] def data_for_path(path) response = nil @@ -170,6 +176,7 @@ module Middleman # Convert all the data into a static hash # # @return [Hash] + Contract None => Hash def to_h data = {} diff --git a/middleman-core/lib/middleman-core/core_extensions/file_watcher.rb b/middleman-core/lib/middleman-core/core_extensions/file_watcher.rb index 8a3e0a9c..68a83280 100644 --- a/middleman-core/lib/middleman-core/core_extensions/file_watcher.rb +++ b/middleman-core/lib/middleman-core/core_extensions/file_watcher.rb @@ -1,5 +1,6 @@ require 'pathname' require 'set' +require 'middleman-core/contracts' module Middleman module CoreExtensions @@ -42,6 +43,7 @@ module Middleman # Core File Change API class class API extend Forwardable + include Contracts attr_reader :app attr_reader :known_paths @@ -61,6 +63,7 @@ module Middleman # # @param [nil,Regexp] matcher A Regexp to match the change path against # @return [Array] + Contract Or[Regexp, Proc] => ArrayOf[ArrayOf[Or[Proc, Regexp, nil]]] def changed(matcher=nil, &block) @_changed << [block, matcher] if block_given? @_changed @@ -70,6 +73,7 @@ module Middleman # # @param [nil,Regexp] matcher A Regexp to match the deleted path against # @return [Array] + Contract Or[Regexp, Proc] => ArrayOf[ArrayOf[Or[Proc, Regexp, nil]]] def deleted(matcher=nil, &block) @_deleted << [block, matcher] if block_given? @_deleted @@ -130,6 +134,7 @@ module Middleman reload_path(path, true) end + Contract String => Bool def exists?(path) p = Pathname(path) p = p.relative_path_from(Pathname(@app.root)) unless p.relative? @@ -139,6 +144,7 @@ module Middleman # Whether this path is ignored # @param [Pathname] path # @return [Boolean] + Contract Or[String, Pathname] => Bool def ignored?(path) path = path.to_s app.config[:file_watcher_ignore].any? { |r| path =~ r } diff --git a/middleman-core/lib/middleman-core/core_extensions/front_matter.rb b/middleman-core/lib/middleman-core/core_extensions/front_matter.rb index 72412398..cd96eb38 100644 --- a/middleman-core/lib/middleman-core/core_extensions/front_matter.rb +++ b/middleman-core/lib/middleman-core/core_extensions/front_matter.rb @@ -31,7 +31,8 @@ module Middleman::CoreExtensions file_watcher.deleted(&method(:clear_data)) end - # Modify each resource to add data & options from frontmatter. + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) resources.each do |resource| next if resource.source_file.blank? @@ -60,10 +61,12 @@ module Middleman::CoreExtensions # Get the template data from a path # @param [String] path # @return [String] + Contract String => String def template_data_for_file(path) data(path).last end + Contract String => [Hash, Maybe[String]] def data(path) p = normalize_path(path) @cache[p] ||= frontmatter_and_content(p) @@ -83,6 +86,7 @@ module Middleman::CoreExtensions # Get the frontmatter and plain content from a file # @param [String] path # @return [Array] + Contract String => [Hash, Maybe[String]] def frontmatter_and_content(path) full_path = if Pathname(path).relative? File.join(app.source_dir, path) @@ -117,6 +121,7 @@ module Middleman::CoreExtensions # Parse YAML frontmatter out of a string # @param [String] content # @return [Array] + Contract String, String => Maybe[[Hash, String]] def parse_yaml_front_matter(content, full_path) yaml_regex = /\A(---\s*\n.*?\n?)^(---\s*$\n?)/m if content =~ yaml_regex @@ -127,10 +132,10 @@ module Middleman::CoreExtensions data = data.symbolize_keys rescue *YAML_ERRORS => e app.logger.error "YAML Exception parsing #{full_path}: #{e.message}" - return false + return nil end else - return false + return nil end [data, content] @@ -138,6 +143,10 @@ module Middleman::CoreExtensions [{}, content] end + # Parse JSON frontmatter out of a string + # @param [String] content + # @return [Array] + Contract String, String => Maybe[[Hash, String]] def parse_json_front_matter(content, full_path) json_regex = /\A(;;;\s*\n.*?\n?)^(;;;\s*$\n?)/m @@ -149,11 +158,11 @@ module Middleman::CoreExtensions data = ActiveSupport::JSON.decode(json).symbolize_keys rescue => e app.logger.error "JSON Exception parsing #{full_path}: #{e.message}" - return false + return nil end else - return false + return nil end [data, content] diff --git a/middleman-core/lib/middleman-core/core_extensions/i18n.rb b/middleman-core/lib/middleman-core/core_extensions/i18n.rb index d62d15cb..32f3f2b0 100644 --- a/middleman-core/lib/middleman-core/core_extensions/i18n.rb +++ b/middleman-core/lib/middleman-core/core_extensions/i18n.rb @@ -43,12 +43,14 @@ class Middleman::CoreExtensions::Internationalization < ::Middleman::Extension end end + Contract None => ArrayOf[Symbol] def langs @langs ||= known_languages end # Update the main sitemap resource list - # @return [void] + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) new_resources = [] @@ -87,6 +89,7 @@ class Middleman::CoreExtensions::Internationalization < ::Middleman::Extension ::I18n.reload! end + Contract String => Regexp def convert_glob_to_regex(glob) # File.fnmatch doesn't support brackets: {rb,yml,yaml} regex = glob.sub(/\./, '\.').sub(File.join('**', '*'), '.*').sub(/\//, '\/').sub('{rb,yml,yaml}', '(rb|ya?ml)') @@ -103,6 +106,7 @@ class Middleman::CoreExtensions::Internationalization < ::Middleman::Extension ::I18n.fallbacks = ::I18n::Locale::Fallbacks.new if ::I18n.respond_to?(:fallbacks) end + Contract None => ArrayOf[Symbol] def known_languages if options[:langs] Array(options[:langs]).map(&:to_sym) @@ -120,6 +124,7 @@ class Middleman::CoreExtensions::Internationalization < ::Middleman::Extension # Parse locale extension filename # @return [lang, path, basename] # will return +nil+ if no locale extension + Contract String => Maybe[[Symbol, String, String]] def parse_locale_extension(path) path_bits = path.split('.') return nil if path_bits.size < 3 @@ -132,6 +137,7 @@ class Middleman::CoreExtensions::Internationalization < ::Middleman::Extension [lang, path, basename] end + Contract String, String, String, Symbol => IsA['Middleman::Sitemap::Resource'] def build_resource(path, source_path, page_id, lang) old_locale = ::I18n.locale ::I18n.locale = lang diff --git a/middleman-core/lib/middleman-core/core_extensions/routing.rb b/middleman-core/lib/middleman-core/core_extensions/routing.rb index 24539ce0..61e80028 100644 --- a/middleman-core/lib/middleman-core/core_extensions/routing.rb +++ b/middleman-core/lib/middleman-core/core_extensions/routing.rb @@ -16,6 +16,8 @@ module Middleman app.add_to_config_context :page, &method(:page) end + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) resources.each do |resource| @page_configs.each do |matcher, metadata| diff --git a/middleman-core/lib/middleman-core/extension.rb b/middleman-core/lib/middleman-core/extension.rb index 713a12c0..cb875e89 100644 --- a/middleman-core/lib/middleman-core/extension.rb +++ b/middleman-core/lib/middleman-core/extension.rb @@ -1,5 +1,6 @@ require 'active_support/core_ext/class/attribute' require 'middleman-core/configuration' +require 'middleman-core/contracts' module Middleman # Middleman's Extension API provides the ability to add functionality to Middleman @@ -64,6 +65,7 @@ module Middleman # @see http://middlemanapp.com/advanced/custom/ Middleman Custom Extensions Documentation class Extension extend Forwardable + include Contracts # @!attribute supports_multiple_instances # @!scope class diff --git a/middleman-core/lib/middleman-core/extensions/asset_hash.rb b/middleman-core/lib/middleman-core/extensions/asset_hash.rb index fb277adf..ae4801c2 100644 --- a/middleman-core/lib/middleman-core/extensions/asset_hash.rb +++ b/middleman-core/lib/middleman-core/extensions/asset_hash.rb @@ -26,6 +26,7 @@ class Middleman::Extensions::AssetHash < ::Middleman::Extension proc: method(:rewrite_url) end + Contract String, Or[String, Pathname], Any => Maybe[String] def rewrite_url(asset_path, dirpath, _request_path) relative_path = Pathname.new(asset_path).relative? @@ -43,7 +44,8 @@ class Middleman::Extensions::AssetHash < ::Middleman::Extension end # Update the main sitemap resource list - # @return [void] + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) @rack_client ||= begin rack_app = ::Middleman::Rack.new(app).to_app @@ -64,6 +66,7 @@ class Middleman::Extensions::AssetHash < ::Middleman::Extension end.each(&method(:manipulate_single_resource)) end + Contract IsA['Middleman::Sitemap::Resource'] => Maybe[IsA['Middleman::Sitemap::Resource']] def manipulate_single_resource(resource) return unless options.exts.include?(resource.ext) return if ignored_resource?(resource) @@ -79,8 +82,10 @@ class Middleman::Extensions::AssetHash < ::Middleman::Extension digest = Digest::SHA1.hexdigest(response.body)[0..7] resource.destination_path = resource.destination_path.sub(/\.(\w+)$/) { |ext| "-#{digest}#{ext}" } + resource end + Contract IsA['Middleman::Sitemap::Resource'] => Bool def ignored_resource?(resource) @ignore.any? { |ignore| Middleman::Util.path_match(ignore, resource.destination_path) } end diff --git a/middleman-core/lib/middleman-core/extensions/asset_host.rb b/middleman-core/lib/middleman-core/extensions/asset_host.rb index ff687895..28765c24 100644 --- a/middleman-core/lib/middleman-core/extensions/asset_host.rb +++ b/middleman-core/lib/middleman-core/extensions/asset_host.rb @@ -16,6 +16,7 @@ class Middleman::Extensions::AssetHost < ::Middleman::Extension proc: method(:rewrite_url) end + Contract String, Or[String, Pathname], Any => String def rewrite_url(asset_path, dirpath, _request_path) relative_path = Pathname.new(asset_path).relative? diff --git a/middleman-core/lib/middleman-core/extensions/cache_buster.rb b/middleman-core/lib/middleman-core/extensions/cache_buster.rb index f1d22610..24536f95 100644 --- a/middleman-core/lib/middleman-core/extensions/cache_buster.rb +++ b/middleman-core/lib/middleman-core/extensions/cache_buster.rb @@ -20,6 +20,7 @@ class Middleman::Extensions::CacheBuster < ::Middleman::Extension proc: method(:rewrite_url) end + Contract String, Or[String, Pathname], Any => String def rewrite_url(asset_path, _dirpath, _request_path) asset_path + '?' + Time.now.strftime('%s') end diff --git a/middleman-core/lib/middleman-core/extensions/directory_indexes.rb b/middleman-core/lib/middleman-core/extensions/directory_indexes.rb index b63eb64e..171b1493 100644 --- a/middleman-core/lib/middleman-core/extensions/directory_indexes.rb +++ b/middleman-core/lib/middleman-core/extensions/directory_indexes.rb @@ -5,7 +5,8 @@ class Middleman::Extensions::DirectoryIndexes < ::Middleman::Extension self.resource_list_manipulator_priority = 100 # Update the main sitemap resource list - # @return [void] + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) index_file = app.config[:index_file] new_index_path = "/#{index_file}" diff --git a/middleman-core/lib/middleman-core/extensions/gzip.rb b/middleman-core/lib/middleman-core/extensions/gzip.rb index d3015ecf..70af4ad1 100644 --- a/middleman-core/lib/middleman-core/extensions/gzip.rb +++ b/middleman-core/lib/middleman-core/extensions/gzip.rb @@ -70,6 +70,7 @@ class Middleman::Extensions::Gzip < ::Middleman::Extension I18n.locale = old_locale end + Contract String => [Maybe[String], Maybe[Num], Maybe[Num]] def gzip_file(path) input_file = File.open(path, 'rb').read output_filename = path + '.gz' @@ -104,6 +105,7 @@ class Middleman::Extensions::Gzip < ::Middleman::Extension # Whether a path should be gzipped # @param [Pathname] path A destination path # @return [Boolean] + Contract Pathname => Bool def should_gzip?(path) path = path.sub app.config[:build_dir] + '/', '' options.exts.include?(path.extname) && options.ignore.none? { |ignore| Middleman::Util.path_match(ignore, path.to_s) } diff --git a/middleman-core/lib/middleman-core/extensions/minify_css.rb b/middleman-core/lib/middleman-core/extensions/minify_css.rb index 0b1f0e1b..08a28d95 100644 --- a/middleman-core/lib/middleman-core/extensions/minify_css.rb +++ b/middleman-core/lib/middleman-core/extensions/minify_css.rb @@ -1,3 +1,5 @@ +require 'middleman-core/contracts' + # Minify CSS Extension class Middleman::Extensions::MinifyCss < ::Middleman::Extension option :inline, false, 'Whether to minify CSS inline within HTML files' @@ -24,6 +26,7 @@ class Middleman::Extensions::MinifyCss < ::Middleman::Extension # Rack middleware to look for CSS and compress it class Rack + include Contracts INLINE_CSS_REGEX = /(]*>\s*(?:\/\*\*\/)?\s*<\/style>)/m # Init @@ -65,10 +68,12 @@ class Middleman::Extensions::MinifyCss < ::Middleman::Extension private + Contract String => Bool def inline_html_content?(path) (path.end_with?('.html') || path.end_with?('.php')) && @inline end + Contract String => Bool def standalone_css_content?(path) path.end_with?('.css') && @ignore.none? { |ignore| Middleman::Util.path_match(ignore, path) } end diff --git a/middleman-core/lib/middleman-core/extensions/minify_javascript.rb b/middleman-core/lib/middleman-core/extensions/minify_javascript.rb index 5a03671b..208b4fda 100644 --- a/middleman-core/lib/middleman-core/extensions/minify_javascript.rb +++ b/middleman-core/lib/middleman-core/extensions/minify_javascript.rb @@ -1,3 +1,5 @@ +require 'middleman-core/contracts' + # Minify Javascript Extension class Middleman::Extensions::MinifyJavascript < ::Middleman::Extension option :inline, false, 'Whether to minify JS inline within HTML files' @@ -16,6 +18,8 @@ class Middleman::Extensions::MinifyJavascript < ::Middleman::Extension # Rack middleware to look for JS and compress it class Rack + include Contracts + # Init # @param [Class] app # @param [Hash] options @@ -61,6 +65,7 @@ class Middleman::Extensions::MinifyJavascript < ::Middleman::Extension private + Contract String => String def minify_inline_content(uncompressed_source) uncompressed_source.gsub(/(]*>\s*(?:\/\/(?:(?:)|(?:\]\]>)))?\s*<\/script>)/m) do |match| first = $1 diff --git a/middleman-core/lib/middleman-core/extensions/relative_assets.rb b/middleman-core/lib/middleman-core/extensions/relative_assets.rb index 3820b8f8..95d32698 100644 --- a/middleman-core/lib/middleman-core/extensions/relative_assets.rb +++ b/middleman-core/lib/middleman-core/extensions/relative_assets.rb @@ -20,6 +20,7 @@ class Middleman::Extensions::RelativeAssets < ::Middleman::Extension proc: method(:rewrite_url) end + Contract String, Or[String, Pathname], Any => Maybe[String] def rewrite_url(asset_path, dirpath, request_path) relative_path = Pathname.new(asset_path).relative? diff --git a/middleman-core/lib/middleman-core/file_renderer.rb b/middleman-core/lib/middleman-core/file_renderer.rb index 1e0bbebd..6e43d712 100644 --- a/middleman-core/lib/middleman-core/file_renderer.rb +++ b/middleman-core/lib/middleman-core/file_renderer.rb @@ -1,5 +1,7 @@ require 'tilt' require 'active_support/core_ext/string/output_safety' +require 'active_support/core_ext/module/delegation' +require 'middleman-core/contracts' ::Tilt.mappings.delete('html') # WTF, Tilt? ::Tilt.mappings.delete('csv') @@ -7,6 +9,7 @@ require 'active_support/core_ext/string/output_safety' module Middleman class FileRenderer extend Forwardable + include Contracts def self.cache @_cache ||= ::Tilt::Cache.new @@ -25,6 +28,7 @@ module Middleman # @param [Hash] opts # @param [Class] context # @return [String] + Contract Hash, Hash, Any, Proc => String def render(locs={}, opts={}, context, &block) path = @path.dup @@ -96,6 +100,7 @@ module Middleman # Get the template data from a path # @param [String] path # @return [String] + Contract String => String def template_data_for_file if @app.extensions[:front_matter] @app.extensions[:front_matter].template_data_for_file(@path) @@ -111,6 +116,7 @@ module Middleman # # @param [String] ext # @return [Hash] + Contract String => Hash def options_for_ext(ext) # Read options for extension from config/Tilt or cache cache.fetch(:options_for_ext, ext) do diff --git a/middleman-core/lib/middleman-core/middleware/inline_url_rewriter.rb b/middleman-core/lib/middleman-core/middleware/inline_url_rewriter.rb index 88142b4a..094a9d9c 100644 --- a/middleman-core/lib/middleman-core/middleware/inline_url_rewriter.rb +++ b/middleman-core/lib/middleman-core/middleware/inline_url_rewriter.rb @@ -1,10 +1,13 @@ require 'middleman-core/util' +require 'middleman-core/contracts' require 'rack' require 'rack/response' module Middleman module Middleware class InlineURLRewriter + include Contracts + def initialize(app, options={}) @rack_app = app @middleman_app = options[:middleman_app] @@ -63,10 +66,11 @@ module Middleman [status, headers, response] end + Contract Or[Regexp, RespondTo[:call], String] => Bool def should_ignore?(validator, value) if validator.is_a? Regexp # Treat as Regexp - value.match(validator) + !value.match(validator).nil? elsif validator.respond_to? :call # Treat as proc validator.call(value) diff --git a/middleman-core/lib/middleman-core/renderers/liquid.rb b/middleman-core/lib/middleman-core/renderers/liquid.rb index e3d51417..78ff5a75 100644 --- a/middleman-core/lib/middleman-core/renderers/liquid.rb +++ b/middleman-core/lib/middleman-core/renderers/liquid.rb @@ -10,6 +10,8 @@ module Middleman ::Liquid::Template.file_system = ::Liquid::LocalFileSystem.new(app.source_dir) end + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) return resources unless app.extensions[:data] diff --git a/middleman-core/lib/middleman-core/sitemap/extensions/ignores.rb b/middleman-core/lib/middleman-core/sitemap/extensions/ignores.rb index feee4c1d..7ac26615 100644 --- a/middleman-core/lib/middleman-core/sitemap/extensions/ignores.rb +++ b/middleman-core/lib/middleman-core/sitemap/extensions/ignores.rb @@ -18,6 +18,7 @@ module Middleman # Ignore a path or add an ignore callback # @param [String, Regexp] path Path glob expression, or path regex # @return [void] + Contract Maybe[Or[String, Regexp]], Proc => Any def create_ignore(path=nil, &block) if path.is_a? Regexp @ignored_callbacks << proc { |p| p =~ path } @@ -40,6 +41,7 @@ module Middleman # Whether a path is ignored # @param [String] path # @return [Boolean] + Contract String => Bool def ignored?(path) path_clean = ::Middleman::Util.normalize_path(path) @ignored_callbacks.any? { |b| b.call(path_clean) } diff --git a/middleman-core/lib/middleman-core/sitemap/extensions/on_disk.rb b/middleman-core/lib/middleman-core/sitemap/extensions/on_disk.rb index e39ca08f..0a42198c 100644 --- a/middleman-core/lib/middleman-core/sitemap/extensions/on_disk.rb +++ b/middleman-core/lib/middleman-core/sitemap/extensions/on_disk.rb @@ -1,4 +1,5 @@ require 'set' +require 'middleman-core/contracts' module Middleman module Sitemap @@ -21,6 +22,7 @@ module Middleman end end + Contract None => Any def before_configuration file_watcher.changed(&method(:touch_file)) file_watcher.deleted(&method(:remove_file)) @@ -28,12 +30,16 @@ module Middleman # Update or add an on-disk file path # @param [String] file - # @return [Boolean] + # @return [void] + Contract String => Any def touch_file(file) return false if File.directory?(file) - path = @app.sitemap.file_to_path(file) - return false unless path + begin + @app.sitemap.file_to_path(file) + rescue + return + end ignored = @app.config[:ignored_sitemap_matchers].any? do |_, callback| if callback.arity == 1 @@ -59,6 +65,7 @@ module Middleman # Remove a file from the store # @param [String] file # @return [void] + Contract String => Any def remove_file(file) return unless @file_paths_on_disk.delete?(file) @@ -70,7 +77,8 @@ module Middleman end # Update the main sitemap resource list - # @return [void] + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) resources + @file_paths_on_disk.map do |file| ::Middleman::Sitemap::Resource.new( diff --git a/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb b/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb index 03541302..cadf81f3 100644 --- a/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb +++ b/middleman-core/lib/middleman-core/sitemap/extensions/proxies.rb @@ -24,6 +24,7 @@ module Middleman # @option opts [Hash] locals Local variables for the template. These will be available when the template renders. # @option opts [Hash] data Extra metadata to add to the page. This is the same as frontmatter, though frontmatter will take precedence over metadata defined here. Available via {Resource#data}. # @return [void] + Contract String, String, Maybe[Hash] => Any def create_proxy(path, target, opts={}) options = opts.dup @@ -41,7 +42,8 @@ module Middleman end # Update the main sitemap resource list - # @return [void] + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) resources + @proxy_configs.map do |config| p = ProxyResource.new( @@ -108,6 +110,7 @@ module Middleman # The resource for the page this page is proxied to. Throws an exception # if there is no resource. # @return [Sitemap::Resource] + Contract None => IsA['Middleman::Sitemap::Resource'] def target_resource resource = @store.find_resource_by_path(@target) @@ -122,10 +125,12 @@ module Middleman resource end + Contract None => String def source_file target_resource.source_file end + Contract None => Maybe[String] def content_type mime_type = super return mime_type if mime_type diff --git a/middleman-core/lib/middleman-core/sitemap/extensions/redirects.rb b/middleman-core/lib/middleman-core/sitemap/extensions/redirects.rb index babe6509..9b1a4953 100644 --- a/middleman-core/lib/middleman-core/sitemap/extensions/redirects.rb +++ b/middleman-core/lib/middleman-core/sitemap/extensions/redirects.rb @@ -1,4 +1,5 @@ require 'middleman-core/sitemap/resource' +require 'middleman-core/contracts' module Middleman module Sitemap @@ -17,6 +18,7 @@ module Middleman # Setup a redirect from a path to a target # @param [String] path # @param [Hash] opts The :to value gives a target path + Contract String, ({ to: Or[String, IsA['Middleman::Sitemap::Resource']] }), Proc => Any def create_redirect(path, opts={}, &block) opts[:template] = block if block_given? @@ -26,7 +28,8 @@ module Middleman end # Update the main sitemap resource list - # @return [void] + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) resources + @redirects.map do |path, opts| r = RedirectResource.new( @@ -41,6 +44,7 @@ module Middleman end class RedirectResource < ::Middleman::Sitemap::Resource + Contract None => Maybe[Proc] attr_accessor :output def initialize(store, path, target) @@ -49,10 +53,12 @@ module Middleman super(store, path) end + Contract None => Bool def template? true end + Contract Args[Any] => String def render(*) url = ::Middleman::Util.url_for(@store.app, @request_path, relative: false, @@ -76,6 +82,7 @@ module Middleman end end + Contract None => Bool def ignored? false end diff --git a/middleman-core/lib/middleman-core/sitemap/extensions/request_endpoints.rb b/middleman-core/lib/middleman-core/sitemap/extensions/request_endpoints.rb index c6046f40..16a43deb 100644 --- a/middleman-core/lib/middleman-core/sitemap/extensions/request_endpoints.rb +++ b/middleman-core/lib/middleman-core/sitemap/extensions/request_endpoints.rb @@ -18,6 +18,7 @@ module Middleman # @param [String] path # @param [Hash] opts The :path value gives a request path if it # differs from the output path + Contract String, Or[({ path: String }), Proc] => Any def create_endpoint(path, opts={}, &block) endpoint = { request_path: path @@ -35,7 +36,8 @@ module Middleman end # Update the main sitemap resource list - # @return [void] + # @return Array + Contract ResourceList => ResourceList def manipulate_resource_list(resources) resources + @endpoints.map do |path, config| r = EndpointResource.new( @@ -50,6 +52,7 @@ module Middleman end class EndpointResource < ::Middleman::Sitemap::Resource + Contract None => Maybe[Proc] attr_accessor :output def initialize(store, path, request_path) @@ -57,16 +60,20 @@ module Middleman @request_path = ::Middleman::Util.normalize_path(request_path) end + Contract None => String attr_reader :request_path + Contract None => Bool def template? true end + Contract Args[Any] => String def render(*) return output.call if output end + Contract None => Bool def ignored? false end diff --git a/middleman-core/lib/middleman-core/sitemap/resource.rb b/middleman-core/lib/middleman-core/sitemap/resource.rb index fdd57b00..eee2068a 100644 --- a/middleman-core/lib/middleman-core/sitemap/resource.rb +++ b/middleman-core/lib/middleman-core/sitemap/resource.rb @@ -2,12 +2,14 @@ require 'rack/mime' require 'middleman-core/sitemap/extensions/traversal' require 'middleman-core/file_renderer' require 'middleman-core/template_renderer' +require 'middleman-core/contracts' module Middleman # Sitemap namespace module Sitemap # Sitemap Resource class class Resource + include Contracts include Middleman::Sitemap::Extensions::Traversal # The source path of this resource (relative to the source directory, @@ -28,6 +30,10 @@ module Middleman # @return [String] alias_method :request_path, :destination_path + # The metadata for this resource + # @return [Hash] + attr_reader :metadata + # Initialize resource with parent store and URL # @param [Middleman::Sitemap::Store] store # @param [String] path @@ -48,6 +54,7 @@ module Middleman # Whether this resource has a template file # @return [Boolean] + Contract None => Bool def template? return false if source_file.nil? !::Tilt[source_file].nil? @@ -59,16 +66,14 @@ module Middleman # Locals are local variables for rendering this resource's template # Page are data that is exposed through this resource's data member. # Note: It is named 'page' for backwards compatibility with older MM. + Contract Hash => Hash def add_metadata(meta={}) @metadata.deep_merge!(meta) end - # The metadata for this resource - # @return [Hash] - attr_reader :metadata - # Data about this resource, populated from frontmatter or extensions. # @return [HashWithIndifferentAccess] + Contract None => IsA['Middleman::Util::HashWithIndifferentAccess'] def data # TODO: Should this really be a HashWithIndifferentAccess? ::Middleman::Util.recursively_enhance(metadata[:page]).freeze @@ -77,30 +82,34 @@ module Middleman # Options about how this resource is rendered, such as its :layout, # :renderer_options, and whether or not to use :directory_indexes. # @return [Hash] + Contract None => Hash def options metadata[:options] end # Local variable mappings that are used when rendering the template for this resource. # @return [Hash] + Contract None => Hash def locals metadata[:locals] end # Extension of the path (i.e. '.js') # @return [String] + Contract None => String def ext File.extname(path) end # Render this resource # @return [String] + Contract Hash, Hash => String def render(opts={}, locs={}) return ::Middleman::FileRenderer.new(@app, source_file).template_data_for_file unless template? relative_source = Pathname(source_file).relative_path_from(Pathname(@app.root)) - @app.instrument 'render.resource', path: relative_source, destination_path: destination_path do + ::Middleman::Util.instrument 'render.resource', path: relative_source, destination_path: destination_path do md = metadata opts = md[:options].deep_merge(opts) locs = md[:locals].deep_merge(locs) @@ -119,6 +128,7 @@ module Middleman # A path without the directory index - so foo/index.html becomes # just foo. Best for linking. # @return [String] + Contract None => String def url url_path = destination_path if @app.config[:strip_index_file] @@ -131,19 +141,22 @@ module Middleman # Whether the source file is binary. # # @return [Boolean] + Contract None => Bool def binary? - source_file && ::Middleman::Util.binary?(source_file) + !source_file.nil? && ::Middleman::Util.binary?(source_file) end # Ignore a resource directly, without going through the whole # ignore filter stuff. # @return [void] + Contract None => Any def ignore! @ignored = true end # Whether the Resource is ignored # @return [Boolean] + Contract None => Bool def ignored? return true if @ignored # Ignore based on the source path (without template extensions) @@ -154,6 +167,7 @@ module Middleman # The preferred MIME content type for this resource based on extension or metadata # @return [String] MIME type for this resource + Contract None => Maybe[String] def content_type options[:content_type] || ::Rack::Mime.mime_type(ext, nil) end diff --git a/middleman-core/lib/middleman-core/sitemap/store.rb b/middleman-core/lib/middleman-core/sitemap/store.rb index bf7d2a57..12348695 100644 --- a/middleman-core/lib/middleman-core/sitemap/store.rb +++ b/middleman-core/lib/middleman-core/sitemap/store.rb @@ -32,6 +32,8 @@ Middleman::Extensions.register :sitemap_redirects, auto_activate: :before_config Middleman::Sitemap::Extensions::Redirects end +require 'middleman-core/contracts' + module Middleman # Sitemap namespace module Sitemap @@ -42,6 +44,8 @@ module Middleman # which is the path relative to the source directory, minus any template # extensions. All "path" parameters used in this class are source paths. class Store + include Contracts + # @return [Middleman::Application] attr_reader :app @@ -67,6 +71,7 @@ module Middleman # @param [#manipulate_resource_list] manipulator Resource list manipulator # @param [Numeric] priority Sets the order of this resource list manipulator relative to the rest. By default this is 50, and manipulators run in the order they are registered, but if a priority is provided then this will run ahead of or behind other manipulators. # @return [void] + Contract Symbol, RespondTo['manipulate_resource_list'], Maybe[Num] => Any def register_resource_list_manipulator(name, manipulator, priority=50) # The third argument used to be a boolean - handle those who still pass one priority = 50 unless priority.is_a? Numeric @@ -92,6 +97,7 @@ module Middleman # Find a resource given its original path # @param [String] request_path The original path of a resource. # @return [Middleman::Sitemap::Resource] + Contract String => Maybe[IsA['Middleman::Sitemap::Resource']] def find_resource_by_path(request_path) @lock.synchronize do request_path = ::Middleman::Util.normalize_path(request_path) @@ -103,6 +109,7 @@ module Middleman # Find a resource given its destination path # @param [String] request_path The destination (output) path of a resource. # @return [Middleman::Sitemap::Resource] + Contract String => Maybe[IsA['Middleman::Sitemap::Resource']] def find_resource_by_destination_path(request_path) @lock.synchronize do request_path = ::Middleman::Util.normalize_path(request_path) @@ -114,6 +121,7 @@ module Middleman # Get the array of all resources # @param [Boolean] include_ignored Whether to include ignored resources # @return [Array] + Contract Bool => ResourceList def resources(include_ignored=false) @lock.synchronize do ensure_resource_list_updated! @@ -134,11 +142,12 @@ module Middleman # Get the URL path for an on-disk file # @param [String] file # @return [String] + Contract String => String def file_to_path(file) file = File.join(@app.root, file) prefix = @app.source_dir.sub(/\/$/, '') + '/' - return false unless file.start_with?(prefix) + raise "'#{file}' not inside project folder '#{prefix}" unless file.start_with?(prefix) path = file.sub(prefix, '') @@ -153,6 +162,7 @@ module Middleman # Get a path without templating extensions # @param [String] file # @return [String] + Contract String => String def extensionless_path(file) path = file.dup remove_templating_extensions(path) @@ -197,6 +207,7 @@ module Middleman # Removes the templating extensions, while keeping the others # @param [String] path # @return [String] + Contract String => String def remove_templating_extensions(path) # Strip templating extensions as long as Tilt knows them path = path.sub(File.extname(path), '') while ::Tilt[path] @@ -206,6 +217,7 @@ module Middleman # Remove the locale token from the end of the path # @param [String] path # @return [String] + Contract String => String def strip_away_locale(path) if @app.extensions[:i18n] path_bits = path.split('.') diff --git a/middleman-core/lib/middleman-core/template_context.rb b/middleman-core/lib/middleman-core/template_context.rb index d80b46d3..78becaf5 100644 --- a/middleman-core/lib/middleman-core/template_context.rb +++ b/middleman-core/lib/middleman-core/template_context.rb @@ -1,6 +1,7 @@ require 'pathname' require 'middleman-core/file_renderer' require 'middleman-core/template_renderer' +require 'middleman-core/contracts' module Middleman # The TemplateContext Class @@ -12,6 +13,7 @@ module Middleman # the request, passed from template, to layouts and partials. class TemplateContext extend Forwardable + include Contracts # Allow templates to directly access the current app instance. # @return [Middleman::Application] @@ -94,6 +96,7 @@ module Middleman # @param [String, Symbol] name The partial to render. # @param [Hash] options # @return [String] + Contract Any, Or[Symbol, String], Hash => String def render(_, name, options={}, &block) name = name.to_s @@ -114,6 +117,7 @@ module Middleman # @api private # @param [String] partial_path # @return [String] + Contract String => Maybe[String] def locate_partial(partial_path) return unless resource = sitemap.find_resource_by_path(current_path) @@ -141,6 +145,7 @@ module Middleman # @param [Hash] opts Template options. # @param [Proc] block A block will be evaluated to return internal contents. # @return [String] The resulting content string. + Contract String, Hash, Hash, Proc => String def render_file(path, locs, opts, &block) file_renderer = ::Middleman::FileRenderer.new(@app, path) file_renderer.render(locs, opts, self, &block) diff --git a/middleman-core/lib/middleman-core/template_renderer.rb b/middleman-core/lib/middleman-core/template_renderer.rb index 27384ccf..0eb2fc8f 100644 --- a/middleman-core/lib/middleman-core/template_renderer.rb +++ b/middleman-core/lib/middleman-core/template_renderer.rb @@ -2,10 +2,12 @@ require 'tilt' require 'active_support/core_ext/string/output_safety' require 'middleman-core/template_context' require 'middleman-core/file_renderer' +require 'middleman-core/contracts' module Middleman class TemplateRenderer extend Forwardable + include Contracts def self.cache @_cache ||= ::Tilt::Cache.new @@ -26,6 +28,7 @@ module Middleman # @param [Hash] locs # @param [Hash] opts # @return [String] + Contract Hash, Hash => String def render(locs={}, opts={}) path = @path.dup extension = File.extname(path) @@ -78,7 +81,8 @@ module Middleman # # @param [Symbol] engine # @param [Hash] opts - # @return [String] + # @return [String, Boolean] + Contract Symbol, Hash => Or[String, Bool] def fetch_layout(engine, opts) # The layout name comes from either the system default or the options local_layout = opts.key?(:layout) ? opts[:layout] : @app.config[:layout] @@ -117,6 +121,7 @@ module Middleman # @param [String] name # @param [Symbol] preferred_engine # @return [String] + Contract Or[String, Symbol], Symbol => Maybe[String] def locate_layout(name, preferred_engine=nil) self.class.locate_layout(@app, name, preferred_engine) end @@ -125,6 +130,7 @@ module Middleman # @param [String] name # @param [Symbol] preferred_engine # @return [String] + Contract IsA['Middleman::Application'], Or[String, Symbol], Symbol => Maybe[String] def self.locate_layout(app, name, preferred_engine=nil) resolve_opts = {} resolve_opts[:preferred_engine] = preferred_engine unless preferred_engine.nil? @@ -143,6 +149,7 @@ module Middleman # @param [String] request_path # @param [Hash] options # @return [Array, Boolean] + Contract String, Hash => ArrayOf[Or[String, Symbol]] def resolve_template(request_path, options={}) self.class.resolve_template(@app, request_path, options) end @@ -151,6 +158,7 @@ module Middleman # @param [String] request_path # @option options [Boolean] :preferred_engine If set, try this engine first, then fall back to any engine. # @return [String, Boolean] Either the path to the template, or false + Contract IsA['Middleman::Application'], Or[Symbol, String], Hash => Maybe[String] def self.resolve_template(app, request_path, options={}) # Find the path by searching or using the cache request_path = request_path.to_s @@ -194,7 +202,7 @@ module Middleman elsif File.exist?(on_disk_path) on_disk_path else - false + nil end end end diff --git a/middleman-core/lib/middleman-core/util.rb b/middleman-core/lib/middleman-core/util.rb index 3620259e..8c7b8c4d 100644 --- a/middleman-core/lib/middleman-core/util.rb +++ b/middleman-core/lib/middleman-core/util.rb @@ -11,348 +11,12 @@ require 'pathname' require 'tilt' require 'rack/mime' +# DbC +require 'middleman-core/contracts' + module Middleman module Util - class << self - # Whether the source file is binary. - # - # @param [String] filename The file to check. - # @return [Boolean] - def binary?(filename) - ext = File.extname(filename) - - # We hardcode detecting of gzipped SVG files - return true if ext == '.svgz' - - return false if Tilt.registered?(ext.sub('.', '')) - - dot_ext = (ext.to_s[0] == '.') ? ext.dup : ".#{ext}" - - if mime = ::Rack::Mime.mime_type(dot_ext, nil) - !nonbinary_mime?(mime) - else - file_contents_include_binary_bytes?(filename) - end - end - - # Facade for ActiveSupport/Notification - def instrument(name, payload={}, &block) - suffixed_name = (name =~ /\.middleman$/) ? name.dup : "#{name}.middleman" - ::ActiveSupport::Notifications.instrument(suffixed_name, payload, &block) - end - - # Recursively convert a normal Hash into a HashWithIndifferentAccess - # - # @private - # @param [Hash] data Normal hash - # @return [Middleman::Util::HashWithIndifferentAccess] - def recursively_enhance(data) - if data.is_a? Hash - data = ::Middleman::Util::HashWithIndifferentAccess.new(data) - data.each do |key, val| - data[key] = recursively_enhance(val) - end - data - elsif data.is_a? Array - data.each_with_index do |val, i| - data[i] = recursively_enhance(val) - end - data - else - data - end - end - - # Normalize a path to not include a leading slash - # @param [String] path - # @return [String] - def normalize_path(path) - # The tr call works around a bug in Ruby's Unicode handling - path.sub(%r{^/}, '').tr('', '') - end - - # This is a separate method from normalize_path in case we - # change how we normalize paths - def strip_leading_slash(path) - path.sub(%r{^/}, '') - end - - # Extract the text of a Rack response as a string. - # Useful for extensions implemented as Rack middleware. - # @param response The response from #call - # @return [String] The whole response as a string. - def extract_response_text(response) - # The rack spec states all response bodies must respond to each - result = '' - response.each do |part, _| - result << part - end - result - 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 - def path_match(matcher, path) - case - when matcher.is_a?(String) - if matcher.include? '*' - File.fnmatch(matcher, path) - else - path == matcher - end - when matcher.respond_to?(:match) - !matcher.match(path).nil? - when matcher.respond_to?(:call) - matcher.call(path) - else - File.fnmatch(matcher.to_s, path) - end - end - - # Get a recusive list of files inside a path. - # Works with symlinks. - # - # @param path Some path string or Pathname - # @param ignore A proc/block that returns true if a given path should be ignored - if a path - # is ignored, nothing below it will be searched either. - # @return [Array] An array of Pathnames for each file (no directories) - def all_files_under(path, &ignore) - path = Pathname(path) - - return [] if ignore && ignore.call(path) - - if path.directory? - path.children.flat_map do |child| - all_files_under(child, &ignore) - end.compact - elsif path.file? - [path] - else - [] - end - end - - # Get the path of a file of a given type - # - # @param [Symbol] kind The type of file - # @param [String] source The path to the file - # @param [Hash] options Data to pass through. - # @return [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 - def asset_url(app, path, prefix='', _options={}) - # Don't touch assets which already have a full path - if path.include?('//') || path.start_with?('data:') - path - else # rewrite paths to use their destination path - if resource = app.sitemap.find_resource_by_destination_path(url_for(app, 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 - end - 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. - def url_for(app, path_or_resource, options={}) - # 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.gsub(' ', '%20') - - # Try to parse URL - begin - uri = URI(url) - rescue 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 - # 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) - resource_url = resource.url if resource - elsif options[:find_resource] && uri.path - resource = app.sitemap.find_resource_by_path(uri.path) - resource_url = resource.url if resource - end - - if resource - uri.path = relative_path_from_resource(this_resource, resource_url, effective_relative) - else - # If they explicitly asked for relative links but we can't find a resource... - raise "No resource exists at #{url}" if relative - 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. - 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 - - def rewrite_paths(body, _path, exts, &_block) - body.dup.gsub(/([=\'\"\(]\s*)([^\s\'\"\)]+(#{Regexp.union(exts)}))/) do |match| - opening_character = $1 - asset_path = $2 - - if result = yield(asset_path) - "#{opening_character}#{result}" - else - match - end - end - end - - private - - # Is mime type known to be non-binary? - # - # @param [String] mime The mimetype to check. - # @return [Boolean] - def nonbinary_mime?(mime) - case - when mime.start_with?('text/') - true - when mime.include?('xml') - true - when mime.include?('json') - true - when mime.include?('javascript') - true - else - false - end - end - - # Read a few bytes from the file and see if they are binary. - # - # @param [String] filename The file to check. - # @return [Boolean] - def file_contents_include_binary_bytes?(filename) - binary_bytes = [0, 1, 2, 3, 4, 5, 6, 11, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 28, 29, 30, 31] - s = File.read(filename, 4096) || '' - s.each_byte do |c| - return true if binary_bytes.include?(c) - end - - false - 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] - 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 && curr_resource - # 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 - end + include Contracts # A hash with indifferent access and magic predicates. # Copied from Thor @@ -428,5 +92,373 @@ module Middleman end end end + + # Whether the source file is binary. + # + # @param [String] filename The file to check. + # @return [Boolean] + Contract String => Bool + def self.binary?(filename) + ext = File.extname(filename) + + # We hardcode detecting of gzipped SVG files + return true if ext == '.svgz' + + return false if Tilt.registered?(ext.sub('.', '')) + + dot_ext = (ext.to_s[0] == '.') ? ext.dup : ".#{ext}" + + if mime = ::Rack::Mime.mime_type(dot_ext, nil) + !nonbinary_mime?(mime) + else + file_contents_include_binary_bytes?(filename) + 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 Or[String, RespondTo[:match], RespondTo[:call], RespondTo[:to_s]], String => Bool + def self.path_match(matcher, path) + case + when matcher.is_a?(String) + if matcher.include? '*' + File.fnmatch(matcher, path) + else + path == matcher + end + when matcher.respond_to?(:match) + !matcher.match(path).nil? + when matcher.respond_to?(:call) + matcher.call(path) + else + File.fnmatch(matcher.to_s, path) + end + end + + # Recursively convert a normal Hash into a HashWithIndifferentAccess + # + # @private + # @param [Hash] data Normal hash + # @return [Middleman::Util::HashWithIndifferentAccess] + Contract Or[Hash, Array] => Or[HashWithIndifferentAccess, Array] + def self.recursively_enhance(data) + if data.is_a? Hash + enhanced = ::Middleman::Util::HashWithIndifferentAccess.new(data) + + enhanced.each do |key, val| + enhanced[key] = if val.is_a?(Hash) || val.is_a?(Array) + recursively_enhance(val) + else + val + end + end + + enhanced + elsif data.is_a? Array + enhanced = data.dup + + enhanced.each_with_index do |val, i| + enhanced[i] = if val.is_a?(Hash) || val.is_a?(Array) + recursively_enhance(val) + else + val + end + end + + enhanced + end + end + + # Normalize a path to not include a leading slash + # @param [String] path + # @return [String] + Contract String => String + def self.normalize_path(path) + # The tr call works around a bug in Ruby's Unicode handling + path.sub(%r{^/}, '').tr('', '') + end + + # This is a separate method from normalize_path in case we + # change how we normalize paths + Contract String => String + def self.strip_leading_slash(path) + path.sub(%r{^/}, '') + end + + # Facade for ActiveSupport/Notification + def self.instrument(name, payload={}, &block) + suffixed_name = (name =~ /\.middleman$/) ? name.dup : "#{name}.middleman" + ::ActiveSupport::Notifications.instrument(suffixed_name, payload, &block) + end + + # Extract the text of a Rack response as a string. + # Useful for extensions implemented as Rack middleware. + # @param response The response from #call + # @return [String] The whole response as a string. + Contract IsA['Rack::BodyProxy'] => String + def self.extract_response_text(response) + # The rack spec states all response bodies must respond to each + result = '' + response.each do |part, _| + result << part + end + result + end + + # Get a recusive list of files inside a path. + # Works with symlinks. + # + # @param path Some path string or Pathname + # @param ignore A proc/block that returns true if a given path should be ignored - if a path + # is ignored, nothing below it will be searched either. + # @return [Array] An array of Pathnames for each file (no directories) + Contract Or[String, Pathname], Proc => ArrayOf[Pathname] + def self.all_files_under(path, &ignore) + path = Pathname(path) + + return [] if ignore && ignore.call(path) + + if path.directory? + path.children.flat_map do |child| + all_files_under(child, &ignore) + end.compact + elsif path.file? + [path] + else + [] + end + end + + # 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 IsA['Middleman::Application'], Symbol, Or[String, Symbol], Hash => String + def self.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 IsA['Middleman::Application'], String, String, Hash => String + def self.asset_url(app, path, prefix='', _options={}) + # Don't touch assets which already have a full path + if path.include?('//') || path.start_with?('data:') + path + else # rewrite paths to use their destination path + if resource = app.sitemap.find_resource_by_destination_path(url_for(app, 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 + end + 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 IsA['Middleman::Application'], Or[String, IsA['Middleman::Sitemap::Resource']], Hash => String + def self.url_for(app, path_or_resource, options={}) + # 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.gsub(' ', '%20') + + # Try to parse URL + begin + uri = URI(url) + rescue 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 + # 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) + resource_url = resource.url if resource + elsif options[:find_resource] && uri.path + resource = app.sitemap.find_resource_by_path(uri.path) + resource_url = resource.url if resource + end + + if resource + uri.path = if this_resource + relative_path_from_resource(this_resource, resource_url, effective_relative) + else + resource_url + end + else + # If they explicitly asked for relative links but we can't find a resource... + raise "No resource exists at #{url}" if relative + 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, IsA['Middleman::Application'] => String + def self.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 + + Contract String, String, ArrayOf[String], Proc => String + def self.rewrite_paths(body, _path, exts, &_block) + body.dup.gsub(/([=\'\"\(]\s*)([^\s\'\"\)]+(#{Regexp.union(exts)}))/) do |match| + opening_character = $1 + asset_path = $2 + + if result = yield(asset_path) + "#{opening_character}#{result}" + else + match + end + end + end + + # Is mime type known to be non-binary? + # + # @param [String] mime The mimetype to check. + # @return [Boolean] + Contract String => Bool + def self.nonbinary_mime?(mime) + case + when mime.start_with?('text/') + true + when mime.include?('xml') + true + when mime.include?('json') + true + when mime.include?('javascript') + true + else + false + end + end + + # Read a few bytes from the file and see if they are binary. + # + # @param [String] filename The file to check. + # @return [Boolean] + Contract String => Bool + def self.file_contents_include_binary_bytes?(filename) + binary_bytes = [0, 1, 2, 3, 4, 5, 6, 11, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 28, 29, 30, 31] + s = File.read(filename, 4096) || '' + s.each_byte do |c| + return true if binary_bytes.include?(c) + end + + false + 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 IsA['Middleman::Sitemap::Resource'], String, Bool => String + def self.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 end end