Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sorted subsections #2372

Open
wants to merge 4 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.19.0 (unreleased)


## 0.18.0 (2023-12-18)

- Fix LFI in `zola serve`
Expand Down
143 changes: 143 additions & 0 deletions components/content/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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);
}
}
45 changes: 45 additions & 0 deletions components/content/src/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;

Expand Down Expand Up @@ -88,6 +91,48 @@ pub struct Page {
pub external_links: Vec<String>,
}

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<P: AsRef<Path>>(file_path: P, meta: PageFrontMatter, base_path: &Path) -> Page {
let file_path = file_path.as_ref();
Expand Down
47 changes: 47 additions & 0 deletions components/content/src/section.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)]
Expand All @@ -30,6 +33,10 @@ pub struct Section {
pub components: Vec<String>,
/// The full URL for that page
pub permalink: String,
/// The previous section when sorting: earlier/earlier_updated/lighter/prev
pub lower: Option<PathBuf>,
/// The next section when sorting: later/later_updated/heavier/next
pub higher: Option<PathBuf>,
/// The actual content of the page, in markdown
pub raw_content: String,
/// The HTML rendered of the page
Expand All @@ -46,6 +53,8 @@ pub struct Section {
pub ancestors: Vec<String>,
/// All direct subsections
pub subsections: Vec<PathBuf>,
/// All subsection that cannot be sorted in this section
pub ignored_subsections: Vec<PathBuf>,
/// Toc made from the headings of the markdown file
pub toc: Vec<Heading>,
/// How many words in the raw content
Expand All @@ -64,6 +73,44 @@ pub struct Section {
pub external_links: Vec<String>,
}

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<P: AsRef<Path>>(
file_path: P,
Expand Down
50 changes: 16 additions & 34 deletions components/content/src/sorting.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>, Vec<PathBuf>) {
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<S: Sortable>(pages: &[&S], sort_by: SortBy) -> (Vec<PathBuf>, Vec<PathBuf>) {
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 {
Expand Down
Loading