diff --git a/CHANGELOG.md b/CHANGELOG.md index d917e4a9b..44bd027a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.19.0 (unreleased) + + ## 0.18.0 (2023-12-18) - Fix LFI in `zola serve` diff --git a/components/content/src/library.rs b/components/content/src/library.rs index 2ab6fdbfa..356ec2f43 100644 --- a/components/content/src/library.rs +++ b/components/content/src/library.rs @@ -208,6 +208,45 @@ impl Library { } } + /// Sort all subsections according to sorting method given + pub fn sort_section_subsections(&mut self) { + let mut updates = AHashMap::new(); + for (path, section) in &self.sections { + let subsections: Vec<_> = + section.subsections.iter().map(|p| &self.sections[p]).collect(); + let (sorted_subsections, cannot_be_sorted_subsections) = match section.meta.sort_by { + SortBy::None => continue, + _ => sort_pages(&subsections, section.meta.sort_by), + }; + + updates.insert( + path.clone(), + (sorted_subsections, cannot_be_sorted_subsections, section.meta.sort_by), + ); + } + + for (path, (sorted, unsortable, _)) in updates { + // Fill siblings + for (i, subsection_path) in sorted.iter().enumerate() { + let p = self.sections.get_mut(subsection_path).unwrap(); + if i > 0 { + // lighter / later / title_prev + p.lower = Some(sorted[i - 1].clone()); + } + + if i < sorted.len() - 1 { + // heavier / earlier / title_next + p.higher = Some(sorted[i + 1].clone()); + } + } + + if let Some(s) = self.sections.get_mut(&path) { + s.subsections = sorted; + s.ignored_subsections = unsortable; + } + } + } + /// Find out the direct subsections of each subsection if there are some /// as well as the pages for each section pub fn populate_sections(&mut self, config: &Config, content_path: &Path) { @@ -331,6 +370,7 @@ impl Library { // And once we have all the pages assigned to their section, we sort them self.sort_section_pages(); + self.sort_section_subsections(); } /// Find all the orphan pages: pages that are in a folder without an `_index.md` @@ -779,4 +819,107 @@ mod tests { ); assert_eq!(library.backlinks["_index.md"], set! {PathBuf::from("page2.md")}); } + + #[test] + fn can_sort_sections_by_weight() { + let config = Config::default_for_test(); + let mut library = Library::default(); + let sections = vec![ + ("content/_index.md", "en", 0, false, SortBy::Weight), + ("content/blog/_index.md", "en", 0, false, SortBy::Weight), + ("content/novels/_index.md", "en", 3, false, SortBy::Weight), + ("content/novels/first/_index.md", "en", 2, false, SortBy::Weight), + ("content/novels/second/_index.md", "en", 1, false, SortBy::Weight), + // Transparency does not apply to sections as of now! + ("content/wiki/_index.md", "en", 4, true, SortBy::Weight), + ("content/wiki/recipes/_index.md", "en", 1, false, SortBy::Weight), + ("content/wiki/programming/_index.md", "en", 2, false, SortBy::Weight), + ]; + for (p, l, w, t, s) in sections.clone() { + library.insert_section(create_section(p, l, w, t, s)); + } + + library.populate_sections(&config, Path::new("content")); + assert_eq!(library.sections.len(), sections.len()); + let root_section = &library.sections[&PathBuf::from("content/_index.md")]; + assert_eq!(root_section.lower, None); + assert_eq!(root_section.higher, None); + + let blog_section = &library.sections[&PathBuf::from("content/blog/_index.md")]; + assert_eq!(blog_section.lower, None); + assert_eq!(blog_section.higher, Some(PathBuf::from("content/novels/_index.md"))); + + let novels_section = &library.sections[&PathBuf::from("content/novels/_index.md")]; + assert_eq!(novels_section.lower, Some(PathBuf::from("content/blog/_index.md"))); + assert_eq!(novels_section.higher, Some(PathBuf::from("content/wiki/_index.md"))); + assert_eq!( + novels_section.subsections, + vec![ + PathBuf::from("content/novels/second/_index.md"), + PathBuf::from("content/novels/first/_index.md"), + ] + ); + + let first_novel_section = + &library.sections[&PathBuf::from("content/novels/first/_index.md")]; + assert_eq!( + first_novel_section.lower, + Some(PathBuf::from("content/novels/second/_index.md")) + ); + assert_eq!(first_novel_section.higher, None); + + let second_novel_section = + &library.sections[&PathBuf::from("content/novels/second/_index.md")]; + assert_eq!(second_novel_section.lower, None); + assert_eq!( + second_novel_section.higher, + Some(PathBuf::from("content/novels/first/_index.md")) + ); + } + + #[test] + fn can_sort_sections_by_title() { + fn create_section(file_path: &str, title: &str, weight: usize, sort_by: SortBy) -> Section { + let mut section = Section::default(); + section.lang = "en".to_owned(); + section.file = FileInfo::new_section(Path::new(file_path), &PathBuf::new()); + section.meta.title = Some(title.to_owned()); + section.meta.weight = weight; + section.meta.transparent = false; + section.meta.sort_by = sort_by; + section.meta.page_template = Some("new_page.html".to_owned()); + section + } + + let config = Config::default_for_test(); + let mut library = Library::default(); + let sections = vec![ + ("content/_index.md", "root", 0, SortBy::Title), + ("content/a_first/_index.md", "1", 1, SortBy::Title), + ("content/b_third/_index.md", "3", 2, SortBy::Title), + ("content/c_second/_index.md", "2", 2, SortBy::Title), + ]; + for (p, l, w, s) in sections.clone() { + library.insert_section(create_section(p, l, w, s)); + } + + library.populate_sections(&config, Path::new("content")); + assert_eq!(library.sections.len(), sections.len()); + + let root_section = &library.sections[&PathBuf::from("content/_index.md")]; + assert_eq!(root_section.lower, None); + assert_eq!(root_section.higher, None); + + let first = &library.sections[&PathBuf::from("content/a_first/_index.md")]; + assert_eq!(first.lower, None); + assert_eq!(first.higher, Some(PathBuf::from("content/c_second/_index.md"))); + + let second = &library.sections[&PathBuf::from("content/c_second/_index.md")]; + assert_eq!(second.lower, Some(PathBuf::from("content/a_first/_index.md"))); + assert_eq!(second.higher, Some(PathBuf::from("content/b_third/_index.md"))); + + let third = &library.sections[&PathBuf::from("content/b_third/_index.md")]; + assert_eq!(third.lower, Some(PathBuf::from("content/c_second/_index.md"))); + assert_eq!(third.higher, None); + } } diff --git a/components/content/src/page.rs b/components/content/src/page.rs index 94672b2bd..31b3f630a 100644 --- a/components/content/src/page.rs +++ b/components/content/src/page.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use libs::lexical_sort::natural_lexical_cmp; use libs::once_cell::sync::Lazy; use libs::regex::Regex; use libs::tera::{Context as TeraContext, Tera}; @@ -18,8 +19,10 @@ use crate::file_info::FileInfo; use crate::front_matter::{split_page_content, PageFrontMatter}; use crate::library::Library; use crate::ser::SerializingPage; +use crate::sorting::Sortable; use crate::utils::get_reading_analytics; use crate::utils::{find_related_assets, has_anchor}; +use crate::SortBy; use utils::anchors::has_anchor_id; use utils::fs::read_file; @@ -88,6 +91,48 @@ pub struct Page { pub external_links: Vec, } +impl Sortable for Page { + fn can_be_sorted(&self, by: SortBy) -> bool { + match by { + SortBy::Date => self.meta.datetime.is_some(), + SortBy::UpdateDate => { + self.meta.datetime.is_some() || self.meta.updated_datetime.is_some() + } + SortBy::Title | SortBy::TitleBytes => self.meta.title.is_some(), + SortBy::Weight => self.meta.weight.is_some(), + SortBy::Slug => true, + SortBy::None => unreachable!(), + } + } + + fn cmp(&self, other: &Self, by: crate::SortBy) -> std::cmp::Ordering { + match by { + SortBy::Date => other.meta.datetime.unwrap().cmp(&self.meta.datetime.unwrap()), + SortBy::UpdateDate => std::cmp::max(other.meta.datetime, other.meta.updated_datetime) + .unwrap() + .cmp(&std::cmp::max(self.meta.datetime, self.meta.updated_datetime).unwrap()), + SortBy::Title => natural_lexical_cmp( + self.meta.title.as_ref().unwrap(), + other.meta.title.as_ref().unwrap(), + ), + SortBy::TitleBytes => { + self.meta.title.as_ref().unwrap().cmp(other.meta.title.as_ref().unwrap()) + } + SortBy::Weight => self.meta.weight.unwrap().cmp(&other.meta.weight.unwrap()), + SortBy::Slug => natural_lexical_cmp(&self.slug, &other.slug), + SortBy::None => unreachable!(), + } + } + + fn get_permalink(&self) -> &str { + &self.permalink + } + + fn get_filepath(&self) -> PathBuf { + self.file.path.clone() + } +} + impl Page { pub fn new>(file_path: P, meta: PageFrontMatter, base_path: &Path) -> Page { let file_path = file_path.as_ref(); diff --git a/components/content/src/section.rs b/components/content/src/section.rs index 920f863ee..2871e0e4d 100644 --- a/components/content/src/section.rs +++ b/components/content/src/section.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use libs::lexical_sort::natural_lexical_cmp; use libs::tera::{Context as TeraContext, Tera}; use config::Config; @@ -15,7 +16,9 @@ use crate::file_info::FileInfo; use crate::front_matter::{split_section_content, SectionFrontMatter}; use crate::library::Library; use crate::ser::{SectionSerMode, SerializingSection}; +use crate::sorting::Sortable; use crate::utils::{find_related_assets, get_reading_analytics, has_anchor}; +use crate::SortBy; // Default is used to create a default index section if there is no _index.md in the root content directory #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -30,6 +33,10 @@ pub struct Section { pub components: Vec, /// The full URL for that page pub permalink: String, + /// The previous section when sorting: earlier/earlier_updated/lighter/prev + pub lower: Option, + /// The next section when sorting: later/later_updated/heavier/next + pub higher: Option, /// The actual content of the page, in markdown pub raw_content: String, /// The HTML rendered of the page @@ -46,6 +53,8 @@ pub struct Section { pub ancestors: Vec, /// All direct subsections pub subsections: Vec, + /// All subsection that cannot be sorted in this section + pub ignored_subsections: Vec, /// Toc made from the headings of the markdown file pub toc: Vec, /// How many words in the raw content @@ -64,6 +73,44 @@ pub struct Section { pub external_links: Vec, } +impl Sortable for Section { + fn can_be_sorted(&self, by: SortBy) -> bool { + match by { + SortBy::Date => false, + SortBy::UpdateDate => false, + SortBy::Title | SortBy::TitleBytes => self.meta.title.is_some(), + SortBy::Weight => true, + SortBy::Slug => false, + SortBy::None => unreachable!(), + } + } + + fn cmp(&self, other: &Self, by: SortBy) -> std::cmp::Ordering { + match by { + SortBy::Date => unreachable!(), + SortBy::UpdateDate => unreachable!(), + SortBy::Title => natural_lexical_cmp( + self.meta.title.as_ref().unwrap(), + other.meta.title.as_ref().unwrap(), + ), + SortBy::TitleBytes => { + self.meta.title.as_ref().unwrap().cmp(other.meta.title.as_ref().unwrap()) + } + SortBy::Weight => self.meta.weight.cmp(&other.meta.weight), + SortBy::Slug => unreachable!(), + SortBy::None => unreachable!(), + } + } + + fn get_permalink(&self) -> &str { + &self.permalink + } + + fn get_filepath(&self) -> PathBuf { + self.file.path.clone() + } +} + impl Section { pub fn new>( file_path: P, diff --git a/components/content/src/sorting.rs b/components/content/src/sorting.rs index eb4b91fb1..91d223f05 100644 --- a/components/content/src/sorting.rs +++ b/components/content/src/sorting.rs @@ -1,59 +1,41 @@ use std::cmp::Ordering; use std::path::PathBuf; -use crate::{Page, SortBy}; -use libs::lexical_sort::natural_lexical_cmp; +use crate::SortBy; use libs::rayon::prelude::*; +pub trait Sortable: Sync { + fn can_be_sorted(&self, by: SortBy) -> bool; + fn cmp(&self, other: &Self, by: SortBy) -> Ordering; + fn get_permalink(&self) -> &str; + fn get_filepath(&self) -> PathBuf; +} + /// Sort by the field picked by the function. /// The pages permalinks are used to break the ties -pub fn sort_pages(pages: &[&Page], sort_by: SortBy) -> (Vec, Vec) { - let (mut can_be_sorted, cannot_be_sorted): (Vec<&Page>, Vec<_>) = - pages.par_iter().partition(|page| match sort_by { - SortBy::Date => page.meta.datetime.is_some(), - SortBy::UpdateDate => { - page.meta.datetime.is_some() || page.meta.updated_datetime.is_some() - } - SortBy::Title | SortBy::TitleBytes => page.meta.title.is_some(), - SortBy::Weight => page.meta.weight.is_some(), - SortBy::Slug => true, - SortBy::None => unreachable!(), - }); +pub fn sort_pages(pages: &[&S], sort_by: SortBy) -> (Vec, Vec) { + let (mut can_be_sorted, cannot_be_sorted): (Vec<&S>, Vec<_>) = + pages.into_par_iter().partition(|page| page.can_be_sorted(sort_by)); can_be_sorted.par_sort_unstable_by(|a, b| { - let ord = match sort_by { - SortBy::Date => b.meta.datetime.unwrap().cmp(&a.meta.datetime.unwrap()), - SortBy::UpdateDate => std::cmp::max(b.meta.datetime, b.meta.updated_datetime) - .unwrap() - .cmp(&std::cmp::max(a.meta.datetime, a.meta.updated_datetime).unwrap()), - SortBy::Title => { - natural_lexical_cmp(a.meta.title.as_ref().unwrap(), b.meta.title.as_ref().unwrap()) - } - SortBy::TitleBytes => { - a.meta.title.as_ref().unwrap().cmp(b.meta.title.as_ref().unwrap()) - } - SortBy::Weight => a.meta.weight.unwrap().cmp(&b.meta.weight.unwrap()), - SortBy::Slug => natural_lexical_cmp(&a.slug, &b.slug), - SortBy::None => unreachable!(), - }; - + let ord = a.cmp(b, sort_by); if ord == Ordering::Equal { - a.permalink.cmp(&b.permalink) + a.get_permalink().cmp(&b.get_permalink()) } else { ord } }); ( - can_be_sorted.iter().map(|p| p.file.path.clone()).collect(), - cannot_be_sorted.iter().map(|p: &&Page| p.file.path.clone()).collect(), + can_be_sorted.into_iter().map(|p| p.get_filepath().to_path_buf()).collect(), + cannot_be_sorted.into_iter().map(|p: &S| p.get_filepath().to_path_buf()).collect(), ) } #[cfg(test)] mod tests { use super::*; - use crate::PageFrontMatter; + use crate::{Page, PageFrontMatter}; fn create_page_with_date(date: &str, updated_date: Option<&str>) -> Page { let mut front_matter = PageFrontMatter { diff --git a/components/site/tests/site.rs b/components/site/tests/site.rs index baca868e2..6ad53e979 100644 --- a/components/site/tests/site.rs +++ b/components/site/tests/site.rs @@ -43,7 +43,7 @@ fn can_parse_site() { assert!(index_section.ancestors.is_empty()); let posts_section = library.sections.get(&posts_path.join("_index.md")).unwrap(); - assert_eq!(posts_section.subsections.len(), 2); + assert_eq!(posts_section.ignored_subsections.len(), 2); assert_eq!(posts_section.pages.len(), 10); // 11 with 1 draft == 10 assert_eq!(posts_section.ancestors, vec![index_section.file.relative.clone()]); diff --git a/docs/content/documentation/content/section.md b/docs/content/documentation/content/section.md index 9a726e7bd..c398508aa 100644 --- a/docs/content/documentation/content/section.md +++ b/docs/content/documentation/content/section.md @@ -208,16 +208,13 @@ to newest (at the bottom). 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`, -and do not have variables that point to the heavier/lighter sections. +Sorting sections is a bit less flexible: sections can only be sorted by `weight` +and `title`. Similarly to pages, section have `section.lower` and `section.higher` +variables, pointing to the heavier/lighter sections. By default, the lightest (lowest `weight`) subsections will be at the top of the list and the heaviest (highest `weight`) will be at the bottom; the `reverse` filter reverses this order. -**Note**: Unlike pages, permalinks will **not** be used to break ties between -equally weighted sections. Thus, if the `weight` variable for your section is not set (or if it -is set in a way that produces ties), then your sections will be sorted in -**random** order. Moreover, that order is determined at build time and will -change with each site rebuild. Thus, if there is any chance that you will -iterate over your sections, you should always assign them distinct weights. +**Note**: A section's "transparency" doesn't apply to subsections, so subsections +will not be visible as direct children of the parent section.