From c3986b701a2f1d14fe7236b4eb655a46f41b5f8c Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Fri, 16 Jun 2017 13:00:48 +0900 Subject: [PATCH] Add table of contents support --- src/content/mod.rs | 2 + src/content/page.rs | 16 ++- src/content/section.rs | 14 ++- src/content/table_of_contents.rs | 166 +++++++++++++++++++++++++++++++ src/rendering/context.rs | 10 +- src/rendering/markdown.rs | 140 +++++++++++++++++--------- 6 files changed, 292 insertions(+), 56 deletions(-) create mode 100644 src/content/table_of_contents.rs diff --git a/src/content/mod.rs b/src/content/mod.rs index b7f9fd76..1aa8521b 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -5,10 +5,12 @@ mod sorting; mod utils; mod file_info; mod taxonomies; +mod table_of_contents; pub use self::page::{Page}; pub use self::section::{Section}; pub use self::pagination::{Paginator, Pager}; pub use self::sorting::{SortBy, sort_pages, populate_previous_and_next_pages}; pub use self::taxonomies::{Taxonomy, TaxonomyItem}; +pub use self::table_of_contents::{TempHeader, Header, make_table_of_contents}; diff --git a/src/content/page.rs b/src/content/page.rs index ad389178..4f7d60cb 100644 --- a/src/content/page.rs +++ b/src/content/page.rs @@ -16,6 +16,7 @@ use rendering::context::Context; use fs::{read_file}; use content::utils::{find_related_assets, get_reading_analytics}; use content::file_info::FileInfo; +use content::Header; #[derive(Clone, Debug, PartialEq)] @@ -45,6 +46,8 @@ pub struct Page { pub previous: Option>, /// The next page, by whatever sorting is used for the index/section pub next: Option>, + /// Toc made from the headers of the markdown file + pub toc: Vec
, } @@ -64,6 +67,7 @@ impl Page { summary: None, previous: None, next: None, + toc: vec![], } } @@ -117,12 +121,14 @@ impl Page { /// We need access to all pages url to render links relative to content /// so that can't happen at the same time as parsing pub fn render_markdown(&mut self, permalinks: &HashMap, tera: &Tera, config: &Config, anchor_insert: InsertAnchor) -> Result<()> { - let context = Context::new(tera, config, permalinks, anchor_insert); - self.content = markdown_to_html(&self.raw_content, &context)?; + let context = Context::new(tera, config, &self.permalink, permalinks, anchor_insert); + let res = markdown_to_html(&self.raw_content, &context)?; + self.content = res.0; + self.toc = res.1; if self.raw_content.contains("") { self.summary = Some({ let summary = self.raw_content.splitn(2, "").collect::>()[0]; - markdown_to_html(summary, &context)? + markdown_to_html(summary, &context)?.0 }) } @@ -161,13 +167,14 @@ impl Default for Page { summary: None, previous: None, next: None, + toc: vec![], } } } impl ser::Serialize for Page { fn serialize(&self, serializer: S) -> StdResult where S: ser::Serializer { - let mut state = serializer.serialize_struct("page", 15)?; + let mut state = serializer.serialize_struct("page", 16)?; state.serialize_field("content", &self.content)?; state.serialize_field("title", &self.meta.title)?; state.serialize_field("description", &self.meta.description)?; @@ -184,6 +191,7 @@ impl ser::Serialize for Page { state.serialize_field("reading_time", &reading_time)?; state.serialize_field("previous", &self.previous)?; state.serialize_field("next", &self.next)?; + state.serialize_field("toc", &self.toc)?; state.end() } } diff --git a/src/content/section.rs b/src/content/section.rs index b9e6f30f..5f21b4ee 100644 --- a/src/content/section.rs +++ b/src/content/section.rs @@ -13,6 +13,7 @@ use rendering::markdown::markdown_to_html; use rendering::context::Context; use content::Page; use content::file_info::FileInfo; +use content::Header; #[derive(Clone, Debug, PartialEq)] @@ -35,6 +36,8 @@ pub struct Section { pub ignored_pages: Vec, /// All direct subsections pub subsections: Vec
, + /// Toc made from the headers of the markdown file + pub toc: Vec
, } impl Section { @@ -51,6 +54,7 @@ impl Section { pages: vec![], ignored_pages: vec![], subsections: vec![], + toc: vec![], } } @@ -86,8 +90,10 @@ impl Section { /// We need access to all pages url to render links relative to content /// so that can't happen at the same time as parsing pub fn render_markdown(&mut self, permalinks: &HashMap, tera: &Tera, config: &Config) -> Result<()> { - let context = Context::new(tera, config, permalinks, self.meta.insert_anchor.unwrap()); - self.content = markdown_to_html(&self.raw_content, &context)?; + let context = Context::new(tera, config, &self.permalink, permalinks, self.meta.insert_anchor.unwrap()); + let res = markdown_to_html(&self.raw_content, &context)?; + self.content = res.0; + self.toc = res.1; Ok(()) } @@ -129,7 +135,7 @@ impl Section { impl ser::Serialize for Section { fn serialize(&self, serializer: S) -> StdResult where S: ser::Serializer { - let mut state = serializer.serialize_struct("section", 9)?; + let mut state = serializer.serialize_struct("section", 10)?; state.serialize_field("content", &self.content)?; state.serialize_field("permalink", &self.permalink)?; state.serialize_field("title", &self.meta.title)?; @@ -139,6 +145,7 @@ impl ser::Serialize for Section { state.serialize_field("permalink", &self.permalink)?; state.serialize_field("pages", &self.pages)?; state.serialize_field("subsections", &self.subsections)?; + state.serialize_field("toc", &self.toc)?; state.end() } } @@ -156,6 +163,7 @@ impl Default for Section { pages: vec![], ignored_pages: vec![], subsections: vec![], + toc: vec![], } } } diff --git a/src/content/table_of_contents.rs b/src/content/table_of_contents.rs new file mode 100644 index 00000000..dcd9ba83 --- /dev/null +++ b/src/content/table_of_contents.rs @@ -0,0 +1,166 @@ + +#[derive(Debug, PartialEq, Clone, Serialize)] +pub struct Header { + #[serde(skip_serializing)] + pub level: i32, + pub id: String, + pub title: String, + pub permalink: String, + pub children: Vec
, +} + +impl Header { + pub fn from_temp_header(tmp: &TempHeader, children: Vec
) -> Header { + Header { + level: tmp.level, + id: tmp.id.clone(), + title: tmp.title.clone(), + permalink: tmp.permalink.clone(), + children, + } + } +} + +/// Used in +#[derive(Debug, PartialEq, Clone)] +pub struct TempHeader { + pub level: i32, + pub id: String, + pub permalink: String, + pub title: String, +} + +impl TempHeader { + pub fn new(level: i32) -> TempHeader { + TempHeader { + level, + id: String::new(), + permalink: String::new(), + title: String::new(), + } + } +} + +impl Default for TempHeader { + fn default() -> Self { + TempHeader::new(0) + } +} + + +/// Recursively finds children of a header +fn find_children(parent_level: i32, start_at: usize, temp_headers: &[TempHeader]) -> (usize, Vec
) { + let mut headers = vec![]; + + let mut start_at = start_at; + // If we have children, we will need to skip some headers since they are already inserted + let mut to_skip = 0; + + for h in &temp_headers[start_at..] { + // stop when we encounter a title at the same level or higher + // than the parent one. Here a lower integer is considered higher as we are talking about + // HTML headers: h1, h2, h3, h4, h5 and h6 + if h.level <= parent_level { + return (start_at, headers); + } + + // Do we need to skip some headers? + if to_skip > 0 { + to_skip -= 1; + continue; + } + + let (end, children) = find_children(h.level, start_at + 1, &temp_headers); + headers.push(Header::from_temp_header(h, children)); + + // we didn't find any children + if end == start_at { + start_at += 1; + to_skip = 0; + } else { + // calculates how many we need to skip. Since the find_children start_at starts at 1, + // we need to remove 1 to ensure correctness + to_skip = end - start_at - 1; + start_at = end; + } + + // we don't want to index out of bounds + if start_at + 1 > temp_headers.len() { + return (start_at, headers); + } + } + + (start_at, headers) +} + + +/// Converts the flat temp headers into a nested set of headers +/// representing the hierarchy +pub fn make_table_of_contents(temp_headers: Vec) -> Vec
{ + let mut toc = vec![]; + let mut start_idx = 0; + for (i, h) in temp_headers.iter().enumerate() { + if i < start_idx { + continue; + } + let (end_idx, children) = find_children(h.level, start_idx + 1, &temp_headers); + start_idx = end_idx; + toc.push(Header::from_temp_header(h, children)); + } + + toc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_make_basic_toc() { + let input = vec![ + TempHeader::new(1), + TempHeader::new(1), + TempHeader::new(1), + ]; + let toc = make_table_of_contents(input); + assert_eq!(toc.len(), 3); + } + + #[test] + fn can_make_more_complex_toc() { + let input = vec![ + TempHeader::new(1), + TempHeader::new(2), + TempHeader::new(2), + TempHeader::new(3), + TempHeader::new(2), + TempHeader::new(1), + TempHeader::new(2), + TempHeader::new(3), + TempHeader::new(3), + ]; + let toc = make_table_of_contents(input); + assert_eq!(toc.len(), 2); + assert_eq!(toc[0].children.len(), 3); + assert_eq!(toc[1].children.len(), 1); + assert_eq!(toc[0].children[1].children.len(), 1); + assert_eq!(toc[1].children[0].children.len(), 2); + } + + #[test] + fn can_make_messy_toc() { + let input = vec![ + TempHeader::new(3), + TempHeader::new(2), + TempHeader::new(2), + TempHeader::new(3), + TempHeader::new(2), + TempHeader::new(1), + TempHeader::new(4), + ]; + let toc = make_table_of_contents(input); + assert_eq!(toc.len(), 5); + assert_eq!(toc[2].children.len(), 1); + assert_eq!(toc[4].children.len(), 1); + } +} diff --git a/src/rendering/context.rs b/src/rendering/context.rs index d7f77c43..63eafbd4 100644 --- a/src/rendering/context.rs +++ b/src/rendering/context.rs @@ -12,14 +12,22 @@ pub struct Context<'a> { pub tera: &'a Tera, pub highlight_code: bool, pub highlight_theme: String, + pub current_page_permalink: String, pub permalinks: &'a HashMap, pub insert_anchor: InsertAnchor, } impl<'a> Context<'a> { - pub fn new(tera: &'a Tera, config: &'a Config, permalinks: &'a HashMap, insert_anchor: InsertAnchor) -> Context<'a> { + pub fn new( + tera: &'a Tera, + config: &'a Config, + current_page_permalink: &str, + permalinks: &'a HashMap, + insert_anchor: InsertAnchor, + ) -> Context<'a> { Context { tera, + current_page_permalink: current_page_permalink.to_string(), permalinks, insert_anchor, highlight_code: config.highlight_code.unwrap(), diff --git a/src/rendering/markdown.rs b/src/rendering/markdown.rs index 4481971f..19826ff6 100644 --- a/src/rendering/markdown.rs +++ b/src/rendering/markdown.rs @@ -16,6 +16,7 @@ use front_matter::InsertAnchor; use rendering::context::Context; use rendering::highlighting::THEME_SET; use rendering::short_code::{ShortCode, parse_shortcode, render_simple_shortcode}; +use content::{TempHeader, Header, make_table_of_contents}; // We need to put those in a struct to impl Send and sync pub struct Setup { @@ -37,7 +38,7 @@ lazy_static!{ } -pub fn markdown_to_html(content: &str, context: &Context) -> Result { +pub fn markdown_to_html(content: &str, context: &Context) -> Result<(String, Vec
)> { // We try to be smart about highlighting code as it can be time-consuming // If the global config disables it, then we do nothing. However, // if we see a code block in the content, we assume that this page needs @@ -62,9 +63,10 @@ pub fn markdown_to_html(content: &str, context: &Context) -> Result { // pulldown_cmark can send several text events for a title if there are markdown // specific characters like `!` in them. We only want to insert the anchor the first time let mut header_already_inserted = false; + let mut anchors: Vec = vec![]; + // the rendered html let mut html = String::new(); - let mut anchors: Vec = vec![]; // We might have cases where the slug is already present in our list of anchor // for example an article could have several titles named Example @@ -83,6 +85,11 @@ pub fn markdown_to_html(content: &str, context: &Context) -> Result { find_anchor(anchors, name, level + 1) } + let mut headers = vec![]; + // Defaults to a 0 level so not a real header + // It should be an Option ideally but not worth the hassle to update + let mut temp_header = TempHeader::default(); + let mut opts = Options::empty(); opts.insert(OPTION_ENABLE_TABLES); opts.insert(OPTION_ENABLE_FOOTNOTES); @@ -158,6 +165,13 @@ pub fn markdown_to_html(content: &str, context: &Context) -> Result { } else { String::new() }; + // update the header and add it to the list + temp_header.id = id.clone(); + temp_header.title = text.clone().into_owned(); + temp_header.permalink = format!("{}#{}", context.current_page_permalink, id); + headers.push(temp_header.clone()); + temp_header = TempHeader::default(); + header_already_inserted = true; let event = match context.insert_anchor { InsertAnchor::Left => Event::Html(Owned(format!(r#"id="{}">{}{}"#, id, anchor_link, text))), @@ -230,6 +244,7 @@ pub fn markdown_to_html(content: &str, context: &Context) -> Result { }, Event::Start(Tag::Header(num)) => { in_header = true; + temp_header = TempHeader::new(num); // ugly eh Event::Html(Owned(format!(" Result { match error { Some(e) => Err(e), - None => Ok(html.replace("

", "")), + None => Ok((html.replace("

", ""), make_table_of_contents(headers))), } } @@ -287,9 +302,9 @@ mod tests { let tera_ctx = Tera::default(); let permalinks_ctx = HashMap::new(); let config_ctx = Config::default(); - let context = Context::new(&tera_ctx, &config_ctx, &permalinks_ctx, InsertAnchor::None); + let context = Context::new(&tera_ctx, &config_ctx, "", &permalinks_ctx, InsertAnchor::None); let res = markdown_to_html("hello", &context).unwrap(); - assert_eq!(res, "

hello

\n"); + assert_eq!(res.0, "

hello

\n"); } #[test] @@ -297,11 +312,11 @@ mod tests { let tera_ctx = Tera::default(); let permalinks_ctx = HashMap::new(); let config_ctx = Config::default(); - let mut context = Context::new(&tera_ctx, &config_ctx, &permalinks_ctx, InsertAnchor::None); + let mut context = Context::new(&tera_ctx, &config_ctx, "", &permalinks_ctx, InsertAnchor::None); context.highlight_code = false; let res = markdown_to_html("```\n$ gutenberg server\n```", &context).unwrap(); assert_eq!( - res, + res.0, "
$ gutenberg server\n
\n" ); } @@ -311,10 +326,10 @@ mod tests { let tera_ctx = Tera::default(); let permalinks_ctx = HashMap::new(); let config_ctx = Config::default(); - let context = Context::new(&tera_ctx, &config_ctx, &permalinks_ctx, InsertAnchor::None); + let context = Context::new(&tera_ctx, &config_ctx, "", &permalinks_ctx, InsertAnchor::None); let res = markdown_to_html("```\n$ gutenberg server\n$ ping\n```", &context).unwrap(); assert_eq!( - res, + res.0, "
\n$ gutenberg server\n$ ping\n
" ); } @@ -324,10 +339,10 @@ mod tests { let tera_ctx = Tera::default(); let permalinks_ctx = HashMap::new(); let config_ctx = Config::default(); - let context = Context::new(&tera_ctx, &config_ctx, &permalinks_ctx, InsertAnchor::None); + let context = Context::new(&tera_ctx, &config_ctx, "", &permalinks_ctx, InsertAnchor::None); let res = markdown_to_html("```python\nlist.append(1)\n```", &context).unwrap(); assert_eq!( - res, + res.0, "
\nlist.append(1)\n
" ); } @@ -337,11 +352,11 @@ mod tests { let tera_ctx = Tera::default(); let permalinks_ctx = HashMap::new(); let config_ctx = Config::default(); - let context = Context::new(&tera_ctx, &config_ctx, &permalinks_ctx, InsertAnchor::None); + let context = Context::new(&tera_ctx, &config_ctx, "", &permalinks_ctx, InsertAnchor::None); let res = markdown_to_html("```yolo\nlist.append(1)\n```", &context).unwrap(); // defaults to plain text assert_eq!( - res, + res.0, "
\nlist.append(1)\n
" ); } @@ -350,21 +365,21 @@ mod tests { fn can_render_shortcode() { let permalinks_ctx = HashMap::new(); let config_ctx = Config::default(); - let context = Context::new(&GUTENBERG_TERA, &config_ctx, &permalinks_ctx, InsertAnchor::None); + let context = Context::new(&GUTENBERG_TERA, &config_ctx, "", &permalinks_ctx, InsertAnchor::None); let res = markdown_to_html(r#" Hello {{ youtube(id="ub36ffWAqgQ") }} "#, &context).unwrap(); - assert!(res.contains("

Hello

\n
")); - assert!(res.contains(r#"