require 'yaml' require 'digest' module Plate # The builder is used to compile create a site from a source directory and output it to a destination. # # In most cases, the Builder is called directly from the command line tools (see {Plate::CLI}) and does # not need to be called directly. class Builder include Callbacks # @return [String] The destination directory path where the site should be placed upon a completed build. attr_accessor :destination # @return [Boolean] Is logging enabled for this build? Set using the `--verbose` command line option. attr_accessor :enable_logging # @return [Hash] A hash of meta values loaded from the config file in the site's source. attr_accessor :metadata # @return [Hash] The options hash for this site build. attr_accessor :options # @return [Site] The site instance for this build. attr_accessor :site # @return [String] The source directory path where the site's content is loaded from. attr_accessor :source # @return [Array] A list of helper classes loaded from the `source/helpers` directory. attr_reader :helpers # Create a new instance of the builder for the given site source, destination # and options. # # In most cases this should be initialized from the command line utilities. def initialize(source, destination, options = {}) @source = source @destination = destination @metadata = {} @options = Hash === options ? options.clone : {} @options.symbolize_keys! end # The directory where the build details are cached. Caching is used to store # temporary data, and as a temporary spot to build the site in before it is # moved to the destination directory. # # Caching is probably a bad name for this since nothing is truly "cached", it is # merely a temporary directory. # # The directory can be set in a site options, or uses the default which is in the user's # current home directory under ~/.plate... # # @return [String] def cache_location return @cache_location if @cache_location if self.options.has_key?(:cache_location) @cache_location ||= File.expand_path(self.options[:cache_location]) else @cache_location ||= File.expand_path("~/.plate/#{self.id}") end end # Remove any caches and temporary data from this site build. def clear_cache! FileUtils.rm_rf(cache_location) @cache_location = nil @tmp_destination = nil @loaded = false end # Loads the site, if not already loaded and returns the options listed. # # @return [Hash] def config load! @options end # A unique id for this site, based off of the source directory. # # @return [String] def id check_source! @id ||= [ File.basename(source), Digest::MD5.hexdigest(source) ].collect { |s| s.to_s.downcase.parameterize }.join('-') end # Are there any items loaded within this site build? Returns false if there are no items. # # When builing a site from the source directory and there are no items persent, the builder # will exit. # # @return [Boolean] def items? self.total_items > 0 end # Read the source directory from the file system, configure the {Plate::Site} instance, # and load any helpers and plugins. # # The loaded data is cached so this method can safely be called multiple times without # having to read from the filesystem more than once. # # @return [Boolean] Was the source loaded? def load! unless @loaded log('Site builder initialized.') require_plugins! load_config_file! setup_site! setup_tmp_directory! @loaded = true end @loaded end # Utility method to grab the relative path of a specific file from the site's source. # # @return [String] File path relative to source directory. def relative_path(file_or_directory) file_or_directory.gsub(/^#{Regexp.quote(source)}(.*)$/, '\1') end # Rebuilds the entire site from source. Used when watching a directory for changes. # # @return [Boolean] def rebuild! log('Re-rendering site...') clear_cache! site.reload! render_site! copy_to_destination! true end # When watching a directory for changes, allow reloading of site content based on modifications # only in the content, layouts and posts folder. Changes to config or lib files will need to # be reloaded manually. # # @return [Boolean] def reloadable?(relative_file) relative_file =~ /^\/?(content|layouts|posts)\/(.*?)/ end # Start the rendering of the site based on the provided, source, destination and # config options. # # The site will be loaded if it has not already been read from source, and rendered first to # the temporary {#cache_location} # # @return [Boolean] def render! @start_time = Time.now around_callback :render do log("Building full site...") load! render_site! copy_to_destination! @end_time = Time.now log("Site build completed in #{timer} seconds") end true end # Render a single file from the source to destination directories. This is used # when watching a site for changes and recompiling a specific file on demand. # # For layout files, the entire site is reloaded. For all other files, only that file # and corresponding destination file are reloaded. # # This may produce some unexpected behavior due to plugins and helpers not being reloaded and # is still considered experimental. # # @return [Boolean] def render_file!(relative_file_path) load! page = site.find(relative_file_path) # Ensure that the file path it is trying to reload actually exists if page and page.file? # If the file is a layout, rebuild all pages using it if Layout === page page.reload! log("Building layout [#{page.relative_file}]") # Rebuild all pages using that particular layout. site.find_by_layout(page.relative_file).each do |layout_page| self.render_file!(layout_page.relative_file) end else log("Building file [#{page.relative_file}]") # Does the file have an existing temporary file in the {#cache_location} existing_tmp = File.join(tmp_destination, page.file_path) if File.exists?(existing_tmp) FileUtils.rm_rf(existing_tmp) end page.reload! page.write! # File should exist again, even though we just removed it since we re-wrote it. if File.exists?(existing_tmp) existing = File.join(destination, page.file_path) if File.exists?(existing) log("Removing existing file [#{existing}]", :indent) FileUtils.rm_rf(existing) end FileUtils.mkdir_p(File.dirname(existing)) FileUtils.cp(existing_tmp, existing) log("File build complete.", :indent) end end else log("Cannot render file, it doesn't exist. [#{relative_file_path}]") end true end # The total number of all assets, layouts pages and posts in the source # directory. # # @return [Integer] def total_items return 0 unless site @total_items ||= site.all_files.size end # Returns the time it took to run render! (in milliseconds) # # @return [Float] Milliseconds def timer return 0 unless @end_time and @start_time (@end_time - @start_time).round end # The directory path of where to put the files while the site is being built. # # If this value is nil, no temporary directory is used and files are built # directly in the normal destination folder. # # @return [String] Full file path def tmp_destination return @tmp_destination if @tmp_destination result = "" if options.has_key?(:tmp_destination) if options[:tmp_destination] result = File.expand_path(options[:tmp_destination]) end else result = File.join(cache_location, 'build-cache') end @tmp_destination = result end # Is this site using a temporary destination location? # # @return [Boolean] def tmp_destination? tmp_destination.to_s.size > 0 end protected # Allows process to continue if the source directory exists. If the source directory does not # exist, raise a source does not exist error. # # @private def check_source! raise SourceNotFound unless directory_exists?(source) end # Copy all files from within the tmp/ build directory into the actual destination. # # Warning: This will overwrite any files already in the destination. # # @private def copy_to_destination! if items? self.setup_destination! if tmp_destination? log("Copying content to destination directory") FileUtils.cp_r(Dir.glob("#{tmp_destination}**/*"), destination) end end end # Utility method for switching between ruby 1.8* and 1.9+ # # @private def directory_exists?(dir) Dir.respond_to?(:exists?) ? Dir.exists?(dir) : File.directory?(dir) end # Loads the configuration options to use for rendering this site. By default, this information # is loaded from a file located in config/plate.yml. If this file does not exist, no config # data is loaded by default. # # You can specific additional options by passing them into the options block of this class: # # ## Custom Config File # # To load a different file, pass in the relative path of that file to the source root into the :config # option: # # Builder.new(source, destination, :config => 'config/other-file.yml') # # On the command line when building a site, or creating a new post, you can specify the # custom config file as a command line option as well: # # plate build --config config/other-file.yml # # @return [Hash] Options hash. Also set to {#options} def load_config_file! config_file = 'config/plate.yml' # Check for provided config options if options.has_key?(:config) # If config is false, just return without loading anything. if options[:config] == false log("Skipping config file load.") config_file = false # If something is provided for config set the config_file else config_file = options[:config] end end if config_file config_file_path = File.join(source, config_file) log("Checking for config file... [#{config_file_path}]") # If the file doesn't exist, just ignore it. If the file exists, load and parse it. if File.exists?(config_file_path) yml = YAML.load_file(config_file_path) if yml yml.symbolize_keys! yml.values.select { |value| Hash === value }.each { |hash| hash.symbolize_keys! } @options = @options.reverse_merge(yml) log("Options loaded from file", :indent) end end end # If meta data was provided, add it to the site's meta data instance if @options.has_key?(:meta) and Hash === @options[:meta] @metadata = @options[:meta] @metadata.symbolize_keys! @options.delete(:meta) end # If a custom destination was provided in the config file, use it. if @options.has_key?(:destination) @destination = File.expand_path(@options[:destination]) end # Make sure that the defaults are available. @options.reverse_merge!({ :permalink => '/:category/:year/:month/:slug' }) end # Write to the log if enable_logging is enabled # # @private def log(message, style = :arrow) prefix = { :arrow => ' -> ', :indent => ' ' }[style] || style puts "#{prefix}#{message}" if !!enable_logging end # Build out the site and store it in the destination directory. # # @private def render_site! if items? log("Rendering site...") paths = [] site.run_callback(:before_render) paths += site.assets.collect(&:write!) paths += site.pages.collect(&:write!) paths += site.posts.collect(&:write!) @build_paths = paths site.run_callback(:after_render) log("Site rendered!", :indent) else log("No assets, posts or pages found. :(") end end # Load any plugins in the ./lib folder and helper modules in the ./helpers folder. # # Any modules named with the format `SomethingHelper` will automatically be loaded # into all views. # # @private def require_plugins! # For plugins, load all .rb files in the lib directory, regardless of name. plugin_files = Dir.glob(File.join(source, 'lib/**/*.rb')) if plugin_files.length > 0 log("Loading plugins...") plugin_files.each do |file| Dsl.evaluate_plugin(file) end end # For helpers, load all files that match `abc_helper.rb`, then check to make sure # there is an appropriately named Module within that file. @helpers = [] helper_files = Dir.glob(File.join(source, 'helpers/**/*.rb')) matcher = /^#{Regexp.quote(File.join(source, 'helpers'))}\/?(.*).rb$/ if helper_files.length > 0 helper_files.each do |file| underscore_name = file.sub(matcher, '\1') if underscore_name =~ /(.*?)_helper$/ require file class_name = underscore_name.classify if defined? class_name log("Loaded helper [#{class_name}]", :indent) klass = class_name.constantize @helpers << klass View.send(:include, klass) end end end end end # Clear out the destination directory, if it exists. Leave the root of the # destination itself, but clear any files within it. # # @private def setup_destination! if directory_exists?(destination) log("Clearing destination directory [#{destination}]") FileUtils.rm_r(Dir.glob("#{destination}**/*"), :force => true) elsif items? log("Creating destination directory [#{destination}]") FileUtils.mkdir_p(destination) end end # Setup the Site instance and prepare it for loading # # @private def setup_site! log("Setting up site instance") self.site = Site.new(source, destination, options) self.site.logger = self self.site.cache_location = self.cache_location self.site.metadata = self.metadata log("Site data loaded from source") end # Create a temporary folder to build everything in. Once the build was successful, # all files will then be placed into the actual destination. # # @private def setup_tmp_directory! return unless tmp_destination? log("Setting up tmp build directory [#{tmp_destination}]") # Clear out any existing tmp folder contents if directory_exists?(tmp_destination) log("Clearing existing tmp directory content") FileUtils.rm_rf(tmp_destination) end FileUtils.mkdir_p(tmp_destination) site.build_destination = tmp_destination end end end