diff --git a/src/book/book.rs b/src/book/book.rs index 1fb9e94b..7dc0d110 100644 --- a/src/book/book.rs +++ b/src/book/book.rs @@ -31,7 +31,12 @@ fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> { let mut items: Vec<_> = summary .prefix_chapters .iter() - .chain(summary.numbered_chapters.iter()) + .chain( + summary + .parts + .iter() + .flat_map(|part| part.numbered_chapters.iter()), + ) .chain(summary.suffix_chapters.iter()) .collect(); @@ -133,6 +138,8 @@ pub enum BookItem { Chapter(Chapter), /// A section separator. Separator, + /// A part title. + PartTitle(String), } impl From for BookItem { @@ -205,17 +212,24 @@ pub(crate) fn load_book_from_disk>(summary: &Summary, src_dir: P) debug!("Loading the book from disk"); let src_dir = src_dir.as_ref(); - let prefix = summary.prefix_chapters.iter(); - let numbered = summary.numbered_chapters.iter(); - let suffix = summary.suffix_chapters.iter(); - - let summary_items = prefix.chain(numbered).chain(suffix); - let mut chapters = Vec::new(); - for summary_item in summary_items { - let chapter = load_summary_item(summary_item, src_dir, Vec::new())?; - chapters.push(chapter); + for prefix_chapter in &summary.prefix_chapters { + chapters.push(load_summary_item(prefix_chapter, src_dir, Vec::new())?); + } + + for part in &summary.parts { + if let Some(title) = &part.title { + chapters.push(BookItem::PartTitle(title.clone())); + } + + for numbered_chapter in &part.numbered_chapters { + chapters.push(load_summary_item(numbered_chapter, src_dir, Vec::new())?); + } + } + + for suffix_chapter in &summary.suffix_chapters { + chapters.push(load_summary_item(suffix_chapter, src_dir, Vec::new())?); } Ok(Book { @@ -327,6 +341,7 @@ impl Display for Chapter { #[cfg(test)] mod tests { use super::*; + use crate::book::summary::Part; use std::io::Write; use tempfile::{Builder as TempFileBuilder, TempDir}; @@ -430,7 +445,10 @@ And here is some \ fn load_a_book_with_a_single_chapter() { let (link, temp) = dummy_link(); let summary = Summary { - numbered_chapters: vec![SummaryItem::Link(link)], + parts: vec![Part { + title: None, + numbered_chapters: vec![SummaryItem::Link(link)], + }], ..Default::default() }; let should_be = Book { @@ -564,11 +582,14 @@ And here is some \ fn cant_load_chapters_with_an_empty_path() { let (_, temp) = dummy_link(); let summary = Summary { - numbered_chapters: vec![SummaryItem::Link(Link { - name: String::from("Empty"), - location: Some(PathBuf::from("")), - ..Default::default() - })], + parts: vec![Part { + title: None, + numbered_chapters: vec![SummaryItem::Link(Link { + name: String::from("Empty"), + location: Some(PathBuf::from("")), + ..Default::default() + })], + }], ..Default::default() }; @@ -583,11 +604,14 @@ And here is some \ fs::create_dir(&dir).unwrap(); let summary = Summary { - numbered_chapters: vec![SummaryItem::Link(Link { - name: String::from("nested"), - location: Some(dir), - ..Default::default() - })], + parts: vec![Part { + title: None, + numbered_chapters: vec![SummaryItem::Link(Link { + name: String::from("nested"), + location: Some(dir), + ..Default::default() + })], + }], ..Default::default() }; diff --git a/src/book/mod.rs b/src/book/mod.rs index 5711eb5e..67c3491e 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -132,6 +132,7 @@ impl MDBook { /// match *item { /// BookItem::Chapter(ref chapter) => {}, /// BookItem::Separator => {}, + /// BookItem::PartTitle(ref title) => {} /// } /// } /// diff --git a/src/book/summary.rs b/src/book/summary.rs index 8fc9e8fc..a9ef7ee7 100644 --- a/src/book/summary.rs +++ b/src/book/summary.rs @@ -25,12 +25,17 @@ use std::path::{Path, PathBuf}; /// [Title of prefix element](relative/path/to/markdown.md) /// ``` /// +/// **Part Title:** An optional title for the next collect of numbered chapters. The numbered +/// chapters can be broken into as many parts as desired. +/// /// **Numbered Chapter:** Numbered chapters are the main content of the book, /// they /// will be numbered and can be nested, resulting in a nice hierarchy (chapters, /// sub-chapters, etc.) /// /// ```markdown +/// # Title of Part +/// /// - [Title of the Chapter](relative/path/to/markdown.md) /// ``` /// @@ -55,12 +60,23 @@ pub struct Summary { pub title: Option, /// Chapters before the main text (e.g. an introduction). pub prefix_chapters: Vec, - /// The main chapters in the document. - pub numbered_chapters: Vec, + /// The main numbered chapters of the book, broken into one or more possibly named parts. + pub parts: Vec, /// Items which come after the main document (e.g. a conclusion). pub suffix_chapters: Vec, } +/// A struct representing a "part" in the `SUMMARY.md`. This is a possibly-titled section with +/// numbered chapters in it. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Part { + /// An optional title for the `SUMMARY.md`, currently just ignored. + pub title: Option, + + /// The main chapters in the document. + pub numbered_chapters: Vec, +} + /// A struct representing an entry in the `SUMMARY.md`, possibly with nested /// entries. /// @@ -134,12 +150,13 @@ impl From for SummaryItem { /// /// ```text /// summary ::= title prefix_chapters numbered_chapters -/// suffix_chapters +/// suffix_chapters /// title ::= "# " TEXT /// | EPSILON /// prefix_chapters ::= item* /// suffix_chapters ::= item* -/// numbered_chapters ::= dotted_item+ +/// numbered_chapters ::= part+ +/// part ::= title dotted_item+ /// dotted_item ::= INDENT* DOT_POINT item /// item ::= link /// | separator @@ -155,6 +172,10 @@ struct SummaryParser<'a> { src: &'a str, stream: pulldown_cmark::OffsetIter<'a>, offset: usize, + + /// We can't actually put an event back into the `OffsetIter` stream, so instead we store it + /// here until somebody calls `next_event` again. + back: Option>, } /// Reads `Events` from the provided stream until the corresponding @@ -203,6 +224,7 @@ impl<'a> SummaryParser<'a> { src: text, stream: pulldown_parser, offset: 0, + back: None, } } @@ -224,8 +246,8 @@ impl<'a> SummaryParser<'a> { let prefix_chapters = self .parse_affix(true) .chain_err(|| "There was an error parsing the prefix chapters")?; - let numbered_chapters = self - .parse_numbered() + let parts = self + .parse_parts() .chain_err(|| "There was an error parsing the numbered chapters")?; let suffix_chapters = self .parse_affix(false) @@ -234,13 +256,12 @@ impl<'a> SummaryParser<'a> { Ok(Summary { title, prefix_chapters, - numbered_chapters, + parts, suffix_chapters, }) } - /// Parse the affix chapters. This expects the first event (start of - /// paragraph) to have already been consumed by the previous parser. + /// Parse the affix chapters. fn parse_affix(&mut self, is_prefix: bool) -> Result> { let mut items = Vec::new(); debug!( @@ -250,10 +271,12 @@ impl<'a> SummaryParser<'a> { loop { match self.next_event() { - Some(Event::Start(Tag::List(..))) => { + Some(ev @ Event::Start(Tag::List(..))) + | Some(ev @ Event::Start(Tag::Heading(1))) => { if is_prefix { // we've finished prefix chapters and are at the start // of the numbered section. + self.back(ev); break; } else { bail!(self.parse_error("Suffix chapters cannot be followed by a list")); @@ -272,6 +295,52 @@ impl<'a> SummaryParser<'a> { Ok(items) } + fn parse_parts(&mut self) -> Result> { + let mut parts = vec![]; + + // We want the section numbers to be continues through all parts. + let mut root_number = SectionNumber::default(); + let mut root_items = 0; + + loop { + // Possibly match a title or the end of the "numbered chapters part". + let title = match self.next_event() { + Some(ev @ Event::Start(Tag::Paragraph)) => { + // we're starting the suffix chapters + self.back(ev); + break; + } + + Some(Event::Start(Tag::Heading(1))) => { + debug!("Found a h1 in the SUMMARY"); + + let tags = collect_events!(self.stream, end Tag::Heading(1)); + Some(stringify_events(tags)) + } + + Some(ev) => { + self.back(ev); + None + } + + None => break, // EOF, bail... + }; + + // Parse the rest of the part. + let numbered_chapters = self + .parse_numbered(&mut root_items, &mut root_number) + .chain_err(|| "There was an error parsing the numbered chapters")?; + + parts.push(Part { + title, + numbered_chapters, + }); + } + + Ok(parts) + } + + /// Finishes parsing a link once the `Event::Start(Tag::Link(..))` has been opened. fn parse_link(&mut self, href: String) -> Link { let link_content = collect_events!(self.stream, end Tag::Link(..)); let name = stringify_events(link_content); @@ -290,36 +359,44 @@ impl<'a> SummaryParser<'a> { } } - /// Parse the numbered chapters. This assumes the opening list tag has - /// already been consumed by a previous parser. - fn parse_numbered(&mut self) -> Result> { + /// Parse the numbered chapters. + fn parse_numbered( + &mut self, + root_items: &mut u32, + root_number: &mut SectionNumber, + ) -> Result> { let mut items = Vec::new(); - let mut root_items = 0; - let root_number = SectionNumber::default(); - // we need to do this funny loop-match-if-let dance because a rule will - // close off any currently running list. Therefore we try to read the - // list items before the rule, then if we encounter a rule we'll add a - // separator and try to resume parsing numbered chapters if we start a - // list immediately afterwards. - // - // If you can think of a better way to do this then please make a PR :) + // For the first iteration, we want to just skip any opening paragraph tags, as that just + // marks the start of the list. But after that, another opening paragraph indicates that we + // have started a new part or the suffix chapters. + let mut first = true; loop { - let mut bunch_of_items = self.parse_nested_numbered(&root_number)?; - - // if we've resumed after something like a rule the root sections - // will be numbered from 1. We need to manually go back and update - // them - update_section_numbers(&mut bunch_of_items, 0, root_items); - root_items += bunch_of_items.len() as u32; - items.extend(bunch_of_items); - match self.next_event() { - Some(Event::Start(Tag::Paragraph)) => { + Some(ev @ Event::Start(Tag::Paragraph)) if !first => { // we're starting the suffix chapters + self.back(ev); break; } + // The expectation is that pulldown cmark will terminate a paragraph before a new + // heading, so we can always count on this to return without skipping headings. + Some(ev @ Event::Start(Tag::Heading(1))) => { + // we're starting a new part + self.back(ev); + break; + } + Some(ev @ Event::Start(Tag::List(..))) => { + self.back(ev); + let mut bunch_of_items = self.parse_nested_numbered(&root_number)?; + + // if we've resumed after something like a rule the root sections + // will be numbered from 1. We need to manually go back and update + // them + update_section_numbers(&mut bunch_of_items, 0, *root_items); + *root_items += bunch_of_items.len() as u32; + items.extend(bunch_of_items); + } Some(Event::Start(other_tag)) => { trace!("Skipping contents of {:?}", other_tag); @@ -329,40 +406,42 @@ impl<'a> SummaryParser<'a> { break; } } - - if let Some(Event::Start(Tag::List(..))) = self.next_event() { - continue; - } else { - break; - } } Some(Event::Rule) => { items.push(SummaryItem::Separator); - if let Some(Event::Start(Tag::List(..))) = self.next_event() { - continue; - } else { - break; - } - } - Some(_) => { - // something else... ignore - continue; } + + // something else... ignore + Some(_) => {} + + // EOF, bail... None => { - // EOF, bail... break; } } + + // From now on, we cannot accept any new paragraph opening tags. + first = false; } Ok(items) } + /// Push an event back to the tail of the stream. + fn back(&mut self, ev: Event<'a>) { + assert!(self.back.is_none()); + trace!("Back: {:?}", ev); + self.back = Some(ev); + } + fn next_event(&mut self) -> Option> { - let next = self.stream.next().map(|(ev, range)| { - self.offset = range.start; - ev + let next = self.back.take().or_else(|| { + self.stream.next().map(|(ev, range)| { + self.offset = range.start; + ev + }) }); + trace!("Next event: {:?}", next); next @@ -448,13 +527,14 @@ impl<'a> SummaryParser<'a> { /// Try to parse the title line. fn parse_title(&mut self) -> Option { - if let Some(Event::Start(Tag::Heading(1))) = self.next_event() { - debug!("Found a h1 in the SUMMARY"); + match self.next_event() { + Some(Event::Start(Tag::Heading(1))) => { + debug!("Found a h1 in the SUMMARY"); - let tags = collect_events!(self.stream, end Tag::Heading(1)); - Some(stringify_events(tags)) - } else { - None + let tags = collect_events!(self.stream, end Tag::Heading(1)); + Some(stringify_events(tags)) + } + _ => None, } } } @@ -604,7 +684,6 @@ mod tests { }), ]; - let _ = parser.stream.next(); // step past first event let got = parser.parse_affix(true).unwrap(); assert_eq!(got, should_be); @@ -615,7 +694,6 @@ mod tests { let src = "[First](./first.md)\n\n---\n\n[Second](./second.md)\n"; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); // step past first event let got = parser.parse_affix(true).unwrap(); assert_eq!(got.len(), 3); @@ -627,7 +705,6 @@ mod tests { let src = "[First](./first.md)\n- [Second](./second.md)\n"; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); // step past first event let got = parser.parse_affix(false); assert!(got.is_err()); @@ -643,7 +720,7 @@ mod tests { }; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); // skip past start of paragraph + let _ = parser.stream.next(); // Discard opening paragraph let href = match parser.stream.next() { Some((Event::Start(Tag::Link(_type, href, _title)), _range)) => href.to_string(), @@ -666,9 +743,9 @@ mod tests { let should_be = vec![SummaryItem::Link(link)]; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); - - let got = parser.parse_numbered().unwrap(); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); assert_eq!(got, should_be); } @@ -698,9 +775,9 @@ mod tests { ]; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); - - let got = parser.parse_numbered().unwrap(); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); assert_eq!(got, should_be); } @@ -725,9 +802,54 @@ mod tests { ]; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); - let got = parser.parse_numbered().unwrap(); + assert_eq!(got, should_be); + } + + #[test] + fn parse_titled_parts() { + let src = "- [First](./first.md)\n- [Second](./second.md)\n\ + # Title 2\n- [Third](./third.md)\n\t- [Fourth](./fourth.md)"; + + let should_be = vec![ + Part { + title: None, + numbered_chapters: vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + }), + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + ], + }, + Part { + title: Some(String::from("Title 2")), + numbered_chapters: vec![SummaryItem::Link(Link { + name: String::from("Third"), + location: Some(PathBuf::from("./third.md")), + number: Some(SectionNumber(vec![3])), + nested_items: vec![SummaryItem::Link(Link { + name: String::from("Fourth"), + location: Some(PathBuf::from("./fourth.md")), + number: Some(SectionNumber(vec![3, 1])), + nested_items: Vec::new(), + })], + })], + }, + ]; + + let mut parser = SummaryParser::new(src); + let got = parser.parse_parts().unwrap(); assert_eq!(got, should_be); } @@ -755,9 +877,9 @@ mod tests { ]; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); - - let got = parser.parse_numbered().unwrap(); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); assert_eq!(got, should_be); } @@ -766,9 +888,8 @@ mod tests { fn an_empty_link_location_is_a_draft_chapter() { let src = "- [Empty]()\n"; let mut parser = SummaryParser::new(src); - parser.stream.next(); - let got = parser.parse_numbered(); + let got = parser.parse_numbered(&mut 0, &mut SectionNumber::default()); let should_be = vec![SummaryItem::Link(Link { name: String::from("Empty"), location: None, @@ -810,9 +931,9 @@ mod tests { ]; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); - - let got = parser.parse_numbered().unwrap(); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); assert_eq!(got, should_be); } diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 80226374..35f72d26 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -514,6 +514,9 @@ fn make_data( let mut chapter = BTreeMap::new(); match *item { + BookItem::PartTitle(ref title) => { + chapter.insert("part".to_owned(), json!(title)); + } BookItem::Chapter(ref ch) => { if let Some(ref section) = ch.number { chapter.insert("section".to_owned(), json!(section.to_string())); diff --git a/src/renderer/html_handlebars/helpers/toc.rs b/src/renderer/html_handlebars/helpers/toc.rs index 67fe4101..33857d86 100644 --- a/src/renderer/html_handlebars/helpers/toc.rs +++ b/src/renderer/html_handlebars/helpers/toc.rs @@ -99,6 +99,14 @@ impl HelperDef for RenderToc { write_li_open_tag(out, is_expanded, item.get("section").is_none())?; } + // Part title + if let Some(title) = item.get("part") { + out.write("
  • ")?; + out.write(title)?; + out.write("
  • ")?; + continue; + } + // Link let path_exists = if let Some(path) = item.get("path") { if !path.is_empty() { diff --git a/src/theme/css/general.css b/src/theme/css/general.css index e2df5d65..b5240244 100644 --- a/src/theme/css/general.css +++ b/src/theme/css/general.css @@ -166,3 +166,8 @@ blockquote { .tooltipped .tooltiptext { visibility: visible; } + +.chapter li.part-title { + color: var(--sidebar-fg); + margin: 5px 0px; +}