2025-07-21 11:37:46 -07:00
|
|
|
use anyhow::{Context, Result};
|
2022-06-24 16:50:02 +05:30
|
|
|
use log::debug;
|
2025-07-21 14:47:11 -07:00
|
|
|
use mdbook_core::book::{Book, BookItem, Chapter};
|
2025-07-21 13:26:57 -07:00
|
|
|
use mdbook_core::config::BuildConfig;
|
2025-07-21 12:20:21 -07:00
|
|
|
use mdbook_core::utils::bracket_escape;
|
2025-07-21 14:47:11 -07:00
|
|
|
use mdbook_summary::{Link, Summary, SummaryItem, parse_summary};
|
|
|
|
|
use std::fs::{self, File};
|
|
|
|
|
use std::io::{Read, Write};
|
|
|
|
|
use std::path::Path;
|
2022-05-05 09:33:51 +03:00
|
|
|
|
2017-11-18 19:50:47 +08:00
|
|
|
/// Load a book into memory from its `src/` directory.
|
2025-07-25 09:02:55 -07:00
|
|
|
pub(crate) fn load_book<P: AsRef<Path>>(src_dir: P, cfg: &BuildConfig) -> Result<Book> {
|
2017-11-18 19:50:47 +08:00
|
|
|
let src_dir = src_dir.as_ref();
|
|
|
|
|
let summary_md = src_dir.join("SUMMARY.md");
|
|
|
|
|
|
|
|
|
|
let mut summary_content = String::new();
|
2020-12-27 15:45:11 -05:00
|
|
|
File::open(&summary_md)
|
2024-09-21 15:53:59 -07:00
|
|
|
.with_context(|| format!("Couldn't open SUMMARY.md in {src_dir:?} directory"))?
|
2017-12-11 10:32:35 +11:00
|
|
|
.read_to_string(&mut summary_content)?;
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-12-27 15:45:11 -05:00
|
|
|
let summary = parse_summary(&summary_content)
|
2024-09-21 15:53:59 -07:00
|
|
|
.with_context(|| format!("Summary parsing failed for file={summary_md:?}"))?;
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2017-12-11 10:32:35 +11:00
|
|
|
if cfg.create_missing {
|
2021-08-24 08:45:06 +01:00
|
|
|
create_missing(src_dir, &summary).with_context(|| "Unable to create missing chapters")?;
|
2017-12-11 10:32:35 +11:00
|
|
|
}
|
|
|
|
|
|
2017-11-18 20:01:50 +08:00
|
|
|
load_book_from_disk(&summary, src_dir)
|
2017-11-18 19:50:47 +08:00
|
|
|
}
|
|
|
|
|
|
2017-12-11 10:32:35 +11:00
|
|
|
fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> {
|
|
|
|
|
let mut items: Vec<_> = summary
|
|
|
|
|
.prefix_chapters
|
|
|
|
|
.iter()
|
2020-05-18 11:18:14 -05:00
|
|
|
.chain(summary.numbered_chapters.iter())
|
2017-12-11 10:32:35 +11:00
|
|
|
.chain(summary.suffix_chapters.iter())
|
|
|
|
|
.collect();
|
|
|
|
|
|
2023-05-13 09:50:32 -07:00
|
|
|
while let Some(next) = items.pop() {
|
2017-12-11 10:32:35 +11:00
|
|
|
if let SummaryItem::Link(ref link) = *next {
|
2020-02-29 17:55:45 +01:00
|
|
|
if let Some(ref location) = link.location {
|
|
|
|
|
let filename = src_dir.join(location);
|
|
|
|
|
if !filename.exists() {
|
|
|
|
|
if let Some(parent) = filename.parent() {
|
|
|
|
|
if !parent.exists() {
|
|
|
|
|
fs::create_dir_all(parent)?;
|
|
|
|
|
}
|
2017-12-11 17:29:32 +11:00
|
|
|
}
|
2020-02-29 17:55:45 +01:00
|
|
|
debug!("Creating missing file {}", filename.display());
|
2017-12-11 10:32:35 +11:00
|
|
|
|
2020-12-05 20:27:03 -05:00
|
|
|
let mut f = File::create(&filename).with_context(|| {
|
|
|
|
|
format!("Unable to create missing file: {}", filename.display())
|
|
|
|
|
})?;
|
2022-04-14 20:35:39 -07:00
|
|
|
writeln!(f, "# {}", bracket_escape(&link.name))?;
|
2020-02-29 17:55:45 +01:00
|
|
|
}
|
2017-12-11 10:32:35 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items.extend(&link.nested_items);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2017-11-18 19:50:47 +08:00
|
|
|
/// Use the provided `Summary` to load a `Book` from disk.
|
|
|
|
|
///
|
|
|
|
|
/// You need to pass in the book's source directory because all the links in
|
|
|
|
|
/// `SUMMARY.md` give the chapter locations relative to it.
|
2019-03-04 11:44:00 -08:00
|
|
|
pub(crate) fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P) -> Result<Book> {
|
2018-01-23 01:28:37 +08:00
|
|
|
debug!("Loading the book from disk");
|
2017-11-18 19:50:47 +08:00
|
|
|
let src_dir = src_dir.as_ref();
|
|
|
|
|
|
2020-05-18 11:18:14 -05:00
|
|
|
let prefix = summary.prefix_chapters.iter();
|
|
|
|
|
let numbered = summary.numbered_chapters.iter();
|
|
|
|
|
let suffix = summary.suffix_chapters.iter();
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-05-18 11:18:14 -05:00
|
|
|
let summary_items = prefix.chain(numbered).chain(suffix);
|
2020-03-20 21:18:07 -05:00
|
|
|
|
2020-05-18 11:18:14 -05:00
|
|
|
let mut chapters = Vec::new();
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-05-18 11:18:14 -05:00
|
|
|
for summary_item in summary_items {
|
|
|
|
|
let chapter = load_summary_item(summary_item, src_dir, Vec::new())?;
|
|
|
|
|
chapters.push(chapter);
|
2017-11-18 19:50:47 +08:00
|
|
|
}
|
|
|
|
|
|
2025-07-21 14:47:11 -07:00
|
|
|
Ok(Book::new_with_items(chapters))
|
2017-11-18 19:50:47 +08:00
|
|
|
}
|
|
|
|
|
|
2020-02-29 17:55:45 +01:00
|
|
|
fn load_summary_item<P: AsRef<Path> + Clone>(
|
2018-03-14 23:47:17 +08:00
|
|
|
item: &SummaryItem,
|
|
|
|
|
src_dir: P,
|
|
|
|
|
parent_names: Vec<String>,
|
|
|
|
|
) -> Result<BookItem> {
|
2020-05-18 11:18:14 -05:00
|
|
|
match item {
|
2017-11-18 19:50:47 +08:00
|
|
|
SummaryItem::Separator => Ok(BookItem::Separator),
|
2025-07-21 10:30:43 -07:00
|
|
|
SummaryItem::Link(link) => load_chapter(link, src_dir, parent_names).map(BookItem::Chapter),
|
2020-05-18 11:18:14 -05:00
|
|
|
SummaryItem::PartTitle(title) => Ok(BookItem::PartTitle(title.clone())),
|
2025-08-09 16:38:22 -07:00
|
|
|
_ => panic!("SummaryItem {item:?} not covered"),
|
2017-11-18 19:50:47 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-03-09 22:34:28 +01:00
|
|
|
fn load_chapter<P: AsRef<Path>>(
|
2018-03-14 23:47:17 +08:00
|
|
|
link: &Link,
|
|
|
|
|
src_dir: P,
|
|
|
|
|
parent_names: Vec<String>,
|
|
|
|
|
) -> Result<Chapter> {
|
2020-03-09 22:34:28 +01:00
|
|
|
let src_dir = src_dir.as_ref();
|
|
|
|
|
|
2020-02-29 17:55:45 +01:00
|
|
|
let mut ch = if let Some(ref link_location) = link.location {
|
|
|
|
|
debug!("Loading {} ({})", link.name, link_location.display());
|
|
|
|
|
|
|
|
|
|
let location = if link_location.is_absolute() {
|
|
|
|
|
link_location.clone()
|
|
|
|
|
} else {
|
|
|
|
|
src_dir.join(link_location)
|
|
|
|
|
};
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-02-29 17:55:45 +01:00
|
|
|
let mut f = File::open(&location)
|
2020-05-20 14:32:00 -07:00
|
|
|
.with_context(|| format!("Chapter file not found, {}", link_location.display()))?;
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-02-29 17:55:45 +01:00
|
|
|
let mut content = String::new();
|
2020-05-20 14:32:00 -07:00
|
|
|
f.read_to_string(&mut content).with_context(|| {
|
|
|
|
|
format!("Unable to read \"{}\" ({})", link.name, location.display())
|
|
|
|
|
})?;
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-07-25 13:02:44 +08:00
|
|
|
if content.as_bytes().starts_with(b"\xef\xbb\xbf") {
|
2020-09-29 18:01:06 +08:00
|
|
|
content.replace_range(..3, "");
|
2020-07-25 13:02:44 +08:00
|
|
|
}
|
|
|
|
|
|
2020-02-29 17:55:45 +01:00
|
|
|
let stripped = location
|
2023-05-13 09:44:11 -07:00
|
|
|
.strip_prefix(src_dir)
|
2020-02-29 17:55:45 +01:00
|
|
|
.expect("Chapters are always inside a book");
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-02-29 17:55:45 +01:00
|
|
|
Chapter::new(&link.name, content, stripped, parent_names.clone())
|
|
|
|
|
} else {
|
|
|
|
|
Chapter::new_draft(&link.name, parent_names.clone())
|
|
|
|
|
};
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-12-31 15:18:37 -05:00
|
|
|
let mut sub_item_parents = parent_names;
|
2020-02-29 17:55:45 +01:00
|
|
|
|
2017-11-18 19:50:47 +08:00
|
|
|
ch.number = link.number.clone();
|
|
|
|
|
|
2018-03-07 07:02:06 -06:00
|
|
|
sub_item_parents.push(link.name.clone());
|
2018-08-02 20:22:49 -05:00
|
|
|
let sub_items = link
|
|
|
|
|
.nested_items
|
2017-12-11 10:32:35 +11:00
|
|
|
.iter()
|
2020-03-09 22:34:28 +01:00
|
|
|
.map(|i| load_summary_item(i, src_dir, sub_item_parents.clone()))
|
2017-12-11 10:32:35 +11:00
|
|
|
.collect::<Result<Vec<_>>>()?;
|
2017-11-18 19:50:47 +08:00
|
|
|
|
|
|
|
|
ch.sub_items = sub_items;
|
|
|
|
|
|
|
|
|
|
Ok(ch)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2025-07-21 14:47:11 -07:00
|
|
|
use mdbook_core::book::SectionNumber;
|
|
|
|
|
use std::path::PathBuf;
|
2018-07-23 12:45:01 -05:00
|
|
|
use tempfile::{Builder as TempFileBuilder, TempDir};
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2019-05-07 01:20:58 +07:00
|
|
|
const DUMMY_SRC: &str = "
|
2017-11-18 19:50:47 +08:00
|
|
|
# Dummy Chapter
|
|
|
|
|
|
|
|
|
|
this is some dummy text.
|
|
|
|
|
|
2017-11-18 22:07:08 +08:00
|
|
|
And here is some \
|
|
|
|
|
more text.
|
2017-11-18 19:50:47 +08:00
|
|
|
";
|
|
|
|
|
|
|
|
|
|
/// Create a dummy `Link` in a temporary directory.
|
|
|
|
|
fn dummy_link() -> (Link, TempDir) {
|
2018-03-27 01:47:37 +02:00
|
|
|
let temp = TempFileBuilder::new().prefix("book").tempdir().unwrap();
|
2017-11-18 19:50:47 +08:00
|
|
|
|
|
|
|
|
let chapter_path = temp.path().join("chapter_1.md");
|
2017-12-11 10:32:35 +11:00
|
|
|
File::create(&chapter_path)
|
|
|
|
|
.unwrap()
|
2019-05-07 01:20:58 +07:00
|
|
|
.write_all(DUMMY_SRC.as_bytes())
|
2017-12-11 10:32:35 +11:00
|
|
|
.unwrap();
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-03-24 23:52:24 +01:00
|
|
|
let link = Link::new("Chapter 1", chapter_path);
|
2017-11-18 19:50:47 +08:00
|
|
|
|
|
|
|
|
(link, temp)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create a nested `Link` written to a temporary directory.
|
|
|
|
|
fn nested_links() -> (Link, TempDir) {
|
|
|
|
|
let (mut root, temp_dir) = dummy_link();
|
|
|
|
|
|
|
|
|
|
let second_path = temp_dir.path().join("second.md");
|
|
|
|
|
|
2017-12-11 10:32:35 +11:00
|
|
|
File::create(&second_path)
|
|
|
|
|
.unwrap()
|
2019-05-07 01:20:58 +07:00
|
|
|
.write_all(b"Hello World!")
|
2017-12-11 10:32:35 +11:00
|
|
|
.unwrap();
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2020-03-24 23:52:24 +01:00
|
|
|
let mut second = Link::new("Nested Chapter 1", &second_path);
|
2025-08-09 16:32:13 -07:00
|
|
|
second.number = Some(SectionNumber::new([1, 2]));
|
2017-11-18 19:50:47 +08:00
|
|
|
|
|
|
|
|
root.nested_items.push(second.clone().into());
|
|
|
|
|
root.nested_items.push(SummaryItem::Separator);
|
2021-08-24 08:45:06 +01:00
|
|
|
root.nested_items.push(second.into());
|
2017-11-18 19:50:47 +08:00
|
|
|
|
|
|
|
|
(root, temp_dir)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_a_single_chapter_from_disk() {
|
|
|
|
|
let (link, temp_dir) = dummy_link();
|
2018-03-14 23:47:17 +08:00
|
|
|
let should_be = Chapter::new(
|
|
|
|
|
"Chapter 1",
|
|
|
|
|
DUMMY_SRC.to_string(),
|
|
|
|
|
"chapter_1.md",
|
|
|
|
|
Vec::new(),
|
|
|
|
|
);
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2018-03-07 07:02:06 -06:00
|
|
|
let got = load_chapter(&link, temp_dir.path(), Vec::new()).unwrap();
|
2017-11-18 19:50:47 +08:00
|
|
|
assert_eq!(got, should_be);
|
|
|
|
|
}
|
|
|
|
|
|
2020-10-07 22:50:25 +08:00
|
|
|
#[test]
|
|
|
|
|
fn load_a_single_chapter_with_utf8_bom_from_disk() {
|
|
|
|
|
let temp_dir = TempFileBuilder::new().prefix("book").tempdir().unwrap();
|
|
|
|
|
|
|
|
|
|
let chapter_path = temp_dir.path().join("chapter_1.md");
|
|
|
|
|
File::create(&chapter_path)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.write_all(("\u{feff}".to_owned() + DUMMY_SRC).as_bytes())
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let link = Link::new("Chapter 1", chapter_path);
|
|
|
|
|
|
|
|
|
|
let should_be = Chapter::new(
|
|
|
|
|
"Chapter 1",
|
|
|
|
|
DUMMY_SRC.to_string(),
|
|
|
|
|
"chapter_1.md",
|
|
|
|
|
Vec::new(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let got = load_chapter(&link, temp_dir.path(), Vec::new()).unwrap();
|
|
|
|
|
assert_eq!(got, should_be);
|
|
|
|
|
}
|
|
|
|
|
|
2017-11-18 19:50:47 +08:00
|
|
|
#[test]
|
|
|
|
|
fn cant_load_a_nonexistent_chapter() {
|
2020-03-24 23:52:24 +01:00
|
|
|
let link = Link::new("Chapter 1", "/foo/bar/baz.md");
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2018-03-07 07:02:06 -06:00
|
|
|
let got = load_chapter(&link, "", Vec::new());
|
2017-11-18 19:50:47 +08:00
|
|
|
assert!(got.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_recursive_link_with_separators() {
|
|
|
|
|
let (root, temp) = nested_links();
|
|
|
|
|
|
2025-08-09 16:38:22 -07:00
|
|
|
let mut nested = Chapter::new(
|
|
|
|
|
"Nested Chapter 1",
|
|
|
|
|
String::from("Hello World!"),
|
|
|
|
|
"second.md",
|
|
|
|
|
vec![String::from("Chapter 1")],
|
|
|
|
|
);
|
|
|
|
|
nested.number = Some(SectionNumber::new([1, 2]));
|
|
|
|
|
let mut chapter =
|
|
|
|
|
Chapter::new("Chapter 1", String::from(DUMMY_SRC), "chapter_1.md", vec![]);
|
|
|
|
|
chapter.sub_items = vec![
|
|
|
|
|
BookItem::Chapter(nested.clone()),
|
|
|
|
|
BookItem::Separator,
|
|
|
|
|
BookItem::Chapter(nested),
|
|
|
|
|
];
|
|
|
|
|
let should_be = BookItem::Chapter(chapter);
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2018-03-07 07:02:06 -06:00
|
|
|
let got = load_summary_item(&SummaryItem::Link(root), temp.path(), Vec::new()).unwrap();
|
2017-11-18 19:50:47 +08:00
|
|
|
assert_eq!(got, should_be);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_a_book_with_a_single_chapter() {
|
|
|
|
|
let (link, temp) = dummy_link();
|
2025-08-09 16:38:22 -07:00
|
|
|
let mut summary = Summary::default();
|
|
|
|
|
summary.numbered_chapters = vec![SummaryItem::Link(link)];
|
|
|
|
|
let chapter = Chapter::new(
|
|
|
|
|
"Chapter 1",
|
|
|
|
|
String::from(DUMMY_SRC),
|
|
|
|
|
PathBuf::from("chapter_1.md"),
|
|
|
|
|
vec![],
|
|
|
|
|
);
|
2025-08-22 17:13:27 -07:00
|
|
|
let items = vec![BookItem::Chapter(chapter)];
|
|
|
|
|
let should_be = Book::new_with_items(items);
|
2017-11-18 19:50:47 +08:00
|
|
|
|
2017-11-18 20:01:50 +08:00
|
|
|
let got = load_book_from_disk(&summary, temp.path()).unwrap();
|
2017-11-18 19:50:47 +08:00
|
|
|
|
|
|
|
|
assert_eq!(got, should_be);
|
|
|
|
|
}
|
|
|
|
|
|
2018-01-22 20:47:29 +08:00
|
|
|
#[test]
|
|
|
|
|
fn cant_load_chapters_with_an_empty_path() {
|
|
|
|
|
let (_, temp) = dummy_link();
|
2025-08-09 16:38:22 -07:00
|
|
|
let mut summary = Summary::default();
|
|
|
|
|
let link = Link::new("Empty", "");
|
|
|
|
|
summary.numbered_chapters = vec![SummaryItem::Link(link)];
|
2018-01-22 20:47:29 +08:00
|
|
|
let got = load_book_from_disk(&summary, temp.path());
|
|
|
|
|
assert!(got.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cant_load_chapters_when_the_link_is_a_directory() {
|
|
|
|
|
let (_, temp) = dummy_link();
|
|
|
|
|
let dir = temp.path().join("nested");
|
|
|
|
|
fs::create_dir(&dir).unwrap();
|
|
|
|
|
|
2025-08-09 16:38:22 -07:00
|
|
|
let mut summary = Summary::default();
|
|
|
|
|
let link = Link::new("nested", dir);
|
|
|
|
|
summary.numbered_chapters = vec![SummaryItem::Link(link)];
|
2018-01-22 20:47:29 +08:00
|
|
|
|
|
|
|
|
let got = load_book_from_disk(&summary, temp.path());
|
|
|
|
|
assert!(got.is_err());
|
|
|
|
|
}
|
2025-03-09 14:40:00 +02:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cant_open_summary_md() {
|
|
|
|
|
let cfg = BuildConfig::default();
|
|
|
|
|
let temp_dir = TempFileBuilder::new().prefix("book").tempdir().unwrap();
|
|
|
|
|
|
|
|
|
|
let got = load_book(&temp_dir, &cfg);
|
|
|
|
|
assert!(got.is_err());
|
|
|
|
|
let error_message = got.err().unwrap().to_string();
|
2025-04-10 08:28:13 +03:00
|
|
|
let expected = format!(
|
2025-03-09 19:13:53 +02:00
|
|
|
r#"Couldn't open SUMMARY.md in {:?} directory"#,
|
|
|
|
|
temp_dir.path()
|
2025-03-09 14:40:00 +02:00
|
|
|
);
|
2025-04-10 08:28:13 +03:00
|
|
|
assert_eq!(error_message, expected);
|
2025-03-09 14:40:00 +02:00
|
|
|
}
|
2017-11-18 19:50:47 +08:00
|
|
|
}
|