diff --git a/book-example/src/format/summary.md b/book-example/src/format/summary.md index 61a2c6ec1e..7b2d5d8d88 100644 --- a/book-example/src/format/summary.md +++ b/book-example/src/format/summary.md @@ -22,15 +22,27 @@ allow for easy parsing. Let's see how you should format your `SUMMARY.md` file. [Title of prefix element](relative/path/to/markdown.md) ``` -3. ***Numbered Chapter*** Numbered chapters are the main content of the book, +3. ***Part Title:*** Headers can be used as a title for the following numbered + chapters. This can be used to logically separate different sections + of book. The title is rendered as unclickable text. + Titles are optional, and the numbered chapters can be broken into as many + parts as desired. + +4. ***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) + + # Title of Another Part + + - [More Chapters](relative/path/to/markdown2.md) ``` You can either use `-` or `*` to indicate a numbered chapter. -4. ***Suffix Chapter*** After the numbered chapters you can add a couple of +5. ***Suffix Chapter*** After the numbered chapters you can add a couple of non-numbered chapters. They are the same as prefix chapters but come after the numbered chapters instead of before. @@ -50,5 +62,5 @@ error. of contents, as you can see for the next chapter in the table of contents on the left. Draft chapters are written like normal chapters but without writing the path to the file ```markdown - - [Draft chapter]() - ``` \ No newline at end of file + - [Draft chapter]() + ``` diff --git a/src/book/book.rs b/src/book/book.rs index 1fb9e94bfb..8af70e9a9c 100644 --- a/src/book/book.rs +++ b/src/book/book.rs @@ -133,6 +133,8 @@ pub enum BookItem { Chapter(Chapter), /// A section separator. Separator, + /// A part title. + PartTitle(String), } impl From for BookItem { @@ -229,11 +231,12 @@ fn load_summary_item + Clone>( src_dir: P, parent_names: Vec, ) -> Result { - match *item { + match item { SummaryItem::Separator => Ok(BookItem::Separator), SummaryItem::Link(ref link) => { load_chapter(link, src_dir, parent_names).map(BookItem::Chapter) } + SummaryItem::PartTitle(title) => Ok(BookItem::PartTitle(title.clone())), } } @@ -569,6 +572,7 @@ And here is some \ location: Some(PathBuf::from("")), ..Default::default() })], + ..Default::default() }; diff --git a/src/book/mod.rs b/src/book/mod.rs index 5711eb5e97..67c3491e11 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 8fc9e8fca2..12fb2cd646 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,7 +60,7 @@ 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. + /// The main numbered chapters of the book, broken into one or more possibly named parts. pub numbered_chapters: Vec, /// Items which come after the main document (e.g. a conclusion). pub suffix_chapters: Vec, @@ -108,6 +113,8 @@ pub enum SummaryItem { Link(Link), /// A separator (`---`). Separator, + /// A part title. + PartTitle(String), } impl SummaryItem { @@ -134,12 +141,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 +163,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 +215,7 @@ impl<'a> SummaryParser<'a> { src: text, stream: pulldown_parser, offset: 0, + back: None, } } @@ -225,7 +238,7 @@ impl<'a> SummaryParser<'a> { .parse_affix(true) .chain_err(|| "There was an error parsing the prefix chapters")?; let numbered_chapters = self - .parse_numbered() + .parse_parts() .chain_err(|| "There was an error parsing the numbered chapters")?; let suffix_chapters = self .parse_affix(false) @@ -239,8 +252,7 @@ impl<'a> SummaryParser<'a> { }) } - /// 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 +262,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 +286,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")?; + + if let Some(title) = title { + parts.push(SummaryItem::PartTitle(title)); + } + parts.extend(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 +350,46 @@ 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)) => { - // we're starting the suffix chapters + 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 +399,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 +520,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 +677,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 +687,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 +698,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 +713,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 +736,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 +768,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 +795,47 @@ mod tests { ]; let mut parser = SummaryParser::new(src); - let _ = parser.stream.next(); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } - let got = parser.parse_numbered().unwrap(); + #[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![ + 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(), + }), + SummaryItem::PartTitle(String::from("Title 2")), + 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 +863,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 +874,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 +917,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 80226374c8..35f72d26f2 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 67fe4101a5..33857d8621 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 e2df5d6511..11e1b92262 100644 --- a/src/theme/css/general.css +++ b/src/theme/css/general.css @@ -166,3 +166,9 @@ blockquote { .tooltipped .tooltiptext { visibility: visible; } + +.chapter li.part-title { + color: var(--sidebar-fg); + margin: 5px 0px; + font-weight: bold; +}