From 07bf5be7b4635b1c9aac7ca87226b44bdc4ae740 Mon Sep 17 00:00:00 2001 From: Ashwin Maroli Date: Sat, 16 Feb 2019 21:49:03 +0530 Subject: [PATCH] Allow custom sorting of collection documents (#7427) Merge pull request 7427 --- docs/_docs/collections.md | 52 ++++++ features/collections.feature | 146 +++++++++++++++++ features/collections_dir.feature | 150 ++++++++++++++++++ features/step_definitions.rb | 8 +- lib/jekyll/collection.rb | 74 ++++++++- .../_tutorials/dive-in-and-publish-already.md | 9 ++ .../_tutorials/extending-with-plugins.md | 9 ++ test/source/_tutorials/getting-started.md | 7 + test/source/_tutorials/graduation-day.md | 10 ++ test/source/_tutorials/lets-roll.md | 16 ++ test/source/_tutorials/tip-of-the-iceberg.md | 6 + test/test_collections.rb | 90 +++++++++++ 12 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 test/source/_tutorials/dive-in-and-publish-already.md create mode 100644 test/source/_tutorials/extending-with-plugins.md create mode 100644 test/source/_tutorials/getting-started.md create mode 100644 test/source/_tutorials/graduation-day.md create mode 100644 test/source/_tutorials/lets-roll.md create mode 100644 test/source/_tutorials/tip-of-the-iceberg.md diff --git a/docs/_docs/collections.md b/docs/_docs/collections.md index 5f6a3650..3b01d28c 100644 --- a/docs/_docs/collections.md +++ b/docs/_docs/collections.md @@ -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 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 ### Collections diff --git a/features/collections.feature b/features/collections.feature index c409c733..8ee1b41f 100644 --- a/features/collections.feature +++ b/features/collections.feature @@ -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, 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 Given I have an "index.html" page that contains "Collections: {% for method in site.thanksgiving %}{{ method.title }} {% endfor %}" And I have fixture collections diff --git a/features/collections_dir.feature b/features/collections_dir.feature index 64c6f44e..712b2117 100644 --- a/features/collections_dir.feature +++ b/features/collections_dir.feature @@ -283,3 +283,153 @@ Feature: Collections Directory And I should see "

Loki: Manager: false

" in "_site/index.html" And I should see "

Loki: Recruit: false

" in "_site/index.html" And I should see "

Loki: Villain: false

" 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" diff --git a/features/step_definitions.rb b/features/step_definitions.rb index fd05c68f..fe5ade94 100644 --- a/features/step_definitions.rb +++ b/features/step_definitions.rb @@ -178,9 +178,11 @@ end # -Given(%r!^I have fixture collections$!) do - FileUtils.cp_r Paths.source_dir.join("test", "source", "_methods"), source_dir - FileUtils.cp_r Paths.source_dir.join("test", "source", "_thanksgiving"), source_dir +Given(%r!^I have fixture collections(?: in "(.*)" directory)?$!) do |directory| + collections_dir = File.join(source_dir, directory.to_s) + 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 # diff --git a/lib/jekyll/collection.rb b/lib/jekyll/collection.rb index 88076f61..41bbb70e 100644 --- a/lib/jekyll/collection.rb +++ b/lib/jekyll/collection.rb @@ -65,7 +65,7 @@ module Jekyll read_static_file(file_path, full_path) end end - docs.sort! + sort_docs! end # All the entries in this collection. @@ -217,6 +217,78 @@ module Jekyll docs << doc if site.unpublished || doc.published? 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) relative_dir = Jekyll.sanitized_path( relative_directory, diff --git a/test/source/_tutorials/dive-in-and-publish-already.md b/test/source/_tutorials/dive-in-and-publish-already.md new file mode 100644 index 00000000..d6a7397a --- /dev/null +++ b/test/source/_tutorials/dive-in-and-publish-already.md @@ -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/) diff --git a/test/source/_tutorials/extending-with-plugins.md b/test/source/_tutorials/extending-with-plugins.md new file mode 100644 index 00000000..03d2ea28 --- /dev/null +++ b/test/source/_tutorials/extending-with-plugins.md @@ -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. diff --git a/test/source/_tutorials/getting-started.md b/test/source/_tutorials/getting-started.md new file mode 100644 index 00000000..cd9bbf85 --- /dev/null +++ b/test/source/_tutorials/getting-started.md @@ -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/). diff --git a/test/source/_tutorials/graduation-day.md b/test/source/_tutorials/graduation-day.md new file mode 100644 index 00000000..bfb8b28e --- /dev/null +++ b/test/source/_tutorials/graduation-day.md @@ -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 diff --git a/test/source/_tutorials/lets-roll.md b/test/source/_tutorials/lets-roll.md new file mode 100644 index 00000000..0cfcfe8a --- /dev/null +++ b/test/source/_tutorials/lets-roll.md @@ -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. diff --git a/test/source/_tutorials/tip-of-the-iceberg.md b/test/source/_tutorials/tip-of-the-iceberg.md new file mode 100644 index 00000000..4dab1cf1 --- /dev/null +++ b/test/source/_tutorials/tip-of-the-iceberg.md @@ -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). diff --git a/test/test_collections.rb b/test/test_collections.rb index eb1eb0f6..872cb5f3 100644 --- a/test/test_collections.rb +++ b/test/test_collections.rb @@ -179,6 +179,96 @@ class TestCollections < JekyllUnitTest 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 setup do @site = fixture_site(