Merge pull request #3116 from alfredxing/incremental

This commit is contained in:
Parker Moore 2014-12-22 08:57:50 -05:00
commit 7227ad4ebb
21 changed files with 458 additions and 10 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ coverage
.ruby-version .ruby-version
.sass-cache .sass-cache
tmp/stackprof-* tmp/stackprof-*
.jekyll-metadata

View File

@ -0,0 +1,60 @@
Feature: Incremental rebuild
As an impatient hacker who likes to blog
I want to be able to make a static site
Without waiting too long for it to build
Scenario: Produce correct output site
Given I have a _layouts directory
And I have a _posts directory
And I have the following posts:
| title | date | layout | content |
| Wargames | 2009-03-27 | default | The only winning move is not to play. |
And I have a default layout that contains "Post Layout: {{ content }}"
When I run jekyll build
Then the _site directory should exist
And I should see "Post Layout: <p>The only winning move is not to play.</p>" in "_site/2009/03/27/wargames.html"
When I run jekyll build
Then the _site directory should exist
And I should see "Post Layout: <p>The only winning move is not to play.</p>" in "_site/2009/03/27/wargames.html"
Scenario: Generate a metadata file
Given I have an "index.html" file that contains "Basic Site"
When I run jekyll build
Then the ".jekyll-metadata" file should exist
Scenario: Rebuild when content is changed
Given I have an "index.html" file that contains "Basic Site"
When I run jekyll build
Then the _site directory should exist
And I should see "Basic Site" in "_site/index.html"
When I wait 1 second
Then I have an "index.html" file that contains "Bacon Site"
When I run jekyll build
Then the _site directory should exist
And I should see "Bacon Site" in "_site/index.html"
Scenario: Rebuild when layout is changed
Given I have a _layouts directory
And I have an "index.html" page with layout "default" that contains "Basic Site with Layout"
And I have a default layout that contains "Page Layout: {{ content }}"
When I run jekyll build
Then the _site directory should exist
And I should see "Page Layout: Basic Site with Layout" in "_site/index.html"
When I wait 1 second
Then I have a default layout that contains "Page Layout Changed: {{ content }}"
When I run jekyll build --full-rebuild
Then the _site directory should exist
And I should see "Page Layout Changed: Basic Site with Layout" in "_site/index.html"
Scenario: Rebuild when an include is changed
Given I have a _includes directory
And I have an "index.html" page that contains "Basic Site with include tag: {% include about.textile %}"
And I have an "_includes/about.textile" file that contains "Generated by Jekyll"
When I run jekyll build
Then the _site directory should exist
And I should see "Basic Site with include tag: Generated by Jekyll" in "_site/index.html"
When I wait 1 second
Then I have an "_includes/about.textile" file that contains "Regenerated by Jekyll"
When I run jekyll build
Then the _site directory should exist
And I should see "Basic Site with include tag: Regenerated by Jekyll" in "_site/index.html"

View File

@ -133,6 +133,10 @@ Given /^I have fixture collections$/ do
FileUtils.cp_r File.join(JEKYLL_SOURCE_DIR, "test", "source", "_methods"), source_dir FileUtils.cp_r File.join(JEKYLL_SOURCE_DIR, "test", "source", "_methods"), source_dir
end end
Given /^I wait (\d+) second(s?)$/ do |time, plural|
sleep(time.to_f)
end
################## ##################
# #
# Changing stuff # Changing stuff

View File

@ -21,6 +21,7 @@ require 'time'
require 'English' require 'English'
require 'pathname' require 'pathname'
require 'logger' require 'logger'
require 'set'
# 3rd party # 3rd party
require 'safe_yaml/load' require 'safe_yaml/load'
@ -48,6 +49,7 @@ module Jekyll
autoload :Layout, 'jekyll/layout' autoload :Layout, 'jekyll/layout'
autoload :LayoutReader, 'jekyll/layout_reader' autoload :LayoutReader, 'jekyll/layout_reader'
autoload :LogAdapter, 'jekyll/log_adapter' autoload :LogAdapter, 'jekyll/log_adapter'
autoload :Metadata, 'jekyll/metadata'
autoload :Page, 'jekyll/page' autoload :Page, 'jekyll/page'
autoload :PluginManager, 'jekyll/plugin_manager' autoload :PluginManager, 'jekyll/plugin_manager'
autoload :Post, 'jekyll/post' autoload :Post, 'jekyll/post'

View File

@ -13,6 +13,7 @@ module Jekyll
# Cleans up the site's destination directory # Cleans up the site's destination directory
def cleanup! def cleanup!
FileUtils.rm_rf(obsolete_files) FileUtils.rm_rf(obsolete_files)
FileUtils.rm_rf(metadata_file) if @site.full_rebuild?
end end
private private
@ -24,6 +25,13 @@ module Jekyll
(existing_files - new_files - new_dirs + replaced_files).to_a (existing_files - new_files - new_dirs + replaced_files).to_a
end end
# Private: The metadata file storing dependency tree and build history
#
# Returns an Array with the metdata file as the only item
def metadata_file
[site.metadata.metadata_file]
end
# Private: The list of existing files, apart from those included in keep_files and hidden files. # Private: The list of existing files, apart from those included in keep_files and hidden files.
# #
# Returns a Set with the file paths # Returns a Set with the file paths

View File

@ -58,6 +58,7 @@ module Jekyll
c.option 'unpublished', '--unpublished', 'Render posts that were marked as unpublished' c.option 'unpublished', '--unpublished', 'Render posts that were marked as unpublished'
c.option 'quiet', '-q', '--quiet', 'Silence output.' c.option 'quiet', '-q', '--quiet', 'Silence output.'
c.option 'verbose', '-V', '--verbose', 'Print verbose output.' c.option 'verbose', '-V', '--verbose', 'Print verbose output.'
c.option 'full_rebuild', '-f', '--full-rebuild', 'Disable incremental rebuild.'
end end
end end

View File

@ -50,8 +50,10 @@ module Jekyll
def build(site, options) def build(site, options)
source = options['source'] source = options['source']
destination = options['destination'] destination = options['destination']
full_build = options['full_rebuild']
Jekyll.logger.info "Source:", source Jekyll.logger.info "Source:", source
Jekyll.logger.info "Destination:", destination Jekyll.logger.info "Destination:", destination
Jekyll.logger.info "Incremental build:", (full_build ? "disabled" : "enabled")
Jekyll.logger.info "Generating..." Jekyll.logger.info "Generating..."
process_site(site) process_site(site)
Jekyll.logger.info "", "done." Jekyll.logger.info "", "done."

View File

@ -0,0 +1,42 @@
module Jekyll
module Commands
class Clean < Command
class << self
def init_with_program(prog)
prog.command(:clean) do |c|
c.syntax 'clean [subcommand]'
c.description 'Clean the site (removes site output and metadata file) without building.'
c.action do |args, _|
Jekyll::Commands::Clean.process({})
end
end
end
def process(options)
options = configuration_from_options(options)
destination = options['destination']
metadata_file = File.join(options['source'], '.jekyll-metadata')
if File.directory? destination
Jekyll.logger.info "Cleaning #{destination}..."
FileUtils.rm_rf(destination)
Jekyll.logger.info "", "done."
else
Jekyll.logger.info "Nothing to do for #{destination}."
end
if File.file? metadata_file
Jekyll.logger.info "Removing #{metadata_file}..."
FileUtils.rm_rf(metadata_file)
Jekyll.logger.info "", "done."
else
Jekyll.logger.info "Nothing to do for #{metadata_file}."
end
end
end
end
end
end

View File

@ -22,6 +22,7 @@ module Jekyll
'encoding' => 'utf-8', 'encoding' => 'utf-8',
'markdown_ext' => 'markdown,mkdown,mkdn,mkd,md', 'markdown_ext' => 'markdown,mkdown,mkdn,mkd,md',
'textile_ext' => 'textile', 'textile_ext' => 'textile',
'full_rebuild' => false,
# Filtering Content # Filtering Content
'show_drafts' => nil, 'show_drafts' => nil,

View File

@ -168,6 +168,15 @@ module Jekyll
true true
end end
# Determine whether to regenerate the file based on metadata.
#
# Returns true if file needs to be regenerated
def regenerate?
asset_file? ||
data['regenerate'] ||
site.metadata.regenerate?(site.in_source_dir(relative_path))
end
# Determine whether the file should be placed into layouts. # Determine whether the file should be placed into layouts.
# #
# Returns false if the document is an asset file. # Returns false if the document is an asset file.
@ -207,6 +216,12 @@ module Jekyll
info, info,
File.join(site.config['layouts'], layout.name)) File.join(site.config['layouts'], layout.name))
# Add layout to dependency tree
site.metadata.add_dependency(
site.in_source_dir(path),
site.in_source_dir(layout.path)
)
if layout = layouts[layout.data["layout"]] if layout = layouts[layout.data["layout"]]
if used.include?(layout) if used.include?(layout)
layout = nil # avoid recursive chain layout = nil # avoid recursive chain

View File

@ -105,6 +105,13 @@ module Jekyll
!(coffeescript_file? || yaml_file?) !(coffeescript_file? || yaml_file?)
end end
# Determine whether the document should be regenerated based on metadata.
#
# Returns true if the document needs to be regenerated.
def regenerate?
data['regenerate'] || site.metadata.regenerate?(path, write?)
end
# Determine whether the file should be placed into layouts. # Determine whether the file should be placed into layouts.
# #
# Returns false if the document is either an asset file or a yaml file, # Returns false if the document is either an asset file or a yaml file,

View File

@ -8,6 +8,9 @@ module Jekyll
# Gets the name of this layout. # Gets the name of this layout.
attr_reader :name attr_reader :name
# Gets the path to this layout.
attr_reader :path
# Gets/Sets the extension of this layout. # Gets/Sets the extension of this layout.
attr_accessor :ext attr_accessor :ext
@ -26,6 +29,7 @@ module Jekyll
@site = site @site = site
@base = base @base = base
@name = name @name = name
@path = site.in_source_dir(base, name)
self.data = {} self.data = {}

121
lib/jekyll/metadata.rb Normal file
View File

@ -0,0 +1,121 @@
module Jekyll
class Metadata
attr_reader :site, :metadata, :cache
def initialize(site)
@site = site
# Read metadata from file
read_metadata
# Initialize cache to an empty hash
@cache = {}
end
# Add a path to the metadata
#
# Returns true, also on failure.
def add(path)
return true unless File.exist?(path)
metadata[path] = {
"mtime" => File.mtime(path),
"deps" => []
}
cache[path] = true
end
# Force a path to regenerate
#
# Returns true.
def force(path)
cache[path] = true
end
# Clear the metadata and cache
#
# Returns nothing
def clear
@metadata = {}
@cache = {}
end
# Checks if a path should be regenerated
#
# Returns a boolean.
def regenerate?(path, add = true)
return true if disabled?
# Check for path in cache
if cache.has_key? path
return cache[path]
end
# Check path that exists in metadata
data = metadata[path]
if data
data["deps"].each do |dependency|
if regenerate?(dependency)
return cache[dependency] = cache[path] = true
end
end
if data["mtime"].eql? File.mtime(path)
return cache[path] = false
else
return !add || add(path)
end
end
# Path does not exist in metadata, add it
return !add || add(path)
end
# Add a dependency of a path
#
# Returns nothing.
def add_dependency(path, dependency)
return if (metadata[path].nil? || @disabled)
metadata[path]["deps"] << dependency unless metadata[path]["deps"].include? dependency
regenerate? dependency
end
# Write the metadata to disk
#
# Returns nothing.
def write
File.open(metadata_file, 'w') do |f|
f.write(metadata.to_yaml)
end
end
# Produce the absolute path of the metadata file
#
# Returns the String path of the file.
def metadata_file
site.in_source_dir('.jekyll-metadata')
end
# Check if metadata has been disabled
#
# Returns a Boolean (true for disabled, false for enabled).
def disabled?
@disabled = site.full_rebuild? if @disabled.nil?
@disabled
end
private
# Read metadata from the metadata file, if no file is found,
# initialize with an empty hash
#
# Returns the read metadata.
def read_metadata
@metadata = if !disabled? && File.file?(metadata_file)
SafeYAML.load(File.read(metadata_file))
else
{}
end
end
end
end

View File

@ -138,6 +138,12 @@ module Jekyll
File.join(site.config['layouts'], layout.name) File.join(site.config['layouts'], layout.name)
) )
# Add layout to dependency tree
site.metadata.add_dependency(
site.in_source_dir(document.path),
site.in_source_dir(layout.path)
) if document.write?
if layout = site.layouts[layout.data["layout"]] if layout = site.layouts[layout.data["layout"]]
if used.include?(layout) if used.include?(layout)
layout = nil # avoid recursive chain layout = nil # avoid recursive chain

View File

@ -11,6 +11,7 @@ module Jekyll
:gems, :plugin_manager :gems, :plugin_manager
attr_accessor :converters, :generators attr_accessor :converters, :generators
attr_reader :metadata
# Public: Initialize a new Site. # Public: Initialize a new Site.
# #
@ -27,6 +28,9 @@ module Jekyll
@source = File.expand_path(config['source']).freeze @source = File.expand_path(config['source']).freeze
@dest = File.expand_path(config['destination']).freeze @dest = File.expand_path(config['destination']).freeze
# Build metadata
@metadata = Metadata.new(self)
self.plugin_manager = Jekyll::PluginManager.new(self) self.plugin_manager = Jekyll::PluginManager.new(self)
self.plugins = plugin_manager.plugins_path self.plugins = plugin_manager.plugins_path
@ -289,13 +293,13 @@ module Jekyll
collections.each do |label, collection| collections.each do |label, collection|
collection.docs.each do |document| collection.docs.each do |document|
document.output = Jekyll::Renderer.new(self, document).run document.output = Jekyll::Renderer.new(self, document).run if document.regenerate?
end end
end end
payload = site_payload payload = site_payload
[posts, pages].flatten.each do |page_or_post| [posts, pages].flatten.each do |page_or_post|
page_or_post.render(layouts, payload) page_or_post.render(layouts, payload) if page_or_post.regenerate?
end end
rescue Errno::ENOENT => e rescue Errno::ENOENT => e
# ignore missing layout dir # ignore missing layout dir
@ -312,7 +316,10 @@ module Jekyll
# #
# Returns nothing. # Returns nothing.
def write def write
each_site_file { |item| item.write(dest) } each_site_file { |item|
item.write(dest) if item.regenerate?
}
metadata.write unless full_rebuild?
end end
# Construct a Hash of Posts indexed by the specified Post attribute. # Construct a Hash of Posts indexed by the specified Post attribute.
@ -483,6 +490,13 @@ module Jekyll
@frontmatter_defaults ||= FrontmatterDefaults.new(self) @frontmatter_defaults ||= FrontmatterDefaults.new(self)
end end
# Whether to perform a full rebuild without metadata
#
# Returns a Boolean: true for a full rebuild, false for normal build
def full_rebuild?(override = {})
override['full_rebuild'] || config['full_rebuild']
end
private private
def has_relative_page? def has_relative_page?

View File

@ -67,6 +67,8 @@ module Jekyll
true true
end end
alias_method :regenerate?, :write?
# Write the static file to the destination directory (if modified). # Write the static file to the destination directory (if modified).
# #
# dest - The String path to the destination dir. # dest - The String path to the destination dir.

View File

@ -105,13 +105,22 @@ eos
end end
def render(context) def render(context)
site = context.registers[:site]
dir = resolved_includes_dir(context) dir = resolved_includes_dir(context)
file = render_variable(context) || @file file = render_variable(context) || @file
validate_file_name(file) validate_file_name(file)
path = File.join(dir, file) path = File.join(dir, file)
validate_path(path, dir, context.registers[:site].safe) validate_path(path, dir, site.safe)
# Add include to dependency tree
if context.registers[:page] and context.registers[:page].has_key? "path"
site.metadata.add_dependency(
site.in_source_dir(context.registers[:page]["path"]),
path
)
end
begin begin
partial = Liquid::Template.parse(source(path, context)) partial = Liquid::Template.parse(source(path, context))

View File

@ -49,6 +49,7 @@ class Test::Unit::TestCase
def clear_dest def clear_dest
FileUtils.rm_rf(dest_dir) FileUtils.rm_rf(dest_dir)
FileUtils.rm_rf(source_dir('.jekyll-metadata'))
end end
def test_dir(*subdirs) def test_dir(*subdirs)

View File

@ -245,8 +245,9 @@ class TestDocument < Test::Unit::TestCase
"output" => true "output" => true
} }
}, },
"source" => source_dir, "source" => source_dir,
"destination" => dest_dir "destination" => dest_dir,
"full_rebuild" => true
})) }))
@site.process @site.process
@document = @site.collections["slides"].files.find { |doc| doc.relative_path == "_slides/octojekyll.png" } @document = @site.collections["slides"].files.find { |doc| doc.relative_path == "_slides/octojekyll.png" }

140
test/test_metadata.rb Normal file
View File

@ -0,0 +1,140 @@
require 'helper'
class TestMetadata < Test::Unit::TestCase
context "The site metadata" do
setup do
FileUtils.rm_rf(source_dir(".jekyll-metadata"))
@site = Site.new(Jekyll.configuration({
"source" => source_dir,
"destination" => dest_dir
}))
@site.process
@path = @site.in_source_dir(@site.pages.first.path)
@metadata = @site.metadata
end
should "store modification times" do
assert_equal File.mtime(@path), @metadata.metadata[@path]["mtime"]
end
should "cache processed entries" do
assert @metadata.cache[@path]
end
should "write to the metadata file" do
@metadata.clear
@metadata.add(@path)
@metadata.write
assert File.file?(source_dir(".jekyll-metadata"))
end
should "read from the metadata file" do
@metadata = Metadata.new(@site)
assert_equal File.mtime(@path), @metadata.metadata[@path]["mtime"]
end
# Methods
should "be able to add a path to the metadata" do
@metadata.clear
@metadata.add(@path)
assert_equal File.mtime(@path), @metadata.metadata[@path]["mtime"]
assert_equal [], @metadata.metadata[@path]["deps"]
assert @metadata.cache[@path]
end
should "return true on nonexistent path" do
@metadata.clear
assert @metadata.add("/bogus/path.md")
assert @metadata.regenerate?("/bogus/path.md")
end
should "be able to force a path to regenerate" do
@metadata.clear
@metadata.force(@path)
assert @metadata.cache[@path]
assert @metadata.regenerate?(@path)
end
should "be able to clear metadata and cache" do
@metadata.clear
@metadata.add(@path)
assert_equal 1, @metadata.metadata.length
assert_equal 1, @metadata.cache.length
@metadata.clear
assert_equal 0, @metadata.metadata.length
assert_equal 0, @metadata.cache.length
end
should "not regenerate a path if it is not modified" do
@metadata.clear
@metadata.add(@path)
@metadata.write
@metadata = Metadata.new(@site)
assert !@metadata.regenerate?(@path)
end
should "not regenerate if path in cache is false" do
@metadata.clear
@metadata.add(@path)
@metadata.write
@metadata = Metadata.new(@site)
assert !@metadata.regenerate?(@path)
assert !@metadata.cache[@path]
assert !@metadata.regenerate?(@path)
end
should "regenerate if path in not in metadata" do
@metadata.clear
@metadata.add(@path)
assert @metadata.regenerate?(@path)
end
should "regenerate if path in cache is true" do
@metadata.clear
@metadata.add(@path)
assert @metadata.regenerate?(@path)
assert @metadata.cache[@path]
assert @metadata.regenerate?(@path)
end
should "regenerate if file is modified" do
@metadata.clear
@metadata.add(@path)
@metadata.metadata[@path]["mtime"] = Time.at(0)
@metadata.write
@metadata = Metadata.new(@site)
assert_not_same File.mtime(@path), @metadata.metadata[@path]["mtime"]
assert @metadata.regenerate?(@path)
end
should "regenerate if dependency is modified" do
@metadata.clear
@metadata.add(@path)
@metadata.write
@metadata = Metadata.new(@site)
@metadata.add_dependency(@path, "new.dependency")
assert_equal ["new.dependency"], @metadata.metadata[@path]["deps"]
assert @metadata.regenerate?("new.dependency")
assert @metadata.regenerate?(@path)
end
should "regenerate everything if metadata is disabled" do
@site.config["full_rebuild"] = true
@metadata.clear
@metadata.add(@path)
@metadata.write
@metadata = Metadata.new(@site)
assert @metadata.regenerate?(@path)
end
end
end

View File

@ -99,6 +99,7 @@ class TestSite < Test::Unit::TestCase
should "write only modified static files" do should "write only modified static files" do
clear_dest clear_dest
StaticFile.reset_cache StaticFile.reset_cache
@site.metadata.clear
@site.process @site.process
some_static_file = @site.static_files[0].path some_static_file = @site.static_files[0].path
@ -128,6 +129,7 @@ class TestSite < Test::Unit::TestCase
should "write static files if not modified but missing in destination" do should "write static files if not modified but missing in destination" do
clear_dest clear_dest
StaticFile.reset_cache StaticFile.reset_cache
@site.metadata.clear
@site.process @site.process
some_static_file = @site.static_files[0].path some_static_file = @site.static_files[0].path
@ -241,6 +243,7 @@ class TestSite < Test::Unit::TestCase
context 'with orphaned files in destination' do context 'with orphaned files in destination' do
setup do setup do
clear_dest clear_dest
@site.metadata.clear
@site.process @site.process
# generate some orphaned files: # generate some orphaned files:
# single file # single file
@ -328,7 +331,7 @@ class TestSite < Test::Unit::TestCase
end end
bad_processor = "Custom::Markdown" bad_processor = "Custom::Markdown"
s = Site.new(site_configuration('markdown' => bad_processor)) s = Site.new(site_configuration('markdown' => bad_processor, 'full_rebuild' => true))
assert_raise Jekyll::Errors::FatalException do assert_raise Jekyll::Errors::FatalException do
s.process s.process
end end
@ -348,7 +351,7 @@ class TestSite < Test::Unit::TestCase
should 'throw FatalException at process time' do should 'throw FatalException at process time' do
bad_processor = 'not a processor name' bad_processor = 'not a processor name'
s = Site.new(site_configuration('markdown' => bad_processor)) s = Site.new(site_configuration('markdown' => bad_processor, 'full_rebuild' => true))
assert_raise Jekyll::Errors::FatalException do assert_raise Jekyll::Errors::FatalException do
s.process s.process
end end
@ -418,7 +421,9 @@ class TestSite < Test::Unit::TestCase
context "manipulating the Jekyll environment" do context "manipulating the Jekyll environment" do
setup do setup do
@site = Site.new(site_configuration) @site = Site.new(site_configuration({
'full_rebuild' => true
}))
@site.process @site.process
@page = @site.pages.find { |p| p.name == "environment.html" } @page = @site.pages.find { |p| p.name == "environment.html" }
end end
@ -430,7 +435,9 @@ class TestSite < Test::Unit::TestCase
context "in production" do context "in production" do
setup do setup do
ENV["JEKYLL_ENV"] = "production" ENV["JEKYLL_ENV"] = "production"
@site = Site.new(site_configuration) @site = Site.new(site_configuration({
'full_rebuild' => true
}))
@site.process @site.process
@page = @site.pages.find { |p| p.name == "environment.html" } @page = @site.pages.find { |p| p.name == "environment.html" }
end end