Allow custom sorting of collection documents (#7427)

Merge pull request 7427
This commit is contained in:
Ashwin Maroli 2019-02-16 21:49:03 +05:30 committed by jekyllbot
parent f6527cd7ef
commit 07bf5be7b4
12 changed files with 573 additions and 4 deletions

View File

@ -111,6 +111,58 @@ You can link to the generated page using the `url` attribute:
There are special [permalink variables for collections](/docs/permalinks/) to There are special [permalink variables for collections](/docs/permalinks/) to
help you control the output url for the entire collection. help you control the output url for the entire collection.
{% if site.version == '4.0.0' %}{% comment %} Remove this encapsulation when v4.0 ships {% endcomment %}
## Custom Sorting of Documents
By default, documents in a collection are sorted by their paths. But you can control this sorting via the collection's metadata.
### Sort By Front Matter Key
Documents can be sorted based on a front matter key by setting a `sort_by` metadata to the front matter key string. For example,
to sort a collection of tutorials based on key `lesson`, the configuration would be:
```yaml
collections:
tutorials:
sort_by: lesson
```
The documents are arranged in the increasing order of the key's value. If a document does not have the front matter key defined
then that document is placed immediately after sorted documents. When multiple documents do not have the front matter key defined,
those documents are sorted by their dates or paths and then placed immediately after the sorted documents.
### Manually Ordering Documents
You can also manually order the documents by setting an `order` metadata with **the filenames listed** in the desired order.
For example, a collection of tutorials would be configured as:
```yaml
collections:
tutorials:
order:
- hello-world.md
- introduction.md
- basic-concepts.md
- advanced-concepts.md
```
Any documents with filenames that do not match the list entry simply gets placed after the rearranged documents. If a document is
nested under subdirectories, include them in entries as well:
```yaml
collections:
tutorials:
order:
- hello-world.md
- introduction.md
- concepts/basics.md
- concepts/advanced.md
```
If both metadata keys have been defined properly, `order` list takes precedence.
{% endif %}
## Liquid Attributes ## Liquid Attributes
### Collections ### Collections

View File

@ -434,6 +434,152 @@ Feature: Collections
And I should see "Collections: this is a test!, Collection#entries, Jekyll.configuration, Jekyll.escape, Jekyll.sanitized_path, Site#generate, Initialize, Site#generate, YAML with Dots" in "_site/index.html" unless Windows And I should see "Collections: this is a test!, Collection#entries, Jekyll.configuration, Jekyll.escape, Jekyll.sanitized_path, Site#generate, Initialize, Site#generate, YAML with Dots" in "_site/index.html" unless Windows
And I should see "Collections: this is a test!, Collection#entries, Jekyll.configuration, Jekyll.escape, Jekyll.sanitized_path, Site#generate, Initialize, YAML with Dots" in "_site/index.html" if on Windows And I should see "Collections: this is a test!, Collection#entries, Jekyll.configuration, Jekyll.escape, Jekyll.sanitized_path, Site#generate, Initialize, YAML with Dots" in "_site/index.html" if on Windows
Scenario: Sort all entries by a Front Matter key defined in all entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections:
tutorials:
output: true
sort_by: lesson
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "Collections: Getting Started, Let's Roll!, Dive-In and Publish Already!, Tip of the Iceberg, Extending with Plugins, Graduation Day" in "_site/index.html"
And I should not see "Previous: Graduation Day" in "_site/tutorials/lets-roll.html"
And I should not see "Next: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
But I should see "Previous: Getting Started" in "_site/tutorials/lets-roll.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/lets-roll.html"
Scenario: Sort all entries by a Front Matter key defined in only some entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections:
tutorials:
output: true
sort_by: approx_time
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "'approx_time' not defined" in the build output
And I should see "Collections: Extending with Plugins, Let's Roll!, Getting Started, Graduation Day, Dive-In and Publish Already!, Tip of the Iceberg" in "_site/index.html"
And I should see "Previous: Getting Started" in "_site/tutorials/graduation-day.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/graduation-day.html"
Scenario: Manually sort entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections:
tutorials:
output: true
order:
- getting-started.md
- tip-of-the-iceberg.md
- lets-roll.md
- dive-in-and-publish-already.md
- graduation-day.md
- random-plugins.md
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "Collections: Getting Started, Tip of the Iceberg, Let's Roll!, Dive-In and Publish Already!, Graduation Day, Extending with Plugins" in "_site/index.html"
And I should not see "Previous: Graduation Day" in "_site/tutorials/lets-roll.html"
And I should not see "Next: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
But I should see "Previous: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/lets-roll.html"
Scenario: Manually sort some entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections:
tutorials:
output: true
order:
- getting-started.md
- lets-roll.md
- dive-in-and-publish-already.md
- graduation-day.md
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "Collections: Getting Started, Let's Roll!, Dive-In and Publish Already!, Graduation Day, Extending with Plugins, Tip of the Iceberg" in "_site/index.html"
And I should not see "Previous: Graduation Day" in "_site/tutorials/lets-roll.html"
And I should not see "Previous: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
And I should not see "Next: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
But I should see "Previous: Getting Started" in "_site/tutorials/lets-roll.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/lets-roll.html"
Scenario: Rendered collection with date/dateless filename Scenario: Rendered collection with date/dateless filename
Given I have an "index.html" page that contains "Collections: {% for method in site.thanksgiving %}{{ method.title }} {% endfor %}" Given I have an "index.html" page that contains "Collections: {% for method in site.thanksgiving %}{{ method.title }} {% endfor %}"
And I have fixture collections And I have fixture collections

View File

@ -283,3 +283,153 @@ Feature: Collections Directory
And I should see "<p>Loki: Manager: false</p>" in "_site/index.html" And I should see "<p>Loki: Manager: false</p>" in "_site/index.html"
And I should see "<p>Loki: Recruit: false</p>" in "_site/index.html" And I should see "<p>Loki: Recruit: false</p>" in "_site/index.html"
And I should see "<p>Loki: Villain: false</p>" in "_site/index.html" And I should see "<p>Loki: Villain: false</p>" in "_site/index.html"
Scenario: Sort all entries by a Front Matter key defined in all entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections in "gathering" directory
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections_dir: gathering
collections:
tutorials:
output: true
sort_by: lesson
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "Collections: Getting Started, Let's Roll!, Dive-In and Publish Already!, Tip of the Iceberg, Extending with Plugins, Graduation Day" in "_site/index.html"
And I should not see "Previous: Graduation Day" in "_site/tutorials/lets-roll.html"
And I should not see "Next: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
But I should see "Previous: Getting Started" in "_site/tutorials/lets-roll.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/lets-roll.html"
Scenario: Sort all entries by a Front Matter key defined in only some entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections in "gathering" directory
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections_dir: gathering
collections:
tutorials:
output: true
sort_by: approx_time
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "'approx_time' not defined" in the build output
And I should see "Collections: Extending with Plugins, Let's Roll!, Getting Started, Graduation Day, Dive-In and Publish Already!, Tip of the Iceberg" in "_site/index.html"
And I should see "Previous: Getting Started" in "_site/tutorials/graduation-day.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/graduation-day.html"
Scenario: Manually sort entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections in "gathering" directory
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections_dir: gathering
collections:
tutorials:
output: true
order:
- getting-started.md
- tip-of-the-iceberg.md
- lets-roll.md
- dive-in-and-publish-already.md
- graduation-day.md
- random-plugins.md
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "Collections: Getting Started, Tip of the Iceberg, Let's Roll!, Dive-In and Publish Already!, Graduation Day, Extending with Plugins" in "_site/index.html"
And I should not see "Previous: Graduation Day" in "_site/tutorials/lets-roll.html"
And I should not see "Next: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
But I should see "Previous: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/lets-roll.html"
Scenario: Manually sort some entries
Given I have an "index.html" page that contains "Collections: {{ site.tutorials | map: 'title' | join: ', ' }}"
And I have fixture collections in "gathering" directory
And I have a _layouts directory
And I have a "_layouts/tutorial.html" file with content:
"""
{% if page.previous %}Previous: {{ page.previous.title }}{% endif %}
{% if page.next %}Next: {{ page.next.title }}{% endif %}
"""
And I have a "_config.yml" file with content:
"""
collections_dir: gathering
collections:
tutorials:
output: true
order:
- getting-started.md
- lets-roll.md
- dive-in-and-publish-already.md
- graduation-day.md
defaults:
- scope:
path: ""
type: tutorials
values:
layout: tutorial
"""
When I run jekyll build
Then I should get a zero exit status
Then the _site directory should exist
And I should see "Collections: Getting Started, Let's Roll!, Dive-In and Publish Already!, Graduation Day, Extending with Plugins, Tip of the Iceberg" in "_site/index.html"
And I should not see "Previous: Graduation Day" in "_site/tutorials/lets-roll.html"
And I should not see "Previous: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
And I should not see "Next: Tip of the Iceberg" in "_site/tutorials/lets-roll.html"
But I should see "Previous: Getting Started" in "_site/tutorials/lets-roll.html"
And I should see "Next: Dive-In and Publish Already!" in "_site/tutorials/lets-roll.html"

View File

@ -178,9 +178,11 @@ end
# #
Given(%r!^I have fixture collections$!) do Given(%r!^I have fixture collections(?: in "(.*)" directory)?$!) do |directory|
FileUtils.cp_r Paths.source_dir.join("test", "source", "_methods"), source_dir collections_dir = File.join(source_dir, directory.to_s)
FileUtils.cp_r Paths.source_dir.join("test", "source", "_thanksgiving"), source_dir FileUtils.cp_r Paths.source_dir.join("test", "source", "_methods"), collections_dir
FileUtils.cp_r Paths.source_dir.join("test", "source", "_thanksgiving"), collections_dir
FileUtils.cp_r Paths.source_dir.join("test", "source", "_tutorials"), collections_dir
end end
# #

View File

@ -65,7 +65,7 @@ module Jekyll
read_static_file(file_path, full_path) read_static_file(file_path, full_path)
end end
end end
docs.sort! sort_docs!
end end
# All the entries in this collection. # All the entries in this collection.
@ -217,6 +217,78 @@ module Jekyll
docs << doc if site.unpublished || doc.published? docs << doc if site.unpublished || doc.published?
end end
def sort_docs!
if metadata["order"].is_a?(Array)
rearrange_docs!
elsif metadata["sort_by"].is_a?(String)
sort_docs_by_key!
else
docs.sort!
end
end
# A custom sort function based on Schwartzian transform
# Refer https://byparker.com/blog/2017/schwartzian-transform-faster-sorting/ for details
def sort_docs_by_key!
meta_key = metadata["sort_by"]
# Modify `docs` array to cache document's property along with the Document instance
docs.map! { |doc| [doc.data[meta_key], doc] }.sort! do |apples, olives|
order = determine_sort_order(meta_key, apples, olives)
# Fall back to `Document#<=>` if the properties were equal or were non-sortable
# Otherwise continue with current sort-order
if order.zero? || order.nil?
apples[-1] <=> olives[-1]
else
order
end
# Finally restore the `docs` array with just the Document objects themselves
end.map!(&:last)
end
def determine_sort_order(sort_key, apples, olives)
apple_property, apple_document = apples
olive_property, olive_document = olives
if apple_property.nil? && !olive_property.nil?
order_with_warning(sort_key, apple_document, 1)
elsif !apple_property.nil? && olive_property.nil?
order_with_warning(sort_key, olive_document, -1)
else
apple_property <=> olive_property
end
end
def order_with_warning(sort_key, document, order)
Jekyll.logger.warn "Sort warning:", "'#{sort_key}' not defined in #{document.relative_path}"
order
end
# Rearrange documents within the `docs` array as listed in the `metadata["order"]` array.
#
# Involves converting the two arrays into hashes based on relative_paths as keys first, then
# merging them to remove duplicates and finally retrieving the Document instances from the
# merged array.
def rearrange_docs!
docs_table = {}
custom_order = {}
# pre-sort to normalize default array across platforms and then proceed to create a Hash
# from that sorted array.
docs.sort.each do |doc|
docs_table[doc.relative_path] = doc
end
metadata["order"].each do |entry|
custom_order[File.join(relative_directory, entry)] = nil
end
result = Jekyll::Utils.deep_merge_hashes(custom_order, docs_table).values
result.compact!
self.docs = result
end
def read_static_file(file_path, full_path) def read_static_file(file_path, full_path)
relative_dir = Jekyll.sanitized_path( relative_dir = Jekyll.sanitized_path(
relative_directory, relative_directory,

View File

@ -0,0 +1,9 @@
---
title: "Dive-In and Publish Already!"
lesson: 3
approx_time: 30 mins
---
Jekyll converts Markdown documents to HTML by default. Don't know what's Markdown?
Read this [documentation](http://daringfireball.net/projects/markdown/)
While you're at it, might as well learn about [Kramdown](https://kramdown.gettalong.org/)

View File

@ -0,0 +1,9 @@
---
title: "Extending with Plugins"
lesson: 5
approx_time: 1 min
---
A lot can be accomplished by using Jekyll out-of-the-box. But a lot more can be achieved by using plugins that extend Jekyll's functionality. There are numerous plugins supported by the official team and many other third-party plugins provided by the Jekyll Community.
Check this [documentation page](https://jekyllrb.com/docs/plugins/) dedicated to working with plugins.

View File

@ -0,0 +1,7 @@
---
title: "Getting Started"
lesson: 1
approx_time: 10 mins
---
The first thing you need is a working installation of Ruby. Install from [the official website](https://www.ruby-lang.org/en/documentation/installation/).

View File

@ -0,0 +1,10 @@
---
title: "Graduation Day"
lesson: 6
approx_time: 10 mins
---
Congratualtions! You now know enough to start Jekylling!
Want to report a bug you found? Or give something back to the community?
Head over to the [Jekyll Repo](https://github.com/jekyll/jekyll) at GitHub

View File

@ -0,0 +1,16 @@
---
title: "Let's Roll!"
lesson: 2
approx_time: 1 min
---
Now that you have installed Ruby, Jekyll and Bundler, lets get Jekylling!
Enter the following in your terminal:
$ jekyll new my blog
Then preview your new project in your browser right away by entering the following and pointing your browser to `http://localhost:4000` :
$ bundle exec jekyll serve
Go ahead. Try it.

View File

@ -0,0 +1,6 @@
---
title: "Tip of the Iceberg"
lesson: 4
---
Now that you know some of the basics, learn more about working with [Jekyll](https://jekyllrb.com).

View File

@ -179,6 +179,96 @@ class TestCollections < JekyllUnitTest
end end
end end
context "with a collection with metadata to sort items by attribute" do
setup do
@site = fixture_site(
"collections" => {
"methods" => {
"output" => true,
},
"tutorials" => {
"output" => true,
"sort_by" => "lesson",
},
}
)
@site.process
@tutorials_collection = @site.collections["tutorials"]
@actual_array = @tutorials_collection.docs.map(&:relative_path)
end
should "sort documents in a collection with 'sort_by' metadata set to a " \
"FrontMatter key 'lesson'" do
default_tutorials_array = %w(
_tutorials/dive-in-and-publish-already.md
_tutorials/extending-with-plugins.md
_tutorials/getting-started.md
_tutorials/graduation-day.md
_tutorials/lets-roll.md
_tutorials/tip-of-the-iceberg.md
)
tutorials_sorted_by_lesson_array = %w(
_tutorials/getting-started.md
_tutorials/lets-roll.md
_tutorials/dive-in-and-publish-already.md
_tutorials/tip-of-the-iceberg.md
_tutorials/extending-with-plugins.md
_tutorials/graduation-day.md
)
refute_equal default_tutorials_array, @actual_array
assert_equal tutorials_sorted_by_lesson_array, @actual_array
end
end
context "with a collection with metadata to rearrange items" do
setup do
@site = fixture_site(
"collections" => {
"methods" => {
"output" => true,
},
"tutorials" => {
"output" => true,
"order" => [
"getting-started.md",
"lets-roll.md",
"dive-in-and-publish-already.md",
"tip-of-the-iceberg.md",
"graduation-day.md",
"extending-with-plugins.md",
],
},
}
)
@site.process
@tutorials_collection = @site.collections["tutorials"]
@actual_array = @tutorials_collection.docs.map(&:relative_path)
end
should "sort documents in a collection in the order outlined in the config file" do
default_tutorials_array = %w(
_tutorials/dive-in-and-publish-already.md
_tutorials/extending-with-plugins.md
_tutorials/getting-started.md
_tutorials/graduation-day.md
_tutorials/lets-roll.md
_tutorials/tip-of-the-iceberg.md
)
tutorials_rearranged_in_config_array = %w(
_tutorials/getting-started.md
_tutorials/lets-roll.md
_tutorials/dive-in-and-publish-already.md
_tutorials/tip-of-the-iceberg.md
_tutorials/graduation-day.md
_tutorials/extending-with-plugins.md
)
refute_equal default_tutorials_array, @actual_array
assert_equal tutorials_rearranged_in_config_array, @actual_array
end
end
context "in safe mode" do context "in safe mode" do
setup do setup do
@site = fixture_site( @site = fixture_site(