From 3ae16111ef6518949892f9e4ac11b89f1a4e3c33 Mon Sep 17 00:00:00 2001 From: Thomas Reynolds Date: Mon, 7 Jul 2014 19:43:22 -0700 Subject: [PATCH] Separate Build from Thor --- CHANGELOG.md | 1 + middleman-cli/lib/middleman-cli/build.rb | 268 +++--------------- middleman-core/fixtures/clean-app/config.rb | 9 + middleman-core/lib/middleman-core/builder.rb | 255 +++++++++++++++++ .../core_extensions/show_exceptions.rb | 2 +- .../lib/middleman-core/extensions/gzip.rb | 4 +- .../step_definitions/builder_steps.rb | 4 +- 7 files changed, 317 insertions(+), 226 deletions(-) create mode 100644 middleman-core/lib/middleman-core/builder.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de8854c..724499e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ master === +* Builder extracted from Thor. `after_build` hook now passes an instance of a Builder instead of the Thor CLI. * New FileWatcher API. * Remove the `partials_dir` setting. Partials should live next to content, or be addressed with absolute paths. * Partials must be named with a leading underscore. `_my_snippet.html.erb`, not `my_snippet.html.erb`. diff --git a/middleman-cli/lib/middleman-cli/build.rb b/middleman-cli/lib/middleman-cli/build.rb index 3918319d..0ec5d234 100644 --- a/middleman-cli/lib/middleman-cli/build.rb +++ b/middleman-cli/lib/middleman-cli/build.rb @@ -1,18 +1,9 @@ -require 'fileutils' -require 'set' - # CLI Module module Middleman::Cli - # Alias "b" to "build" - Base.map('b' => 'build') - # The CLI Build class class Build < Thor include Thor::Actions - attr_reader :debugging - attr_accessor :had_errors - check_unknown_options! namespace :build @@ -53,250 +44,85 @@ module Middleman::Cli require 'middleman-core' require 'middleman-core/logger' - require 'middleman-core/rack' - - require 'rack' - require 'rack/mock' - - require 'find' - - @debugging = Middleman::Cli::Base.respond_to?(:debugging) && Middleman::Cli::Base.debugging - self.had_errors = false + require 'middleman-core/builder' + require 'fileutils' env = options['environment'].to_sym verbose = options['verbose'] ? 0 : 1 instrument = options['instrument'] - app = ::Middleman::Application.new do + @app = ::Middleman::Application.new do config[:mode] = :build config[:environment] = env + config[:show_exceptions] = false ::Middleman::Logger.singleton(verbose, instrument) end - opts = {} - opts[:glob] = options['glob'] if options.key?('glob') - opts[:clean] = options['clean'] + builder = Middleman::Builder.new(@app, + glob: options['glob'], + clean: options['clean']) - app.run_hook :before_build, self + builder.on_build_event(&method(:on_event)) - action BuildAction.new(self, app, opts) - - app.run_hook :after_build, self - app.config_context.execute_after_build_callbacks(self) - - if had_errors && !debugging + if builder.run! + clean_directories! if options['clean'] + else msg = 'There were errors during this build' unless options['verbose'] msg << ', re-run with `middleman build --verbose` to see the full exception.' end shell.say msg, :red - end - exit(1) if had_errors - end - - # Static methods - class << self - def exit_on_failure? - true - end - end - end - - # A Thor Action, modular code, which does the majority of the work. - class BuildAction < ::Thor::Actions::EmptyDirectory - attr_reader :source - attr_reader :logger - - # Setup the action - # - # @param [Middleman::Cli::Build] base - # @param [Hash] config - def initialize(base, app, config={}) - @app = app - @source_dir = Pathname(@app.source_dir) - @build_dir = Pathname(@app.config[:build_dir]) - @to_clean = Set.new - - @logger = @app.logger - rack_app = ::Middleman::Rack.new(@app).to_app - @rack = ::Rack::MockRequest.new(rack_app) - - super(base, @build_dir, config) - end - - # Execute the action - # @return [void] - def invoke! - queue_current_paths if should_clean? - execute! - clean! if should_clean? - end - - protected - - # Remove files which were not built in this cycle - # @return [void] - def clean! - @to_clean.each do |f| - base.remove_file f, force: true - end - - Dir[@build_dir.join('**', '*')].select { |d| File.directory?(d) }.each do |d| - base.remove_file d, force: true if directory_empty? d + exit(1) end end - # Whether we should clean the build - # @return [Boolean] - def should_clean? - @config[:clean] + # Tell Thor to send an exit status on a failure. + def self.exit_on_failure? + true end - # Whether the given directory is empty - # @param [String, Pathname] directory - # @return [Boolean] - def directory_empty?(directory) - Pathname(directory).children.empty? - end + no_tasks do - # Get a list of all the file paths in the destination folder and save them - # for comparison against the files we build in this cycle - # @return [void] - def queue_current_paths - return unless File.exist?(@build_dir) - - paths = ::Middleman::Util.all_files_under(@build_dir).map(&:realpath).select(&:file?) - - @to_clean += paths.select do |path| - path.to_s !~ /\/\./ || path.to_s =~ /\.(htaccess|htpasswd)/ - end - - return unless RUBY_PLATFORM =~ /darwin/ - - # handle UTF-8-MAC filename on MacOS - @to_clean = @to_clean.map { |path| path.to_s.encode('UTF-8', 'UTF-8-MAC') } - end - - # Actually build the app - # @return [void] - def execute! - # Sort order, images, fonts, js/css and finally everything else. - sort_order = %w(.png .jpeg .jpg .gif .bmp .svg .svgz .ico .woff .otf .ttf .eot .js .css) - - # Pre-request CSS to give Compass a chance to build sprites - logger.debug '== Prerendering CSS' - - @app.sitemap.resources.select do |resource| - resource.ext == '.css' - end.each(&method(:build_resource)) - - logger.debug '== Checking for generated images' - - # Double-check for generated images - @app.files.find_new_files((@source_dir + @app.config[:images_dir]).relative_path_from(@app.root_path)) - @app.sitemap.ensure_resource_list_updated! - - # Sort paths to be built by the above order. This is primarily so Compass can - # find files in the build folder when it needs to generate sprites for the - # css files - - logger.debug '== Building files' - - resources = @app.sitemap.resources.sort_by do |r| - sort_order.index(r.ext) || 100 - end - - if @build_dir.expand_path.relative_path_from(@source_dir).to_s =~ /\A[.\/]+\Z/ - raise ":build_dir (#{@build_dir}) cannot be a parent of :source_dir (#{@source_dir})" - end - - # Loop over all the paths and build them. - resources.reject do |resource| - resource.ext == '.css' - end.each(&method(:build_resource)) - - ::Middleman::Profiling.report('build') - end - - def build_resource(resource) - return if @config[:glob] && !File.fnmatch(@config[:glob], resource.destination_path) - - output_path = render_to_file(resource) - - return unless should_clean? && output_path.exist? - - if RUBY_PLATFORM =~ /darwin/ - # handle UTF-8-MAC filename on MacOS - - @to_clean.delete(output_path.realpath.to_s.encode('UTF-8', 'UTF-8-MAC')) - else - @to_clean.delete(output_path.realpath) - end - end - - # Render a resource to a file. - # - # @param [Middleman::Sitemap::Resource] resource - # @return [Pathname] The full path of the file that was written - def render_to_file(resource) - output_file = @build_dir + resource.destination_path.gsub('%20', ' ') - - if resource.binary? - if !output_file.exist? - base.say_status :create, output_file, :green - elsif FileUtils.compare_file(resource.source_file, output_file) - base.say_status :identical, output_file, :blue - return output_file + # Handles incoming events from the builder. + # @param [Symbol] event_type The type of event. + # @param [String] contents The event contents. + # @param [String] extra The extra information. + # @return [void] + def on_event(event_type, target, extra=nil) + case event_type + when :error + say_status :error, target, :red + shell.say extra, :red if options['verbose'] + when :deleted + say_status :remove, target, :green + when :created + say_status :create, target, :green + when :identical + say_status :identical, target, :blue + when :updated + say_status :updated, target, :yellow else - base.say_status :update, output_file, :yellow - end - - output_file.dirname.mkpath - FileUtils.cp(resource.source_file, output_file) - else - begin - response = @rack.get(URI.escape(resource.request_path)) - - if response.status == 200 - base.create_file(output_file, binary_encode(response.body)) - else - handle_error(output_file, response.body) - end - rescue => e - handle_error(output_file, "#{e}\n#{e.backtrace.join("\n")}", e) + say_status event_type, extra, :blue end end - output_file - end + # Find empty directories in the build folder and remove them. + # @return [Boolean] + def clean_directories! + all_build_files = File.join(@app.config[:build_dir], '**', '*') - def handle_error(file_name, response, e=Thor::Error.new(response)) - base.had_errors = true + empty_directories = Dir[all_build_files].select do |d| + File.directory?(d) + end - base.say_status :error, file_name, :red - if base.debugging - raise e - elsif base.options['verbose'] - base.shell.say response, :red + empty_directories.each do |d| + remove_file d, force: true if Pathname(d).children.empty? + end end end - - def binary_encode(string) - string.force_encoding('ascii-8bit') if string.respond_to?(:force_encoding) - string - end end -end -# Quiet down create file -class ::Thor::Actions::CreateFile - def on_conflict_behavior(&block) - if identical? - say_status :identical, :blue - else - say_status :update, :yellow - block.call unless pretend? - end - end + # Alias "b" to "build" + Base.map('b' => 'build') end diff --git a/middleman-core/fixtures/clean-app/config.rb b/middleman-core/fixtures/clean-app/config.rb index e69de29b..1326120c 100644 --- a/middleman-core/fixtures/clean-app/config.rb +++ b/middleman-core/fixtures/clean-app/config.rb @@ -0,0 +1,9 @@ +proxy "/fake.html", "/real.html", layout: false + +ignore "/should_be_ignored.html" +ignore "/should_be_ignored2.html" +proxy "/target_ignore.html", "/should_be_ignored3.html", ignore: true + +%w(one two).each do |num| + proxy "/fake/#{num}.html", "/real/index.html", locals: { num: num } +end diff --git a/middleman-core/lib/middleman-core/builder.rb b/middleman-core/lib/middleman-core/builder.rb new file mode 100644 index 00000000..23a9ac3a --- /dev/null +++ b/middleman-core/lib/middleman-core/builder.rb @@ -0,0 +1,255 @@ +require 'pathname' +require 'fileutils' +require 'tempfile' +require 'middleman-core/rack' +require 'middleman-core/contracts' + +module Middleman + class Builder + extend Forwardable + include Contracts + + # Make app & events available to `after_build` callbacks. + attr_reader :app, :events + + # Logger comes from App. + def_delegator :@app, :logger + + # Sort order, images, fonts, js/css and finally everything else. + SORT_ORDER = %w(.png .jpeg .jpg .gif .bmp .svg .svgz .ico .woff .otf .ttf .eot .js .css) + + # Create a new Builder instance. + # @param [Middleman::Application] app The app to build. + # @param [Hash] opts The builder options + def initialize(app, opts={}) + @app = app + @source_dir = Pathname(@app.source_dir) + @build_dir = Pathname(@app.config[:build_dir]) + + if @build_dir.expand_path.relative_path_from(@source_dir).to_s =~ /\A[.\/]+\Z/ + raise ":build_dir (#{@build_dir}) cannot be a parent of :source_dir (#{@source_dir})" + end + + @glob = opts.fetch(:glob) + @cleaning = opts.fetch(:clean) + + @_event_callbacks = [] + + rack_app = ::Middleman::Rack.new(@app).to_app + @rack = ::Rack::MockRequest.new(rack_app) + end + + # Run the build phase. + # @return [Boolean] Whether the build was successful. + Contract None => Bool + def run! + @has_error = false + @events = {} + + @app.run_hook :before_build, self + + queue_current_paths if @cleaning + prerender_css + output_files + clean if @cleaning + + ::Middleman::Profiling.report('build') + + # Run hooks + @app.run_hook :after_build, self + @app.config_context.execute_after_build_callbacks(self) + + !@has_error + end + + # Attach callbacks for build events. + # @return [Array] All the attached events. + Contract Proc => ArrayOf[Proc] + def on_build_event(&block) + @_event_callbacks << block if block_given? + @_event_callbacks + end + + # Pre-request CSS to give Compass a chance to build sprites + # @return [Array] List of css resources that were output. + Contract None => ResourceList + def prerender_css + logger.debug '== Prerendering CSS' + + css_files = @app.sitemap.resources.select do |resource| + resource.ext == '.css' + end.each(&method(:output_resource)) + + logger.debug '== Checking for Compass sprites' + + # Double-check for compass sprites + @app.files.find_new_files((@source_dir + @app.config[:images_dir]).relative_path_from(@app.root_path)) + @app.sitemap.ensure_resource_list_updated! + + css_files + end + + # Find all the files we need to output and do so. + # @return [Array] List of resources that were output. + Contract None => ResourceList + def output_files + logger.debug '== Building files' + + # Sort paths to be built by the above order. This is primarily so Compass can + # find files in the build folder when it needs to generate sprites for the + # css files. + # + # Loop over all the paths and build them. + @app.sitemap.resources + .sort_by { |resource| SORT_ORDER.index(resource.ext) || 100 } + .reject { |resource| resource.ext == '.css' } + .select { |resource| !@glob || File.fnmatch(@glob, resource.destination_path) } + .each(&method(:output_resource)) + end + + # Figure out the correct event mode. + # @param [Pathname] output_file The output file path. + # @param [String] source The source file path. + # @return [Symbol] + Contract Pathname, String => Symbol + def which_mode(output_file, source) + if !output_file.exist? + :created + else + FileUtils.compare_file(source.to_s, output_file.to_s) ? :identical : :updated + end + end + + # Create a tempfile for a given output with contents. + # @param [Pathname] output_file The output path. + # @param [String] contents The file contents. + # @return [Tempfile] + Contract Pathname, String => Tempfile + def write_tempfile(output_file, contents) + file = Tempfile.new([ + File.basename(output_file), + File.extname(output_file)]) + file.binmode + file.write(contents) + file.close + file + end + + # Actually export the file. + # @param [Pathname] output_file The path to output to. + # @param [String|Pathname] source The source path or contents. + # @return [void] + Contract Pathname, Or[String, Pathname] => Any + def export_file!(output_file, source) + source = write_tempfile(output_file, source.to_s) if source.is_a? String + + method, source_path = if source.is_a? Tempfile + [FileUtils.method(:mv), source.path] + else + [FileUtils.method(:cp), source.to_s] + end + + mode = which_mode(output_file, source_path) + + if mode == :created || mode == :updated + FileUtils.mkdir_p(output_file.dirname) + method.call(source_path, output_file.to_s) + end + + source.unlink if source.is_a? Tempfile + + trigger(mode, output_file) + end + + # Try to output a resource and capture errors. + # @param [Middleman::Sitemap::Resource] resource The resource. + # @return [void] + Contract IsA['Middleman::Sitemap::Resource'] => Any + def output_resource(resource) + output_file = @build_dir + resource.destination_path.gsub('%20', ' ') + + begin + if resource.binary? + export_file!(output_file, Pathname(resource.source_file)) + else + response = @rack.get(URI.escape(resource.request_path)) + + # If we get a response, save it to a tempfile. + if response.status == 200 + export_file!(output_file, binary_encode(response.body)) + else + @has_error = true + trigger(:error, output_file, response.body) + end + end + rescue => e + @has_error = true + trigger(:error, output_file, "#{e}\n#{e.backtrace.join("\n")}") + end + + return unless @cleaning + return unless output_file.exist? + + # handle UTF-8-MAC filename on MacOS + cleaned_name = if RUBY_PLATFORM =~ /darwin/ + output_file.to_s.encode('UTF-8', 'UTF-8-MAC') + else + output_file + end + + @to_clean.delete(Pathname(cleaned_name)) + end + + # Get a list of all the paths in the destination folder and save them + # for comparison against the files we build in this cycle + # @return [void] + Contract None => Any + def queue_current_paths + @to_clean = [] + + return unless File.exist?(@app.config[:build_dir]) + + paths = ::Middleman::Util.all_files_under(@app.config[:build_dir]).map do |path| + Pathname(path) + end + + @to_clean = paths.select do |path| + path.to_s !~ /\/\./ || path.to_s =~ /\.(htaccess|htpasswd)/ + end + + # handle UTF-8-MAC filename on MacOS + @to_clean = @to_clean.map do |path| + if RUBY_PLATFORM =~ /darwin/ + Pathname(path.to_s.encode('UTF-8', 'UTF-8-MAC')) + else + Pathname(path) + end + end + end + + # Remove files which were not built in this cycle + Contract None => ArrayOf[Pathname] + def clean + @to_clean.each do |f| + FileUtils.rm(f) + trigger(:deleted, f) + end + end + + Contract String => String + def binary_encode(string) + string.force_encoding('ascii-8bit') if string.respond_to?(:force_encoding) + string + end + + Contract Symbol, Or[String, Pathname], Maybe[String] => Any + def trigger(event_type, target, extra=nil) + @events[event_type] ||= [] + @events[event_type] << target + + @_event_callbacks.each do |callback| + callback.call(event_type, target, extra) + end + end + end +end diff --git a/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb b/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb index a28e6a7f..53c48877 100644 --- a/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb +++ b/middleman-core/lib/middleman-core/core_extensions/show_exceptions.rb @@ -6,7 +6,7 @@ module Middleman::CoreExtensions def initialize(app, options_hash={}, &block) super - app.config.define_setting :show_exceptions, true, 'Whether to catch and display exceptions' + app.config.define_setting :show_exceptions, !!ENV['TEST'], 'Whether to catch and display exceptions' end def after_configuration diff --git a/middleman-core/lib/middleman-core/extensions/gzip.rb b/middleman-core/lib/middleman-core/extensions/gzip.rb index 70af4ad1..36cccf5e 100644 --- a/middleman-core/lib/middleman-core/extensions/gzip.rb +++ b/middleman-core/lib/middleman-core/extensions/gzip.rb @@ -63,10 +63,10 @@ class Middleman::Extensions::Gzip < ::Middleman::Extension total_savings += (old_size - new_size) size_change_word = (old_size - new_size) > 0 ? 'smaller' : 'larger' - builder.say_status :gzip, "#{output_filename} (#{NumberHelpers.new.number_to_human_size((old_size - new_size).abs)} #{size_change_word})" + builder.trigger :gzip, "#{output_filename} (#{NumberHelpers.new.number_to_human_size((old_size - new_size).abs)} #{size_change_word})" end - builder.say_status :gzip, "Total gzip savings: #{NumberHelpers.new.number_to_human_size(total_savings)}", :blue + builder.trigger :gzip, "Total gzip savings: #{NumberHelpers.new.number_to_human_size(total_savings)}" I18n.locale = old_locale end diff --git a/middleman-core/lib/middleman-core/step_definitions/builder_steps.rb b/middleman-core/lib/middleman-core/step_definitions/builder_steps.rb index 291eb449..5582db77 100644 --- a/middleman-core/lib/middleman-core/step_definitions/builder_steps.rb +++ b/middleman-core/lib/middleman-core/step_definitions/builder_steps.rb @@ -34,12 +34,12 @@ end Given /^a built app at "([^\"]*)"$/ do |path| step %Q{a fixture app "#{path}"} - step %Q{I run `middleman build`} + step %Q{I run `middleman build --verbose`} end Given /^was successfully built$/ do - step %Q{a directory named "build" should exist} step %Q{the exit status should be 0} + step %Q{a directory named "build" should exist} end Given /^a successfully built app at "([^\"]*)"$/ do |path|