Merge pull request #1171 from mark-i-m/master

implement support for book parts
This commit is contained in:
Eric Huss 2020-05-20 12:17:45 -07:00 committed by GitHub
commit 5d5c55e619
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 221 additions and 80 deletions

View File

@ -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.

View File

@ -133,6 +133,8 @@ pub enum BookItem {
Chapter(Chapter),
/// A section separator.
Separator,
/// A part title.
PartTitle(String),
}
impl From<Chapter> for BookItem {
@ -229,11 +231,12 @@ fn load_summary_item<P: AsRef<Path> + Clone>(
src_dir: P,
parent_names: Vec<String>,
) -> Result<BookItem> {
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()
};

View File

@ -132,6 +132,7 @@ impl MDBook {
/// match *item {
/// BookItem::Chapter(ref chapter) => {},
/// BookItem::Separator => {},
/// BookItem::PartTitle(ref title) => {}
/// }
/// }
///

View File

@ -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<String>,
/// Chapters before the main text (e.g. an introduction).
pub prefix_chapters: Vec<SummaryItem>,
/// 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<SummaryItem>,
/// Items which come after the main document (e.g. a conclusion).
pub suffix_chapters: Vec<SummaryItem>,
@ -108,6 +113,8 @@ pub enum SummaryItem {
Link(Link),
/// A separator (`---`).
Separator,
/// A part title.
PartTitle(String),
}
impl SummaryItem {
@ -139,7 +146,8 @@ impl From<Link> for SummaryItem {
/// | 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<Event<'a>>,
}
/// 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<Vec<SummaryItem>> {
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<Vec<SummaryItem>> {
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,35 +350,45 @@ 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<Vec<SummaryItem>> {
/// Parse the numbered chapters.
fn parse_numbered(
&mut self,
root_items: &mut u32,
root_number: &mut SectionNumber,
) -> Result<Vec<SummaryItem>> {
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 {
match self.next_event() {
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;
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
break;
}
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;
}
None => {
Some(_) => {}
// EOF, bail...
None => {
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<Event<'a>> {
let next = self.stream.next().map(|(ev, range)| {
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<String> {
if let Some(Event::Start(Tag::Heading(1))) = self.next_event() {
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
}
_ => 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();
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![
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);
}

View File

@ -532,6 +532,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()));

View File

@ -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("<li class=\"part-title active\">")?;
out.write(title)?;
out.write("</li>")?;
continue;
}
// Link
let path_exists = if let Some(path) = item.get("path") {
if !path.is_empty() {

View File

@ -166,3 +166,9 @@ blockquote {
.tooltipped .tooltiptext {
visibility: visible;
}
.chapter li.part-title {
color: var(--sidebar-fg);
margin: 5px 0px;
font-weight: bold;
}