diff --git a/Cargo.lock b/Cargo.lock index c5118b22..6d13072c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "any_ascii" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70033777eb8b5124a81a1889416543dddef2de240019b674c81285a2635a7e1e" + [[package]] name = "arrayvec" version = "0.5.2" @@ -1160,6 +1166,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "lexical-sort" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09e4591611e231daf4d4c685a66cb0410cc1e502027a20ae55f2bb9e997207a" +dependencies = [ + "any_ascii", +] + [[package]] name = "libc" version = "0.2.82" @@ -1176,6 +1191,7 @@ dependencies = [ "front_matter", "globset", "lazy_static", + "lexical-sort", "rayon", "regex", "rendering", diff --git a/components/front_matter/src/lib.rs b/components/front_matter/src/lib.rs index 834747b3..5b54fd51 100644 --- a/components/front_matter/src/lib.rs +++ b/components/front_matter/src/lib.rs @@ -46,6 +46,8 @@ impl RawFrontMatter<'_> { pub enum SortBy { /// Most recent to oldest Date, + /// Sort by title + Title, /// Lower weight comes first Weight, /// No sorting diff --git a/components/library/Cargo.toml b/components/library/Cargo.toml index bfc421f4..81799616 100644 --- a/components/library/Cargo.toml +++ b/components/library/Cargo.toml @@ -13,6 +13,7 @@ serde = "1" serde_derive = "1" regex = "1" lazy_static = "1" +lexical-sort = "0.3" front_matter = { path = "../front_matter" } config = { path = "../config" } diff --git a/components/library/src/content/page.rs b/components/library/src/content/page.rs index 5e7578b1..c453b87f 100644 --- a/components/library/src/content/page.rs +++ b/components/library/src/content/page.rs @@ -62,6 +62,10 @@ pub struct Page { pub earlier: Option, /// The later page, for pages sorted by date pub later: Option, + /// The previous page, for pages sorted by title + pub title_prev: Option, + /// The next page, for pages sorted by title + pub title_next: Option, /// The lighter page, for pages sorted by weight pub lighter: Option, /// The heavier page, for pages sorted by weight diff --git a/components/library/src/content/ser.rs b/components/library/src/content/ser.rs index 1f28acd2..cf951b01 100644 --- a/components/library/src/content/ser.rs +++ b/components/library/src/content/ser.rs @@ -92,6 +92,8 @@ pub struct SerializingPage<'a> { heavier: Option>>, earlier: Option>>, later: Option>>, + title_prev: Option>>, + title_next: Option>>, translations: Vec>, } @@ -119,6 +121,12 @@ impl<'a> SerializingPage<'a> { let later = page .later .map(|k| Box::new(Self::from_page_basic(pages.get(k).unwrap(), Some(library)))); + let title_prev = page + .title_prev + .map(|k| Box::new(Self::from_page_basic(pages.get(k).unwrap(), Some(library)))); + let title_next = page + .title_next + .map(|k| Box::new(Self::from_page_basic(pages.get(k).unwrap(), Some(library)))); let ancestors = page .ancestors .iter() @@ -155,6 +163,8 @@ impl<'a> SerializingPage<'a> { heavier, earlier, later, + title_prev, + title_next, translations, } } @@ -217,6 +227,8 @@ impl<'a> SerializingPage<'a> { heavier: None, earlier: None, later: None, + title_prev: None, + title_next: None, translations, } } diff --git a/components/library/src/library.rs b/components/library/src/library.rs index d75b0aa3..553df543 100644 --- a/components/library/src/library.rs +++ b/components/library/src/library.rs @@ -6,7 +6,7 @@ use slotmap::{DefaultKey, DenseSlotMap}; use front_matter::SortBy; use crate::content::{Page, Section}; -use crate::sorting::{find_siblings, sort_pages_by_date, sort_pages_by_weight}; +use crate::sorting::{find_siblings, sort_pages_by_date, sort_pages_by_title, sort_pages_by_weight}; use config::Config; // Like vec! but for HashSet @@ -282,6 +282,21 @@ impl Library { sort_pages_by_date(data) } + SortBy::Title => { + let data = section + .pages + .iter() + .map(|k| { + if let Some(page) = self.pages.get(*k) { + (k, page.meta.title.as_deref(), page.permalink.as_ref()) + } else { + unreachable!("Sorting got an unknown page") + } + }) + .collect(); + + sort_pages_by_title(data) + } SortBy::Weight => { let data = section .pages @@ -312,6 +327,10 @@ impl Library { page.earlier = val2; page.later = val1; } + SortBy::Title => { + page.title_prev = val1; + page.title_next = val2; + } SortBy::Weight => { page.lighter = val1; page.heavier = val2; diff --git a/components/library/src/sorting.rs b/components/library/src/sorting.rs index 6213c880..01571e1f 100644 --- a/components/library/src/sorting.rs +++ b/components/library/src/sorting.rs @@ -1,6 +1,7 @@ use std::cmp::Ordering; use chrono::NaiveDateTime; +use lexical_sort::natural_lexical_cmp; use rayon::prelude::*; use slotmap::DefaultKey; @@ -39,6 +40,28 @@ pub fn sort_pages_by_date( (can_be_sorted.iter().map(|p| *p.0).collect(), cannot_be_sorted.iter().map(|p| *p.0).collect()) } +/// Takes a list of (page key, title, permalink) and sort them by title if possible. +/// Uses the a natural lexical comparison as defined by the lexical_sort crate. +/// Pages without title will be put in the unsortable bucket. +/// The permalink is used to break ties. +pub fn sort_pages_by_title( + pages: Vec<(&DefaultKey, Option<&str>, &str)>, +) -> (Vec, Vec) { + let (mut can_be_sorted, cannot_be_sorted): (Vec<_>, Vec<_>) = + pages.into_par_iter().partition(|page| page.1.is_some()); + + can_be_sorted.par_sort_unstable_by(|a, b| { + let ord = natural_lexical_cmp(a.1.unwrap(), b.1.unwrap()); + if ord == Ordering::Equal { + a.2.cmp(&b.2) + } else { + ord + } + }); + + (can_be_sorted.iter().map(|p| *p.0).collect(), cannot_be_sorted.iter().map(|p| *p.0).collect()) +} + /// Takes a list of (page key, weight, permalink) and sort them by weight if possible /// Pages without weight will be put in the unsortable bucket /// The permalink is used to break ties @@ -60,7 +83,8 @@ pub fn sort_pages_by_weight( (can_be_sorted.iter().map(|p| *p.0).collect(), cannot_be_sorted.iter().map(|p| *p.0).collect()) } -/// Find the lighter/heavier and earlier/later pages for all pages having a date/weight +/// Find the lighter/heavier, earlier/later, and title_prev/title_next +/// pages for all pages having a date/weight/title pub fn find_siblings( sorted: &[DefaultKey], ) -> Vec<(DefaultKey, Option, Option)> { @@ -71,12 +95,12 @@ pub fn find_siblings( let mut with_siblings = (*key, None, None); if i > 0 { - // lighter / later + // lighter / later / title_prev with_siblings.1 = Some(sorted[i - 1]); } if i < length - 1 { - // heavier/earlier + // heavier / earlier / title_next with_siblings.2 = Some(sorted[i + 1]); } res.push(with_siblings); @@ -90,7 +114,7 @@ mod tests { use slotmap::DenseSlotMap; use std::path::PathBuf; - use super::{find_siblings, sort_pages_by_date, sort_pages_by_weight}; + use super::{find_siblings, sort_pages_by_date, sort_pages_by_title, sort_pages_by_weight}; use crate::content::Page; use front_matter::PageFrontMatter; @@ -101,6 +125,12 @@ mod tests { Page::new("content/hello.md", front_matter, &PathBuf::new()) } + fn create_page_with_title(title: &str) -> Page { + let mut front_matter = PageFrontMatter::default(); + front_matter.title = Some(title.to_string()); + Page::new("content/hello.md", front_matter, &PathBuf::new()) + } + fn create_page_with_weight(weight: usize) -> Page { let mut front_matter = PageFrontMatter::default(); front_matter.weight = Some(weight); @@ -129,6 +159,49 @@ mod tests { assert_eq!(pages[2], key2); } + #[test] + fn can_sort_by_titles() { + let titles = vec![ + "bagel", + "track_3", + "microkernel", + "métro", + "BART", + "Underground", + "track_13", + "μ-kernel", + "meter", + "track_1", + ]; + let pages: Vec = titles.iter().map( + |title| create_page_with_title(title) + ).collect(); + let mut dense = DenseSlotMap::new(); + let keys: Vec<_> = pages.iter().map( + |p| dense.insert(p) + ).collect(); + let input: Vec<_> = pages.iter().enumerate().map( + |(i, page)| (&keys[i], page.meta.title.as_deref(), page.permalink.as_ref()) + ).collect(); + let (sorted, _) = sort_pages_by_title(input); + // Should be sorted by title + let sorted_titles: Vec<_> = sorted.iter().map( + |key| dense.get(*key).unwrap().meta.title.as_ref().unwrap() + ).collect(); + assert_eq!(sorted_titles, vec![ + "bagel", + "BART", + "μ-kernel", + "meter", + "métro", + "microkernel", + "track_1", + "track_3", + "track_13", + "Underground", + ]); + } + #[test] fn can_sort_by_weight() { let mut dense = DenseSlotMap::new(); diff --git a/docs/content/documentation/content/section.md b/docs/content/documentation/content/section.md index fd9f9916..f7f1d87b 100644 --- a/docs/content/documentation/content/section.md +++ b/docs/content/documentation/content/section.md @@ -19,7 +19,7 @@ Any non-Markdown file in a section directory is added to the `assets` collection Markdown file using relative links. ## Drafting -Just like pages sections can be drafted by setting the `draft` option in the front matter. By default this is not done. When a section is drafted it's descendants like pages, subsections and assets will not be processed unless the `--drafts` flag is passed. Note that even pages that don't have a `draft` status will not be processed if one of their parent sections is drafted. +Just like pages sections can be drafted by setting the `draft` option in the front matter. By default this is not done. When a section is drafted it's descendants like pages, subsections and assets will not be processed unless the `--drafts` flag is passed. Note that even pages that don't have a `draft` status will not be processed if one of their parent sections is drafted. ## Front matter @@ -48,7 +48,7 @@ description = "" # A draft section is only loaded if the `--drafts` flag is passed to `zola build`, `zola serve` or `zola check`. draft = false -# Used to sort pages by "date", "weight" or "none". See below for more information. +# Used to sort pages by "date", "title, "weight", or "none". See below for more information. sort_by = "none" # Used by the parent section to order its subsections. @@ -91,7 +91,7 @@ render = true # Useful for the same reason as `render` but when you don't want a 404 when # landing on the root section page. # Example: redirect_to = "documentation/content/overview" -redirect_to = +redirect_to = # If set to "true", the section will pass its pages on to the parent section. Defaults to `false`. # Useful when the section shouldn't split up the parent section, like @@ -124,6 +124,7 @@ You can also change the pagination path (the word displayed while paginated in t by setting the `paginate_path` variable, which defaults to `page`. ## Sorting + It is very common for Zola templates to iterate over pages or sections to display all pages/sections in a given directory. Consider a very simple example: a `blog` directory with three files: `blog/Post_1.md`, @@ -139,7 +140,7 @@ create a list of links to the posts, a simple template might look like this: This would iterate over the posts in the order specified by the `sort_by` variable set in the `_index.md` page for the corresponding section. The `sort_by` variable can be given one of three values: `date`, -`weight` or `none`. If `sort_by` is not set, the pages will be +`title`, `weight` or `none`. If `sort_by` is not set, the pages will be sorted in the `none` order, which is not intended for sorted content. Any page that is missing the data it needs to be sorted will be ignored and @@ -159,6 +160,20 @@ top of the list) to the oldest (at the bottom of the list). Each page will get `page.earlier` and `page.later` variables that contain the pages with earlier and later dates, respectively. +### `title` +This will sort all pages by their `title` field in natural lexical order, as +defined by `natural_lexical_cmp` in the [lexical-sort] crate. Each page will +get `page.title_prev` and `page.title_next` variables that contain the pages +with previous and next titles, respectively. + +For example, here is a natural lexical ordering: "bachata, BART, bolero, +μ-kernel, meter, Métro, Track-2, Track-3, Track-13, underground". Notice how +special characters and numbers are sorted reasonably. This is better than +the standard sorting: "BART, Métro, Track-13, Track-2, Track-3, bachata, +bolero, meter, underground, μ-kernel". + +[lexical-sort]: https://docs.rs/lexical-sort + ### `weight` This will be sort all pages by their `weight` field, from lightest weight (at the top of the list) to heaviest (at the bottom of the list). Each @@ -172,9 +187,13 @@ pages sorted by weight will be sorted from lightest (at the top) to heaviest (at the bottom); pages sorted by date will be sorted from oldest (at the top) to newest (at the bottom). -`reverse` has no effect on `page.later`/`page.earlier` or `page.heavier`/`page.lighter`. +`reverse` has no effect on: -If the section is paginated the `paginate_reversed=true` in the front matter of the relevant section should be set instead of using the filter. +* `page.later` / `page.earlier`, +* `page.title_prev` / `page.title_next`, or +* `page.heavier` / `page.lighter`. + +If the section is paginated the `paginate_reversed=true` in the front matter of the relevant section should be set instead of using the filter. ## Sorting subsections Sorting sections is a bit less flexible: sections can only be sorted by `weight`,