Incrementally rebuild when a data file is changed (#8771)
Merge pull request 8771
This commit is contained in:
parent
d45fb96477
commit
160a6816af
|
@ -67,6 +67,59 @@ Feature: Incremental rebuild
|
|||
And the _site directory should exist
|
||||
And I should see "Basic Site with include tag: Regenerated by Jekyll" in "_site/index.html"
|
||||
|
||||
Scenario: Rebuild when a data file is changed
|
||||
Given I have a _data directory
|
||||
And I have a "_data/colors.yml" file that contains "[red, green, blue]"
|
||||
And I have a _data/members/core directory
|
||||
And I have a "_data/members/core/emeritus.yml" file with content:
|
||||
"""
|
||||
- name: John Doe
|
||||
role: Admin
|
||||
"""
|
||||
And I have an _includes directory
|
||||
And I have an "_includes/about.html" file with content:
|
||||
"""
|
||||
<ul>
|
||||
{% for entry in site.data.members.core.emeritus %}
|
||||
<li title="{{ entry.name }} -- {{ entry.role }}">{{ entry.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
"""
|
||||
And I have a _layouts directory
|
||||
And I have a page layout that contains "{{ content }}\n\n{% include about.html %}"
|
||||
And I have a home layout that contains "{{ content }}\n\nGenerated by Jekyll"
|
||||
And I have a "_layouts/post.html" page with layout "page" that contains "{{ content }}"
|
||||
And I have a "_layouts/static.html" page with layout "home" that contains "{{ content }}"
|
||||
And I have an "index.html" page with layout "home" that contains "{{ site.data.colors | join: '_' }}"
|
||||
And I have an "about.html" page with layout "page" that contains "About Us"
|
||||
And I have a configuration file with "collections_dir" set to "collections"
|
||||
And I have a collections/_posts directory
|
||||
And I have the following post within the "collections" directory:
|
||||
| title | date | layout | content |
|
||||
| Table | 2009-03-26 | post | Post with data dependency |
|
||||
| Wargames | 2009-03-27 | static | Post without data dependency |
|
||||
When I run jekyll build -IV
|
||||
Then I should get a zero exit status
|
||||
And the _site directory should exist
|
||||
And I should see "red_green_blue" in "_site/index.html"
|
||||
And I should see "John Doe -- Admin" in "_site/about.html"
|
||||
And I should see "Rendering: index.html" in the build output
|
||||
And I should see "Rendering: _posts/2009-03-27-wargames.markdown" in the build output
|
||||
When I wait 1 second
|
||||
Then I have a "_data/members/core/emeritus.yml" file with content:
|
||||
"""
|
||||
- name: Jane Doe
|
||||
role: Admin
|
||||
"""
|
||||
When I run jekyll build -IV
|
||||
Then I should get a zero exit status
|
||||
And the _site directory should exist
|
||||
And I should see "red_green_blue" in "_site/index.html"
|
||||
And I should see "Jane Doe -- Admin" in "_site/about.html"
|
||||
And I should see "Rendering: _posts/2009-03-26-table.markdown" in the build output
|
||||
But I should not see "Rendering: index.html" in the build output
|
||||
And I should not see "Rendering: _posts/2009-03-27-wargames.markdown" in the build output
|
||||
|
||||
Scenario: Rebuild when a dependency of document in custom collection_dir is changed
|
||||
Given I have a _includes directory
|
||||
And I have a configuration file with "collections_dir" set to "collections"
|
||||
|
|
|
@ -45,6 +45,8 @@ module Jekyll
|
|||
autoload :Collection, "jekyll/collection"
|
||||
autoload :Configuration, "jekyll/configuration"
|
||||
autoload :Convertible, "jekyll/convertible"
|
||||
autoload :DataEntry, "jekyll/data_entry"
|
||||
autoload :DataHash, "jekyll/data_hash"
|
||||
autoload :Deprecator, "jekyll/deprecator"
|
||||
autoload :Document, "jekyll/document"
|
||||
autoload :EntryFilter, "jekyll/entry_filter"
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Jekyll
|
||||
class DataEntry
|
||||
attr_accessor :context
|
||||
attr_reader :data
|
||||
|
||||
# Create a Jekyll wrapper for given parsed data object.
|
||||
#
|
||||
# site - The current Jekyll::Site instance.
|
||||
# abs_path - Absolute path to the data source file.
|
||||
# parsed_data - Parsed representation of data source file contents.
|
||||
#
|
||||
# Returns nothing.
|
||||
def initialize(site, abs_path, parsed_data)
|
||||
@site = site
|
||||
@path = abs_path
|
||||
@data = parsed_data
|
||||
end
|
||||
|
||||
# Liquid representation of current instance is the parsed data object.
|
||||
#
|
||||
# Mark as a dependency for regeneration here since every renderable object primarily uses the
|
||||
# parsed data object while the parent resource is being rendered by Liquid. Accessing the data
|
||||
# object directly via Ruby interface `#[]()` is outside the scope of regeneration.
|
||||
#
|
||||
# FIXME: Marking as dependency on every call is non-ideal. Optimize at later day.
|
||||
#
|
||||
# Returns the parsed data object.
|
||||
def to_liquid
|
||||
add_regenerator_dependencies if incremental_build?
|
||||
@data
|
||||
end
|
||||
|
||||
# -- Overrides to maintain backwards compatibility --
|
||||
|
||||
# Any missing method will be forwarded to the underlying data object stored in the instance
|
||||
# variable `@data`.
|
||||
def method_missing(method, *args, &block)
|
||||
@data.respond_to?(method) ? @data.send(method, *args, &block) : super
|
||||
end
|
||||
|
||||
def respond_to_missing?(method, *)
|
||||
@data.respond_to?(method) || super
|
||||
end
|
||||
|
||||
def <=>(other)
|
||||
data <=> (other.is_a?(self.class) ? other.data : other)
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
data == (other.is_a?(self.class) ? other.data : other)
|
||||
end
|
||||
|
||||
# Explicitly defined to bypass re-routing from `method_missing` hook for greater performance.
|
||||
#
|
||||
# Returns string representation of parsed data object.
|
||||
def inspect
|
||||
@data.inspect
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def incremental_build?
|
||||
@incremental = @site.config["incremental"] if @incremental.nil?
|
||||
@incremental
|
||||
end
|
||||
|
||||
def add_regenerator_dependencies
|
||||
page = context.registers[:page]
|
||||
return unless page&.key?("path")
|
||||
|
||||
absolute_path = \
|
||||
if page["collection"]
|
||||
@site.in_source_dir(@site.config["collections_dir"], page["path"])
|
||||
else
|
||||
@site.in_source_dir(page["path"])
|
||||
end
|
||||
|
||||
@site.regenerator.add_dependency(absolute_path, @path)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,61 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Jekyll
|
||||
# A class that behaves very similar to Ruby's `Hash` class yet different in how it is handled by
|
||||
# Liquid. This class emulates Hash by delegation instead of inheritance to minimize overridden
|
||||
# methods especially since some Hash methods returns another Hash instance instead of the
|
||||
# subclass instance.
|
||||
class DataHash
|
||||
#
|
||||
# Delegate given (zero-arity) method(s) to the Hash object stored in instance variable
|
||||
# `@registry`.
|
||||
# NOTE: Avoiding the use of `Forwardable` module's `def_delegators` for preventing unnecessary
|
||||
# creation of interim objects on multiple calls.
|
||||
def self.delegate_to_registry(*symbols)
|
||||
symbols.each { |sym| define_method(sym) { @registry.send(sym) } }
|
||||
end
|
||||
private_class_method :delegate_to_registry
|
||||
|
||||
# -- core instance methods --
|
||||
|
||||
attr_accessor :context
|
||||
|
||||
def initialize
|
||||
@registry = {}
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@registry[key].tap do |value|
|
||||
value.context = context if value.respond_to?(:context=)
|
||||
end
|
||||
end
|
||||
|
||||
# `Hash#to_liquid` returns the Hash instance itself.
|
||||
# Mimic that behavior by returning `self` instead of returning the `@registry` variable value.
|
||||
def to_liquid
|
||||
self
|
||||
end
|
||||
|
||||
# -- supplementary instance methods to emulate Hash --
|
||||
|
||||
delegate_to_registry :freeze, :inspect
|
||||
|
||||
def merge(other, &block)
|
||||
merged_registry = @registry.merge(other, &block)
|
||||
dup.tap { |d| d.instance_variable_set(:@registry, merged_registry) }
|
||||
end
|
||||
|
||||
def merge!(other, &block)
|
||||
@registry.merge!(other, &block)
|
||||
self
|
||||
end
|
||||
|
||||
def method_missing(method, *args, &block)
|
||||
@registry.send(method, *args, &block)
|
||||
end
|
||||
|
||||
def respond_to_missing?(method, *)
|
||||
@registry.respond_to?(method)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -7,7 +7,6 @@ module Jekyll
|
|||
|
||||
mutable false
|
||||
|
||||
delegate_method_as :site_data, :data
|
||||
delegate_methods :time, :pages, :static_files, :tags, :categories
|
||||
|
||||
private delegate_method_as :config, :fallback_data
|
||||
|
@ -24,6 +23,12 @@ module Jekyll
|
|||
(key != "posts" && @obj.collections.key?(key)) || super
|
||||
end
|
||||
|
||||
def data
|
||||
@obj.site_data.tap do |value|
|
||||
value.context = @context if value.respond_to?(:context=)
|
||||
end
|
||||
end
|
||||
|
||||
def posts
|
||||
@site_posts ||= @obj.posts.docs.sort { |a, b| b <=> a }
|
||||
end
|
||||
|
|
|
@ -6,7 +6,7 @@ module Jekyll
|
|||
|
||||
def initialize(site, in_source_dir: nil)
|
||||
@site = site
|
||||
@content = {}
|
||||
@content = DataHash.new
|
||||
@entry_filter = EntryFilter.new(site)
|
||||
@in_source_dir = in_source_dir || @site.method(:in_source_dir)
|
||||
@source_dir = @in_source_dir.call("/")
|
||||
|
@ -24,6 +24,8 @@ module Jekyll
|
|||
@content
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize
|
||||
|
||||
# Read and parse all .yaml, .yml, .json, .csv and .tsv
|
||||
# files under <dir> and add them to the <data> variable.
|
||||
#
|
||||
|
@ -43,13 +45,14 @@ module Jekyll
|
|||
next if @entry_filter.symlink?(path)
|
||||
|
||||
if File.directory?(path)
|
||||
read_data_to(path, data[sanitize_filename(entry)] = {})
|
||||
read_data_to(path, data[sanitize_filename(entry)] = DataHash.new)
|
||||
else
|
||||
key = sanitize_filename(File.basename(entry, ".*"))
|
||||
data[key] = read_data_file(path)
|
||||
data[key] = DataEntry.new(site, path, read_data_file(path))
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize
|
||||
|
||||
# Determines how to read a data file.
|
||||
#
|
||||
|
|
|
@ -47,7 +47,14 @@ module Jekyll
|
|||
end
|
||||
|
||||
def mergable?(value)
|
||||
value.is_a?(Hash) || value.is_a?(Drops::Drop)
|
||||
case value
|
||||
when Hash, Drops::Drop, DataHash
|
||||
true
|
||||
when DataEntry
|
||||
mergable?(value.data)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def duplicable?(obj)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
true
|
|
@ -0,0 +1,4 @@
|
|||
- java
|
||||
- ruby
|
||||
- rust
|
||||
- golang
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "helper"
|
||||
|
||||
class TestDataEntry < JekyllUnitTest
|
||||
context "Data Entry" do
|
||||
setup do
|
||||
site = fixture_site
|
||||
site.read
|
||||
@data_hash = site.site_data
|
||||
end
|
||||
|
||||
should "expose underlying data object es Liquid representation" do
|
||||
subject = @data_hash["languages"]
|
||||
assert_equal Jekyll::DataEntry, subject.class
|
||||
assert_equal subject.data, subject.to_liquid
|
||||
end
|
||||
|
||||
should "respond to `#[](key)` when expected to but raise Exception otherwise" do
|
||||
greeting = @data_hash["greetings"]
|
||||
assert greeting["foo"]
|
||||
|
||||
boolean = @data_hash["boolean"] # the value is a Boolean.
|
||||
assert_raises(NoMethodError) { boolean["foo"] }
|
||||
end
|
||||
|
||||
should "compare with another instance of same class using underlying data" do
|
||||
assert_equal(
|
||||
[%w(java ruby), %w(java ruby rust golang)],
|
||||
[@data_hash["languages_plus"], @data_hash["languages"]].sort
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,57 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "helper"
|
||||
|
||||
class TestDataHash < JekyllUnitTest
|
||||
context "Data Hash" do
|
||||
setup do
|
||||
@site = fixture_site
|
||||
@site.read
|
||||
end
|
||||
|
||||
should "only mimic a ::Hash instance" do
|
||||
subject = @site.site_data
|
||||
assert_equal Jekyll::DataHash, subject.class
|
||||
refute subject.is_a?(Hash)
|
||||
|
||||
copy = subject.dup
|
||||
assert copy["greetings"]["foo"]
|
||||
assert_includes copy.dig("greetings", "foo"), "Hello!"
|
||||
|
||||
copy["greetings"] = "Hola!"
|
||||
assert_equal "Hola!", copy["greetings"]
|
||||
refute copy["greetings"]["foo"]
|
||||
|
||||
frozen_data_hash = Jekyll::DataHash.new.freeze
|
||||
assert_raises(FrozenError) { frozen_data_hash["lorem"] = "ipsum" }
|
||||
end
|
||||
|
||||
should "be mergable" do
|
||||
alpha = Jekyll::DataHash.new
|
||||
beta = Jekyll::DataHash.new
|
||||
|
||||
assert_equal "{}", alpha.inspect
|
||||
sample_data = { "foo" => "bar" }
|
||||
|
||||
assert_equal sample_data["foo"], alpha.merge(sample_data)["foo"]
|
||||
assert_equal alpha.class, alpha.merge(sample_data).class
|
||||
assert_empty alpha
|
||||
|
||||
beta.merge!(sample_data)
|
||||
assert_equal sample_data["foo"], alpha.merge(beta)["foo"]
|
||||
assert_equal alpha.class, alpha.merge(beta).class
|
||||
assert_empty alpha
|
||||
|
||||
beta.merge!(@site.site_data)
|
||||
assert_equal alpha.class, beta.class
|
||||
assert_includes beta.dig("greetings", "foo"), "Hello!"
|
||||
|
||||
assert_empty alpha
|
||||
assert_equal sample_data["foo"], Jekyll::Utils.deep_merge_hashes(alpha, sample_data)["foo"]
|
||||
assert_includes(
|
||||
Jekyll::Utils.deep_merge_hashes(alpha, beta).dig("greetings", "foo"),
|
||||
"Hello!"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue