require 'yaml' require 'digest' module Plate # Used by the command line tool to generate a site in the given directory. class Builder attr_accessor :source, :destination, :options, :site, :enable_logging, :helpers def initialize(source, destination, options = {}) @source = source @destination = destination @options = Hash === options ? options.clone : {} @options.symbolize_keys! end 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 from this site build, also resets any variables for the caching and # temporary build folders so they can be reset def clear_cache! FileUtils.rm_rf(cache_location) @cache_location = nil @tmp_destination = nil @loaded = false end # Returns the options for this site. def config self.load! @options end # A unique id for this site, based off of the source directory def id check_source! @id ||= [ File.basename(source), Digest::MD5.hexdigest(source) ].collect { |s| s.to_s.downcase.parameterize }.join('-') end def items? self.total_items > 0 end def load! unless @loaded log('Site builder initialized.') self.require_plugins! self.load_config_file! self.setup_site! self.setup_tmp_directory! @loaded = true end @loaded end def relative_path(file_or_directory) file_or_directory.gsub(/^#{Regexp.quote(source)}(.*)$/, '\1') end def rebuild! log('Re-rendering site...') clear_cache! self.site.reload! self.render_site! self.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. def reloadable?(relative_file) relative_file =~ /^\/?(content|layouts|posts)\/(.*?)/ end # Called to start the rendering of the site based on the provided, source, destination and config options. def render! @start_time = Time.now log("Building full site...") self.load! self.render_site! self.copy_to_destination! @end_time = Time.now log("Site build completed in #{timer} seconds") true end def render_file!(relative_file_path) self.load! page = self.site.find(relative_file_path) 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}]") self.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}]") # Remove tmp file 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 # Total number of all assets, posts and pages. def total_items return 0 unless self.site @total_items ||= self.site.all_files.size end # Returns the time it took to run render! (in 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. def tmp_destination return @tmp_destination if @tmp_destination result = "" if self.options.has_key?(:tmp_destination) if self.options[:tmp_destination] result = File.expand_path(self.options[:tmp_destination]) end else result = File.join(cache_location, 'build-cache') end @tmp_destination = result end def tmp_destination? self.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. 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. 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+ 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 # 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(self.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 # 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 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 def render_site! if items? log("Rendering site...") paths = [] self.site.run_callback(:before_render) paths += self.site.assets.collect(&:write!) paths += self.site.pages.collect(&:write!) paths += self.site.posts.collect(&:write!) @build_paths = paths self.site.run_callback(:after_render) log("Site rendered!", :indent) else log("No assets, posts or pages found. :(") end end # Load any plugins and helpers in the ./lib folder. Any modules named with the # format SomethingHelper will automatically be loaded into all views. def require_plugins! self.helpers = [] matcher = /^#{Regexp.quote(File.join(source, 'lib'))}\/?(.*).rb$/ plugins = Dir.glob(File.join(source, "lib/**/*.rb")) if plugins.length > 0 log("Loading plugins...") plugins.each do |file| require file underscore_name = file.sub(matcher, '\1') # For helpers, make sure the module is defined, and add it to the helpers list if underscore_name =~ /(.*?)_helper$/ class_name = underscore_name.classify if defined? class_name log("Loaded helper [#{class_name}]", :indent) klass = class_name.constantize self.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. 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 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 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. 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) self.site.build_destination = tmp_destination end end end