diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 12:02:58 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-17 12:02:58 +0000 |
commit | 698f8c2f01ea549d77d7dc3338a12e04c11057b9 (patch) | |
tree | 173a775858bd501c378080a10dca74132f05bc50 /vendor/mdbook/src | |
parent | Initial commit. (diff) | |
download | rustc-698f8c2f01ea549d77d7dc3338a12e04c11057b9.tar.xz rustc-698f8c2f01ea549d77d7dc3338a12e04c11057b9.zip |
Adding upstream version 1.64.0+dfsg1.upstream/1.64.0+dfsg1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'vendor/mdbook/src')
47 files changed, 12590 insertions, 0 deletions
diff --git a/vendor/mdbook/src/book/book.rs b/vendor/mdbook/src/book/book.rs new file mode 100644 index 000000000..d28c22dad --- /dev/null +++ b/vendor/mdbook/src/book/book.rs @@ -0,0 +1,640 @@ +use std::collections::VecDeque; +use std::fmt::{self, Display, Formatter}; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use super::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; +use crate::config::BuildConfig; +use crate::errors::*; +use crate::utils::bracket_escape; + +use serde::{Deserialize, Serialize}; + +/// Load a book into memory from its `src/` directory. +pub fn load_book<P: AsRef<Path>>(src_dir: P, cfg: &BuildConfig) -> Result<Book> { + let src_dir = src_dir.as_ref(); + let summary_md = src_dir.join("SUMMARY.md"); + + let mut summary_content = String::new(); + File::open(&summary_md) + .with_context(|| format!("Couldn't open SUMMARY.md in {:?} directory", src_dir))? + .read_to_string(&mut summary_content)?; + + let summary = parse_summary(&summary_content) + .with_context(|| format!("Summary parsing failed for file={:?}", summary_md))?; + + if cfg.create_missing { + create_missing(src_dir, &summary).with_context(|| "Unable to create missing chapters")?; + } + + load_book_from_disk(&summary, src_dir) +} + +fn create_missing(src_dir: &Path, summary: &Summary) -> Result<()> { + let mut items: Vec<_> = summary + .prefix_chapters + .iter() + .chain(summary.numbered_chapters.iter()) + .chain(summary.suffix_chapters.iter()) + .collect(); + + while !items.is_empty() { + let next = items.pop().expect("already checked"); + + if let SummaryItem::Link(ref link) = *next { + 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)?; + } + } + debug!("Creating missing file {}", filename.display()); + + let mut f = File::create(&filename).with_context(|| { + format!("Unable to create missing file: {}", filename.display()) + })?; + writeln!(f, "# {}", bracket_escape(&link.name))?; + } + } + + items.extend(&link.nested_items); + } + } + + Ok(()) +} + +/// A dumb tree structure representing a book. +/// +/// For the moment a book is just a collection of [`BookItems`] which are +/// accessible by either iterating (immutably) over the book with [`iter()`], or +/// recursively applying a closure to each section to mutate the chapters, using +/// [`for_each_mut()`]. +/// +/// [`iter()`]: #method.iter +/// [`for_each_mut()`]: #method.for_each_mut +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Book { + /// The sections in this book. + pub sections: Vec<BookItem>, + __non_exhaustive: (), +} + +impl Book { + /// Create an empty book. + pub fn new() -> Self { + Default::default() + } + + /// Get a depth-first iterator over the items in the book. + pub fn iter(&self) -> BookItems<'_> { + BookItems { + items: self.sections.iter().collect(), + } + } + + /// Recursively apply a closure to each item in the book, allowing you to + /// mutate them. + /// + /// # Note + /// + /// Unlike the `iter()` method, this requires a closure instead of returning + /// an iterator. This is because using iterators can possibly allow you + /// to have iterator invalidation errors. + pub fn for_each_mut<F>(&mut self, mut func: F) + where + F: FnMut(&mut BookItem), + { + for_each_mut(&mut func, &mut self.sections); + } + + /// Append a `BookItem` to the `Book`. + pub fn push_item<I: Into<BookItem>>(&mut self, item: I) -> &mut Self { + self.sections.push(item.into()); + self + } +} + +pub fn for_each_mut<'a, F, I>(func: &mut F, items: I) +where + F: FnMut(&mut BookItem), + I: IntoIterator<Item = &'a mut BookItem>, +{ + for item in items { + if let BookItem::Chapter(ch) = item { + for_each_mut(func, &mut ch.sub_items); + } + + func(item); + } +} + +/// Enum representing any type of item which can be added to a book. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum BookItem { + /// A nested chapter. + Chapter(Chapter), + /// A section separator. + Separator, + /// A part title. + PartTitle(String), +} + +impl From<Chapter> for BookItem { + fn from(other: Chapter) -> BookItem { + BookItem::Chapter(other) + } +} + +/// The representation of a "chapter", usually mapping to a single file on +/// disk however it may contain multiple sub-chapters. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Chapter { + /// The chapter's name. + pub name: String, + /// The chapter's contents. + pub content: String, + /// The chapter's section number, if it has one. + pub number: Option<SectionNumber>, + /// Nested items. + pub sub_items: Vec<BookItem>, + /// The chapter's location, relative to the `SUMMARY.md` file. + pub path: Option<PathBuf>, + /// The chapter's source file, relative to the `SUMMARY.md` file. + pub source_path: Option<PathBuf>, + /// An ordered list of the names of each chapter above this one in the hierarchy. + pub parent_names: Vec<String>, +} + +impl Chapter { + /// Create a new chapter with the provided content. + pub fn new<P: Into<PathBuf>>( + name: &str, + content: String, + p: P, + parent_names: Vec<String>, + ) -> Chapter { + let path: PathBuf = p.into(); + Chapter { + name: name.to_string(), + content, + path: Some(path.clone()), + source_path: Some(path), + parent_names, + ..Default::default() + } + } + + /// Create a new draft chapter that is not attached to a source markdown file (and thus + /// has no content). + pub fn new_draft(name: &str, parent_names: Vec<String>) -> Self { + Chapter { + name: name.to_string(), + content: String::new(), + path: None, + source_path: None, + parent_names, + ..Default::default() + } + } + + /// Check if the chapter is a draft chapter, meaning it has no path to a source markdown file. + pub fn is_draft_chapter(&self) -> bool { + self.path.is_none() + } +} + +/// 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. +pub(crate) fn load_book_from_disk<P: AsRef<Path>>(summary: &Summary, src_dir: P) -> Result<Book> { + 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); + } + + Ok(Book { + sections: chapters, + __non_exhaustive: (), + }) +} + +fn load_summary_item<P: AsRef<Path> + Clone>( + item: &SummaryItem, + src_dir: P, + parent_names: Vec<String>, +) -> Result<BookItem> { + 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())), + } +} + +fn load_chapter<P: AsRef<Path>>( + link: &Link, + src_dir: P, + parent_names: Vec<String>, +) -> Result<Chapter> { + let src_dir = src_dir.as_ref(); + + 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) + }; + + let mut f = File::open(&location) + .with_context(|| format!("Chapter file not found, {}", link_location.display()))?; + + let mut content = String::new(); + f.read_to_string(&mut content).with_context(|| { + format!("Unable to read \"{}\" ({})", link.name, location.display()) + })?; + + if content.as_bytes().starts_with(b"\xef\xbb\xbf") { + content.replace_range(..3, ""); + } + + let stripped = location + .strip_prefix(&src_dir) + .expect("Chapters are always inside a book"); + + Chapter::new(&link.name, content, stripped, parent_names.clone()) + } else { + Chapter::new_draft(&link.name, parent_names.clone()) + }; + + let mut sub_item_parents = parent_names; + + ch.number = link.number.clone(); + + sub_item_parents.push(link.name.clone()); + let sub_items = link + .nested_items + .iter() + .map(|i| load_summary_item(i, src_dir, sub_item_parents.clone())) + .collect::<Result<Vec<_>>>()?; + + ch.sub_items = sub_items; + + Ok(ch) +} + +/// A depth-first iterator over the items in a book. +/// +/// # Note +/// +/// This struct shouldn't be created directly, instead prefer the +/// [`Book::iter()`] method. +pub struct BookItems<'a> { + items: VecDeque<&'a BookItem>, +} + +impl<'a> Iterator for BookItems<'a> { + type Item = &'a BookItem; + + fn next(&mut self) -> Option<Self::Item> { + let item = self.items.pop_front(); + + if let Some(&BookItem::Chapter(ref ch)) = item { + // if we wanted a breadth-first iterator we'd `extend()` here + for sub_item in ch.sub_items.iter().rev() { + self.items.push_front(sub_item); + } + } + + item + } +} + +impl Display for Chapter { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if let Some(ref section_number) = self.number { + write!(f, "{} ", section_number)?; + } + + write!(f, "{}", self.name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::{Builder as TempFileBuilder, TempDir}; + + const DUMMY_SRC: &str = " +# Dummy Chapter + +this is some dummy text. + +And here is some \ + more text. +"; + + /// Create a dummy `Link` in a temporary directory. + fn dummy_link() -> (Link, TempDir) { + let temp = TempFileBuilder::new().prefix("book").tempdir().unwrap(); + + let chapter_path = temp.path().join("chapter_1.md"); + File::create(&chapter_path) + .unwrap() + .write_all(DUMMY_SRC.as_bytes()) + .unwrap(); + + let link = Link::new("Chapter 1", chapter_path); + + (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"); + + File::create(&second_path) + .unwrap() + .write_all(b"Hello World!") + .unwrap(); + + let mut second = Link::new("Nested Chapter 1", &second_path); + second.number = Some(SectionNumber(vec![1, 2])); + + root.nested_items.push(second.clone().into()); + root.nested_items.push(SummaryItem::Separator); + root.nested_items.push(second.into()); + + (root, temp_dir) + } + + #[test] + fn load_a_single_chapter_from_disk() { + let (link, temp_dir) = dummy_link(); + 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); + } + + #[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); + } + + #[test] + fn cant_load_a_nonexistent_chapter() { + let link = Link::new("Chapter 1", "/foo/bar/baz.md"); + + let got = load_chapter(&link, "", Vec::new()); + assert!(got.is_err()); + } + + #[test] + fn load_recursive_link_with_separators() { + let (root, temp) = nested_links(); + + let nested = Chapter { + name: String::from("Nested Chapter 1"), + content: String::from("Hello World!"), + number: Some(SectionNumber(vec![1, 2])), + path: Some(PathBuf::from("second.md")), + source_path: Some(PathBuf::from("second.md")), + parent_names: vec![String::from("Chapter 1")], + sub_items: Vec::new(), + }; + let should_be = BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + number: None, + path: Some(PathBuf::from("chapter_1.md")), + source_path: Some(PathBuf::from("chapter_1.md")), + parent_names: Vec::new(), + sub_items: vec![ + BookItem::Chapter(nested.clone()), + BookItem::Separator, + BookItem::Chapter(nested), + ], + }); + + let got = load_summary_item(&SummaryItem::Link(root), temp.path(), Vec::new()).unwrap(); + assert_eq!(got, should_be); + } + + #[test] + fn load_a_book_with_a_single_chapter() { + let (link, temp) = dummy_link(); + let summary = Summary { + numbered_chapters: vec![SummaryItem::Link(link)], + ..Default::default() + }; + let should_be = Book { + sections: vec![BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + path: Some(PathBuf::from("chapter_1.md")), + source_path: Some(PathBuf::from("chapter_1.md")), + ..Default::default() + })], + ..Default::default() + }; + + let got = load_book_from_disk(&summary, temp.path()).unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn book_iter_iterates_over_sequential_items() { + let book = Book { + sections: vec![ + BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + ..Default::default() + }), + BookItem::Separator, + ], + ..Default::default() + }; + + let should_be: Vec<_> = book.sections.iter().collect(); + + let got: Vec<_> = book.iter().collect(); + + assert_eq!(got, should_be); + } + + #[test] + fn iterate_over_nested_book_items() { + let book = Book { + sections: vec![ + BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + number: None, + path: Some(PathBuf::from("Chapter_1/index.md")), + source_path: Some(PathBuf::from("Chapter_1/index.md")), + parent_names: Vec::new(), + sub_items: vec![ + BookItem::Chapter(Chapter::new( + "Hello World", + String::new(), + "Chapter_1/hello.md", + Vec::new(), + )), + BookItem::Separator, + BookItem::Chapter(Chapter::new( + "Goodbye World", + String::new(), + "Chapter_1/goodbye.md", + Vec::new(), + )), + ], + }), + BookItem::Separator, + ], + ..Default::default() + }; + + let got: Vec<_> = book.iter().collect(); + + assert_eq!(got.len(), 5); + + // checking the chapter names are in the order should be sufficient here... + let chapter_names: Vec<String> = got + .into_iter() + .filter_map(|i| match *i { + BookItem::Chapter(ref ch) => Some(ch.name.clone()), + _ => None, + }) + .collect(); + let should_be: Vec<_> = vec![ + String::from("Chapter 1"), + String::from("Hello World"), + String::from("Goodbye World"), + ]; + + assert_eq!(chapter_names, should_be); + } + + #[test] + fn for_each_mut_visits_all_items() { + let mut book = Book { + sections: vec![ + BookItem::Chapter(Chapter { + name: String::from("Chapter 1"), + content: String::from(DUMMY_SRC), + number: None, + path: Some(PathBuf::from("Chapter_1/index.md")), + source_path: Some(PathBuf::from("Chapter_1/index.md")), + parent_names: Vec::new(), + sub_items: vec![ + BookItem::Chapter(Chapter::new( + "Hello World", + String::new(), + "Chapter_1/hello.md", + Vec::new(), + )), + BookItem::Separator, + BookItem::Chapter(Chapter::new( + "Goodbye World", + String::new(), + "Chapter_1/goodbye.md", + Vec::new(), + )), + ], + }), + BookItem::Separator, + ], + ..Default::default() + }; + + let num_items = book.iter().count(); + let mut visited = 0; + + book.for_each_mut(|_| visited += 1); + + assert_eq!(visited, num_items); + } + + #[test] + 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() + })], + + ..Default::default() + }; + + 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(); + + let summary = Summary { + numbered_chapters: vec![SummaryItem::Link(Link { + name: String::from("nested"), + location: Some(dir), + ..Default::default() + })], + ..Default::default() + }; + + let got = load_book_from_disk(&summary, temp.path()); + assert!(got.is_err()); + } +} diff --git a/vendor/mdbook/src/book/init.rs b/vendor/mdbook/src/book/init.rs new file mode 100644 index 000000000..264c113d3 --- /dev/null +++ b/vendor/mdbook/src/book/init.rs @@ -0,0 +1,207 @@ +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; + +use super::MDBook; +use crate::config::Config; +use crate::errors::*; +use crate::theme; + +/// A helper for setting up a new book and its directory structure. +#[derive(Debug, Clone, PartialEq)] +pub struct BookBuilder { + root: PathBuf, + create_gitignore: bool, + config: Config, + copy_theme: bool, +} + +impl BookBuilder { + /// Create a new `BookBuilder` which will generate a book in the provided + /// root directory. + pub fn new<P: Into<PathBuf>>(root: P) -> BookBuilder { + BookBuilder { + root: root.into(), + create_gitignore: false, + config: Config::default(), + copy_theme: false, + } + } + + /// Set the [`Config`] to be used. + pub fn with_config(&mut self, cfg: Config) -> &mut BookBuilder { + self.config = cfg; + self + } + + /// Get the config used by the `BookBuilder`. + pub fn config(&self) -> &Config { + &self.config + } + + /// Should the theme be copied into the generated book (so users can tweak + /// it)? + pub fn copy_theme(&mut self, copy: bool) -> &mut BookBuilder { + self.copy_theme = copy; + self + } + + /// Should we create a `.gitignore` file? + pub fn create_gitignore(&mut self, create: bool) -> &mut BookBuilder { + self.create_gitignore = create; + self + } + + /// Generate the actual book. This will: + /// + /// - Create the directory structure. + /// - Stub out some dummy chapters and the `SUMMARY.md`. + /// - Create a `.gitignore` (if applicable) + /// - Create a themes directory and populate it (if applicable) + /// - Generate a `book.toml` file, + /// - Then load the book so we can build it or run tests. + pub fn build(&self) -> Result<MDBook> { + info!("Creating a new book with stub content"); + + self.create_directory_structure() + .with_context(|| "Unable to create directory structure")?; + + self.create_stub_files() + .with_context(|| "Unable to create stub files")?; + + if self.create_gitignore { + self.build_gitignore() + .with_context(|| "Unable to create .gitignore")?; + } + + if self.copy_theme { + self.copy_across_theme() + .with_context(|| "Unable to copy across the theme")?; + } + + self.write_book_toml()?; + + match MDBook::load(&self.root) { + Ok(book) => Ok(book), + Err(e) => { + error!("{}", e); + + panic!( + "The BookBuilder should always create a valid book. If you are seeing this it \ + is a bug and should be reported." + ); + } + } + } + + fn write_book_toml(&self) -> Result<()> { + debug!("Writing book.toml"); + let book_toml = self.root.join("book.toml"); + let cfg = toml::to_vec(&self.config).with_context(|| "Unable to serialize the config")?; + + File::create(book_toml) + .with_context(|| "Couldn't create book.toml")? + .write_all(&cfg) + .with_context(|| "Unable to write config to book.toml")?; + Ok(()) + } + + fn copy_across_theme(&self) -> Result<()> { + debug!("Copying theme"); + + let html_config = self.config.html_config().unwrap_or_default(); + let themedir = html_config.theme_dir(&self.root); + + if !themedir.exists() { + debug!( + "{} does not exist, creating the directory", + themedir.display() + ); + fs::create_dir(&themedir)?; + } + + let mut index = File::create(themedir.join("index.hbs"))?; + index.write_all(theme::INDEX)?; + + let cssdir = themedir.join("css"); + if !cssdir.exists() { + fs::create_dir(&cssdir)?; + } + + let mut general_css = File::create(cssdir.join("general.css"))?; + general_css.write_all(theme::GENERAL_CSS)?; + + let mut chrome_css = File::create(cssdir.join("chrome.css"))?; + chrome_css.write_all(theme::CHROME_CSS)?; + + if html_config.print.enable { + let mut print_css = File::create(cssdir.join("print.css"))?; + print_css.write_all(theme::PRINT_CSS)?; + } + + let mut variables_css = File::create(cssdir.join("variables.css"))?; + variables_css.write_all(theme::VARIABLES_CSS)?; + + let mut favicon = File::create(themedir.join("favicon.png"))?; + favicon.write_all(theme::FAVICON_PNG)?; + + let mut favicon = File::create(themedir.join("favicon.svg"))?; + favicon.write_all(theme::FAVICON_SVG)?; + + let mut js = File::create(themedir.join("book.js"))?; + js.write_all(theme::JS)?; + + let mut highlight_css = File::create(themedir.join("highlight.css"))?; + highlight_css.write_all(theme::HIGHLIGHT_CSS)?; + + let mut highlight_js = File::create(themedir.join("highlight.js"))?; + highlight_js.write_all(theme::HIGHLIGHT_JS)?; + + Ok(()) + } + + fn build_gitignore(&self) -> Result<()> { + debug!("Creating .gitignore"); + + let mut f = File::create(self.root.join(".gitignore"))?; + + writeln!(f, "{}", self.config.build.build_dir.display())?; + + Ok(()) + } + + fn create_stub_files(&self) -> Result<()> { + debug!("Creating example book contents"); + let src_dir = self.root.join(&self.config.book.src); + + let summary = src_dir.join("SUMMARY.md"); + if !summary.exists() { + trace!("No summary found creating stub summary and chapter_1.md."); + let mut f = File::create(&summary).with_context(|| "Unable to create SUMMARY.md")?; + writeln!(f, "# Summary")?; + writeln!(f)?; + writeln!(f, "- [Chapter 1](./chapter_1.md)")?; + + let chapter_1 = src_dir.join("chapter_1.md"); + let mut f = + File::create(&chapter_1).with_context(|| "Unable to create chapter_1.md")?; + writeln!(f, "# Chapter 1")?; + } else { + trace!("Existing summary found, no need to create stub files."); + } + Ok(()) + } + + fn create_directory_structure(&self) -> Result<()> { + debug!("Creating directory tree"); + fs::create_dir_all(&self.root)?; + + let src = self.root.join(&self.config.book.src); + fs::create_dir_all(&src)?; + + let build = self.root.join(&self.config.build.build_dir); + fs::create_dir_all(&build)?; + + Ok(()) + } +} diff --git a/vendor/mdbook/src/book/mod.rs b/vendor/mdbook/src/book/mod.rs new file mode 100644 index 000000000..9745d2b7e --- /dev/null +++ b/vendor/mdbook/src/book/mod.rs @@ -0,0 +1,836 @@ +//! The internal representation of a book and infrastructure for loading it from +//! disk and building it. +//! +//! For examples on using `MDBook`, consult the [top-level documentation][1]. +//! +//! [1]: ../index.html + +#[allow(clippy::module_inception)] +mod book; +mod init; +mod summary; + +pub use self::book::{load_book, Book, BookItem, BookItems, Chapter}; +pub use self::init::BookBuilder; +pub use self::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; + +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; +use std::string::ToString; +use tempfile::Builder as TempFileBuilder; +use toml::Value; +use topological_sort::TopologicalSort; + +use crate::errors::*; +use crate::preprocess::{ + CmdPreprocessor, IndexPreprocessor, LinkPreprocessor, Preprocessor, PreprocessorContext, +}; +use crate::renderer::{CmdRenderer, HtmlHandlebars, MarkdownRenderer, RenderContext, Renderer}; +use crate::utils; + +use crate::config::{Config, RustEdition}; + +/// The object used to manage and build a book. +pub struct MDBook { + /// The book's root directory. + pub root: PathBuf, + /// The configuration used to tweak now a book is built. + pub config: Config, + /// A representation of the book's contents in memory. + pub book: Book, + renderers: Vec<Box<dyn Renderer>>, + + /// List of pre-processors to be run on the book. + preprocessors: Vec<Box<dyn Preprocessor>>, +} + +impl MDBook { + /// Load a book from its root directory on disk. + pub fn load<P: Into<PathBuf>>(book_root: P) -> Result<MDBook> { + let book_root = book_root.into(); + let config_location = book_root.join("book.toml"); + + // the book.json file is no longer used, so we should emit a warning to + // let people know to migrate to book.toml + if book_root.join("book.json").exists() { + warn!("It appears you are still using book.json for configuration."); + warn!("This format is no longer used, so you should migrate to the"); + warn!("book.toml format."); + warn!("Check the user guide for migration information:"); + warn!("\thttps://rust-lang.github.io/mdBook/format/config.html"); + } + + let mut config = if config_location.exists() { + debug!("Loading config from {}", config_location.display()); + Config::from_disk(&config_location)? + } else { + Config::default() + }; + + config.update_from_env(); + + if config + .html_config() + .map_or(false, |html| html.google_analytics.is_some()) + { + warn!( + "The output.html.google-analytics field has been deprecated; \ + it will be removed in a future release.\n\ + Consider placing the appropriate site tag code into the \ + theme/head.hbs file instead.\n\ + The tracking code may be found in the Google Analytics Admin page.\n\ + " + ); + } + + if log_enabled!(log::Level::Trace) { + for line in format!("Config: {:#?}", config).lines() { + trace!("{}", line); + } + } + + MDBook::load_with_config(book_root, config) + } + + /// Load a book from its root directory using a custom `Config`. + pub fn load_with_config<P: Into<PathBuf>>(book_root: P, config: Config) -> Result<MDBook> { + let root = book_root.into(); + + let src_dir = root.join(&config.book.src); + let book = book::load_book(&src_dir, &config.build)?; + + let renderers = determine_renderers(&config); + let preprocessors = determine_preprocessors(&config)?; + + Ok(MDBook { + root, + config, + book, + renderers, + preprocessors, + }) + } + + /// Load a book from its root directory using a custom `Config` and a custom summary. + pub fn load_with_config_and_summary<P: Into<PathBuf>>( + book_root: P, + config: Config, + summary: Summary, + ) -> Result<MDBook> { + let root = book_root.into(); + + let src_dir = root.join(&config.book.src); + let book = book::load_book_from_disk(&summary, &src_dir)?; + + let renderers = determine_renderers(&config); + let preprocessors = determine_preprocessors(&config)?; + + Ok(MDBook { + root, + config, + book, + renderers, + preprocessors, + }) + } + + /// Returns a flat depth-first iterator over the elements of the book, + /// it returns a [`BookItem`] enum: + /// `(section: String, bookitem: &BookItem)` + /// + /// ```no_run + /// # use mdbook::MDBook; + /// # use mdbook::book::BookItem; + /// # let book = MDBook::load("mybook").unwrap(); + /// for item in book.iter() { + /// match *item { + /// BookItem::Chapter(ref chapter) => {}, + /// BookItem::Separator => {}, + /// BookItem::PartTitle(ref title) => {} + /// } + /// } + /// + /// // would print something like this: + /// // 1. Chapter 1 + /// // 1.1 Sub Chapter + /// // 1.2 Sub Chapter + /// // 2. Chapter 2 + /// // + /// // etc. + /// ``` + pub fn iter(&self) -> BookItems<'_> { + self.book.iter() + } + + /// `init()` gives you a `BookBuilder` which you can use to setup a new book + /// and its accompanying directory structure. + /// + /// The `BookBuilder` creates some boilerplate files and directories to get + /// you started with your book. + /// + /// ```text + /// book-test/ + /// ├── book + /// └── src + /// ├── chapter_1.md + /// └── SUMMARY.md + /// ``` + /// + /// It uses the path provided as the root directory for your book, then adds + /// in a `src/` directory containing a `SUMMARY.md` and `chapter_1.md` file + /// to get you started. + pub fn init<P: Into<PathBuf>>(book_root: P) -> BookBuilder { + BookBuilder::new(book_root) + } + + /// Tells the renderer to build our book and put it in the build directory. + pub fn build(&self) -> Result<()> { + info!("Book building has started"); + + for renderer in &self.renderers { + self.execute_build_process(&**renderer)?; + } + + Ok(()) + } + + /// Run the entire build process for a particular [`Renderer`]. + pub fn execute_build_process(&self, renderer: &dyn Renderer) -> Result<()> { + let mut preprocessed_book = self.book.clone(); + let preprocess_ctx = PreprocessorContext::new( + self.root.clone(), + self.config.clone(), + renderer.name().to_string(), + ); + + for preprocessor in &self.preprocessors { + if preprocessor_should_run(&**preprocessor, renderer, &self.config) { + debug!("Running the {} preprocessor.", preprocessor.name()); + preprocessed_book = preprocessor.run(&preprocess_ctx, preprocessed_book)?; + } + } + + let name = renderer.name(); + let build_dir = self.build_dir_for(name); + + let mut render_context = RenderContext::new( + self.root.clone(), + preprocessed_book, + self.config.clone(), + build_dir, + ); + render_context + .chapter_titles + .extend(preprocess_ctx.chapter_titles.borrow_mut().drain()); + + info!("Running the {} backend", renderer.name()); + renderer + .render(&render_context) + .with_context(|| "Rendering failed") + } + + /// You can change the default renderer to another one by using this method. + /// The only requirement is that your renderer implement the [`Renderer`] + /// trait. + pub fn with_renderer<R: Renderer + 'static>(&mut self, renderer: R) -> &mut Self { + self.renderers.push(Box::new(renderer)); + self + } + + /// Register a [`Preprocessor`] to be used when rendering the book. + pub fn with_preprocessor<P: Preprocessor + 'static>(&mut self, preprocessor: P) -> &mut Self { + self.preprocessors.push(Box::new(preprocessor)); + self + } + + /// Run `rustdoc` tests on the book, linking against the provided libraries. + pub fn test(&mut self, library_paths: Vec<&str>) -> Result<()> { + let library_args: Vec<&str> = (0..library_paths.len()) + .map(|_| "-L") + .zip(library_paths.into_iter()) + .flat_map(|x| vec![x.0, x.1]) + .collect(); + + let temp_dir = TempFileBuilder::new().prefix("mdbook-").tempdir()?; + + // FIXME: Is "test" the proper renderer name to use here? + let preprocess_context = + PreprocessorContext::new(self.root.clone(), self.config.clone(), "test".to_string()); + + let book = LinkPreprocessor::new().run(&preprocess_context, self.book.clone())?; + // Index Preprocessor is disabled so that chapter paths continue to point to the + // actual markdown files. + + let mut failed = false; + for item in book.iter() { + if let BookItem::Chapter(ref ch) = *item { + let chapter_path = match ch.path { + Some(ref path) if !path.as_os_str().is_empty() => path, + _ => continue, + }; + + let path = self.source_dir().join(&chapter_path); + info!("Testing file: {:?}", path); + + // write preprocessed file to tempdir + let path = temp_dir.path().join(&chapter_path); + let mut tmpf = utils::fs::create_file(&path)?; + tmpf.write_all(ch.content.as_bytes())?; + + let mut cmd = Command::new("rustdoc"); + cmd.arg(&path).arg("--test").args(&library_args); + + if let Some(edition) = self.config.rust.edition { + match edition { + RustEdition::E2015 => { + cmd.args(&["--edition", "2015"]); + } + RustEdition::E2018 => { + cmd.args(&["--edition", "2018"]); + } + RustEdition::E2021 => { + cmd.args(&["--edition", "2021"]); + } + } + } + + let output = cmd.output()?; + + if !output.status.success() { + failed = true; + error!( + "rustdoc returned an error:\n\ + \n--- stdout\n{}\n--- stderr\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + } + if failed { + bail!("One or more tests failed"); + } + Ok(()) + } + + /// The logic for determining where a backend should put its build + /// artefacts. + /// + /// If there is only 1 renderer, put it in the directory pointed to by the + /// `build.build_dir` key in [`Config`]. If there is more than one then the + /// renderer gets its own directory within the main build dir. + /// + /// i.e. If there were only one renderer (in this case, the HTML renderer): + /// + /// - build/ + /// - index.html + /// - ... + /// + /// Otherwise if there are multiple: + /// + /// - build/ + /// - epub/ + /// - my_awesome_book.epub + /// - html/ + /// - index.html + /// - ... + /// - latex/ + /// - my_awesome_book.tex + /// + pub fn build_dir_for(&self, backend_name: &str) -> PathBuf { + let build_dir = self.root.join(&self.config.build.build_dir); + + if self.renderers.len() <= 1 { + build_dir + } else { + build_dir.join(backend_name) + } + } + + /// Get the directory containing this book's source files. + pub fn source_dir(&self) -> PathBuf { + self.root.join(&self.config.book.src) + } + + /// Get the directory containing the theme resources for the book. + pub fn theme_dir(&self) -> PathBuf { + self.config + .html_config() + .unwrap_or_default() + .theme_dir(&self.root) + } +} + +/// Look at the `Config` and try to figure out what renderers to use. +fn determine_renderers(config: &Config) -> Vec<Box<dyn Renderer>> { + let mut renderers = Vec::new(); + + if let Some(output_table) = config.get("output").and_then(Value::as_table) { + renderers.extend(output_table.iter().map(|(key, table)| { + if key == "html" { + Box::new(HtmlHandlebars::new()) as Box<dyn Renderer> + } else if key == "markdown" { + Box::new(MarkdownRenderer::new()) as Box<dyn Renderer> + } else { + interpret_custom_renderer(key, table) + } + })); + } + + // if we couldn't find anything, add the HTML renderer as a default + if renderers.is_empty() { + renderers.push(Box::new(HtmlHandlebars::new())); + } + + renderers +} + +const DEFAULT_PREPROCESSORS: &[&str] = &["links", "index"]; + +fn is_default_preprocessor(pre: &dyn Preprocessor) -> bool { + let name = pre.name(); + name == LinkPreprocessor::NAME || name == IndexPreprocessor::NAME +} + +/// Look at the `MDBook` and try to figure out what preprocessors to run. +fn determine_preprocessors(config: &Config) -> Result<Vec<Box<dyn Preprocessor>>> { + // Collect the names of all preprocessors intended to be run, and the order + // in which they should be run. + let mut preprocessor_names = TopologicalSort::<String>::new(); + + if config.build.use_default_preprocessors { + for name in DEFAULT_PREPROCESSORS { + preprocessor_names.insert(name.to_string()); + } + } + + if let Some(preprocessor_table) = config.get("preprocessor").and_then(Value::as_table) { + for (name, table) in preprocessor_table.iter() { + preprocessor_names.insert(name.to_string()); + + let exists = |name| { + (config.build.use_default_preprocessors && DEFAULT_PREPROCESSORS.contains(&name)) + || preprocessor_table.contains_key(name) + }; + + if let Some(before) = table.get("before") { + let before = before.as_array().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.before to be an array", + name + )) + })?; + for after in before { + let after = after.as_str().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.before to contain strings", + name + )) + })?; + + if !exists(after) { + // Only warn so that preprocessors can be toggled on and off (e.g. for + // troubleshooting) without having to worry about order too much. + warn!( + "preprocessor.{}.after contains \"{}\", which was not found", + name, after + ); + } else { + preprocessor_names.add_dependency(name, after); + } + } + } + + if let Some(after) = table.get("after") { + let after = after.as_array().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.after to be an array", + name + )) + })?; + for before in after { + let before = before.as_str().ok_or_else(|| { + Error::msg(format!( + "Expected preprocessor.{}.after to contain strings", + name + )) + })?; + + if !exists(before) { + // See equivalent warning above for rationale + warn!( + "preprocessor.{}.before contains \"{}\", which was not found", + name, before + ); + } else { + preprocessor_names.add_dependency(before, name); + } + } + } + } + } + + // Now that all links have been established, queue preprocessors in a suitable order + let mut preprocessors = Vec::with_capacity(preprocessor_names.len()); + // `pop_all()` returns an empty vector when no more items are not being depended upon + for mut names in std::iter::repeat_with(|| preprocessor_names.pop_all()) + .take_while(|names| !names.is_empty()) + { + // The `topological_sort` crate does not guarantee a stable order for ties, even across + // runs of the same program. Thus, we break ties manually by sorting. + // Careful: `str`'s default sorting, which we are implicitly invoking here, uses code point + // values ([1]), which may not be an alphabetical sort. + // As mentioned in [1], doing so depends on locale, which is not desirable for deciding + // preprocessor execution order. + // [1]: https://doc.rust-lang.org/stable/std/cmp/trait.Ord.html#impl-Ord-14 + names.sort(); + for name in names { + let preprocessor: Box<dyn Preprocessor> = match name.as_str() { + "links" => Box::new(LinkPreprocessor::new()), + "index" => Box::new(IndexPreprocessor::new()), + _ => { + // The only way to request a custom preprocessor is through the `preprocessor` + // table, so it must exist, be a table, and contain the key. + let table = &config.get("preprocessor").unwrap().as_table().unwrap()[&name]; + let command = get_custom_preprocessor_cmd(&name, table); + Box::new(CmdPreprocessor::new(name, command)) + } + }; + preprocessors.push(preprocessor); + } + } + + // "If `pop_all` returns an empty vector and `len` is not 0, there are cyclic dependencies." + // Normally, `len() == 0` is equivalent to `is_empty()`, so we'll use that. + if preprocessor_names.is_empty() { + Ok(preprocessors) + } else { + Err(Error::msg("Cyclic dependency detected in preprocessors")) + } +} + +fn get_custom_preprocessor_cmd(key: &str, table: &Value) -> String { + table + .get("command") + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| format!("mdbook-{}", key)) +} + +fn interpret_custom_renderer(key: &str, table: &Value) -> Box<CmdRenderer> { + // look for the `command` field, falling back to using the key + // prepended by "mdbook-" + let table_dot_command = table + .get("command") + .and_then(Value::as_str) + .map(ToString::to_string); + + let command = table_dot_command.unwrap_or_else(|| format!("mdbook-{}", key)); + + Box::new(CmdRenderer::new(key.to_string(), command)) +} + +/// Check whether we should run a particular `Preprocessor` in combination +/// with the renderer, falling back to `Preprocessor::supports_renderer()` +/// method if the user doesn't say anything. +/// +/// The `build.use-default-preprocessors` config option can be used to ensure +/// default preprocessors always run if they support the renderer. +fn preprocessor_should_run( + preprocessor: &dyn Preprocessor, + renderer: &dyn Renderer, + cfg: &Config, +) -> bool { + // default preprocessors should be run by default (if supported) + if cfg.build.use_default_preprocessors && is_default_preprocessor(preprocessor) { + return preprocessor.supports_renderer(renderer.name()); + } + + let key = format!("preprocessor.{}.renderers", preprocessor.name()); + let renderer_name = renderer.name(); + + if let Some(Value::Array(ref explicit_renderers)) = cfg.get(&key) { + return explicit_renderers + .iter() + .filter_map(Value::as_str) + .any(|name| name == renderer_name); + } + + preprocessor.supports_renderer(renderer_name) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + use toml::value::{Table, Value}; + + #[test] + fn config_defaults_to_html_renderer_if_empty() { + let cfg = Config::default(); + + // make sure we haven't got anything in the `output` table + assert!(cfg.get("output").is_none()); + + let got = determine_renderers(&cfg); + + assert_eq!(got.len(), 1); + assert_eq!(got[0].name(), "html"); + } + + #[test] + fn add_a_random_renderer_to_the_config() { + let mut cfg = Config::default(); + cfg.set("output.random", Table::new()).unwrap(); + + let got = determine_renderers(&cfg); + + assert_eq!(got.len(), 1); + assert_eq!(got[0].name(), "random"); + } + + #[test] + fn add_a_random_renderer_with_custom_command_to_the_config() { + let mut cfg = Config::default(); + + let mut table = Table::new(); + table.insert("command".to_string(), Value::String("false".to_string())); + cfg.set("output.random", table).unwrap(); + + let got = determine_renderers(&cfg); + + assert_eq!(got.len(), 1); + assert_eq!(got[0].name(), "random"); + } + + #[test] + fn config_defaults_to_link_and_index_preprocessor_if_not_set() { + let cfg = Config::default(); + + // make sure we haven't got anything in the `preprocessor` table + assert!(cfg.get("preprocessor").is_none()); + + let got = determine_preprocessors(&cfg); + + assert!(got.is_ok()); + assert_eq!(got.as_ref().unwrap().len(), 2); + assert_eq!(got.as_ref().unwrap()[0].name(), "index"); + assert_eq!(got.as_ref().unwrap()[1].name(), "links"); + } + + #[test] + fn use_default_preprocessors_works() { + let mut cfg = Config::default(); + cfg.build.use_default_preprocessors = false; + + let got = determine_preprocessors(&cfg).unwrap(); + + assert_eq!(got.len(), 0); + } + + #[test] + fn can_determine_third_party_preprocessors() { + let cfg_str = r#" + [book] + title = "Some Book" + + [preprocessor.random] + + [build] + build-dir = "outputs" + create-missing = false + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + // make sure the `preprocessor.random` table exists + assert!(cfg.get_preprocessor("random").is_some()); + + let got = determine_preprocessors(&cfg).unwrap(); + + assert!(got.into_iter().any(|p| p.name() == "random")); + } + + #[test] + fn preprocessors_can_provide_their_own_commands() { + let cfg_str = r#" + [preprocessor.random] + command = "python random.py" + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + // make sure the `preprocessor.random` table exists + let random = cfg.get_preprocessor("random").unwrap(); + let random = get_custom_preprocessor_cmd("random", &Value::Table(random.clone())); + + assert_eq!(random, "python random.py"); + } + + #[test] + fn preprocessor_before_must_be_array() { + let cfg_str = r#" + [preprocessor.random] + before = 0 + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + assert!(determine_preprocessors(&cfg).is_err()); + } + + #[test] + fn preprocessor_after_must_be_array() { + let cfg_str = r#" + [preprocessor.random] + after = 0 + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + assert!(determine_preprocessors(&cfg).is_err()); + } + + #[test] + fn preprocessor_order_is_honored() { + let cfg_str = r#" + [preprocessor.random] + before = [ "last" ] + after = [ "index" ] + + [preprocessor.last] + after = [ "links", "index" ] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + let preprocessors = determine_preprocessors(&cfg).unwrap(); + let index = |name| { + preprocessors + .iter() + .enumerate() + .find(|(_, preprocessor)| preprocessor.name() == name) + .unwrap() + .0 + }; + let assert_before = |before, after| { + if index(before) >= index(after) { + eprintln!("Preprocessor order:"); + for preprocessor in &preprocessors { + eprintln!(" {}", preprocessor.name()); + } + panic!("{} should come before {}", before, after); + } + }; + + assert_before("index", "random"); + assert_before("index", "last"); + assert_before("random", "last"); + assert_before("links", "last"); + } + + #[test] + fn cyclic_dependencies_are_detected() { + let cfg_str = r#" + [preprocessor.links] + before = [ "index" ] + + [preprocessor.index] + before = [ "links" ] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + assert!(determine_preprocessors(&cfg).is_err()); + } + + #[test] + fn dependencies_dont_register_undefined_preprocessors() { + let cfg_str = r#" + [preprocessor.links] + before = [ "random" ] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + let preprocessors = determine_preprocessors(&cfg).unwrap(); + + assert!(!preprocessors + .iter() + .any(|preprocessor| preprocessor.name() == "random")); + } + + #[test] + fn dependencies_dont_register_builtin_preprocessors_if_disabled() { + let cfg_str = r#" + [preprocessor.random] + before = [ "links" ] + + [build] + use-default-preprocessors = false + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + let preprocessors = determine_preprocessors(&cfg).unwrap(); + + assert!(!preprocessors + .iter() + .any(|preprocessor| preprocessor.name() == "links")); + } + + #[test] + fn config_respects_preprocessor_selection() { + let cfg_str = r#" + [preprocessor.links] + renderers = ["html"] + "#; + + let cfg = Config::from_str(cfg_str).unwrap(); + + // double-check that we can access preprocessor.links.renderers[0] + let html = cfg + .get_preprocessor("links") + .and_then(|links| links.get("renderers")) + .and_then(Value::as_array) + .and_then(|renderers| renderers.get(0)) + .and_then(Value::as_str) + .unwrap(); + assert_eq!(html, "html"); + let html_renderer = HtmlHandlebars::default(); + let pre = LinkPreprocessor::new(); + + let should_run = preprocessor_should_run(&pre, &html_renderer, &cfg); + assert!(should_run); + } + + struct BoolPreprocessor(bool); + impl Preprocessor for BoolPreprocessor { + fn name(&self) -> &str { + "bool-preprocessor" + } + + fn run(&self, _ctx: &PreprocessorContext, _book: Book) -> Result<Book> { + unimplemented!() + } + + fn supports_renderer(&self, _renderer: &str) -> bool { + self.0 + } + } + + #[test] + fn preprocessor_should_run_falls_back_to_supports_renderer_method() { + let cfg = Config::default(); + let html = HtmlHandlebars::new(); + + let should_be = true; + let got = preprocessor_should_run(&BoolPreprocessor(should_be), &html, &cfg); + assert_eq!(got, should_be); + + let should_be = false; + let got = preprocessor_should_run(&BoolPreprocessor(should_be), &html, &cfg); + assert_eq!(got, should_be); + } +} diff --git a/vendor/mdbook/src/book/summary.rs b/vendor/mdbook/src/book/summary.rs new file mode 100644 index 000000000..2bd81580f --- /dev/null +++ b/vendor/mdbook/src/book/summary.rs @@ -0,0 +1,1097 @@ +use crate::errors::*; +use memchr::{self, Memchr}; +use pulldown_cmark::{self, Event, HeadingLevel, Tag}; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display, Formatter}; +use std::iter::FromIterator; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; + +/// Parse the text from a `SUMMARY.md` file into a sort of "recipe" to be +/// used when loading a book from disk. +/// +/// # Summary Format +/// +/// **Title:** It's common practice to begin with a title, generally +/// "# Summary". It's not mandatory and the parser (currently) ignores it, so +/// you can too if you feel like it. +/// +/// **Prefix Chapter:** Before the main numbered chapters you can add a couple +/// of elements that will not be numbered. This is useful for forewords, +/// introductions, etc. There are however some constraints. You can not nest +/// prefix chapters, they should all be on the root level. And you can not add +/// prefix chapters once you have added numbered chapters. +/// +/// ```markdown +/// [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) +/// ``` +/// +/// You can either use - or * to indicate a numbered chapter, the parser doesn't +/// care but you'll probably want to stay consistent. +/// +/// **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. +/// +/// All other elements are unsupported and will be ignored at best or result in +/// an error. +pub fn parse_summary(summary: &str) -> Result<Summary> { + let parser = SummaryParser::new(summary); + parser.parse() +} + +/// The parsed `SUMMARY.md`, specifying how the book should be laid out. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Summary { + /// An optional title for the `SUMMARY.md`, currently just ignored. + pub title: Option<String>, + /// Chapters before the main text (e.g. an introduction). + pub prefix_chapters: Vec<SummaryItem>, + /// 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>, +} + +/// A struct representing an entry in the `SUMMARY.md`, possibly with nested +/// entries. +/// +/// This is roughly the equivalent of `[Some section](./path/to/file.md)`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Link { + /// The name of the chapter. + pub name: String, + /// The location of the chapter's source file, taking the book's `src` + /// directory as the root. + pub location: Option<PathBuf>, + /// The section number, if this chapter is in the numbered section. + pub number: Option<SectionNumber>, + /// Any nested items this chapter may contain. + pub nested_items: Vec<SummaryItem>, +} + +impl Link { + /// Create a new link with no nested items. + pub fn new<S: Into<String>, P: AsRef<Path>>(name: S, location: P) -> Link { + Link { + name: name.into(), + location: Some(location.as_ref().to_path_buf()), + number: None, + nested_items: Vec::new(), + } + } +} + +impl Default for Link { + fn default() -> Self { + Link { + name: String::new(), + location: Some(PathBuf::new()), + number: None, + nested_items: Vec::new(), + } + } +} + +/// An item in `SUMMARY.md` which could be either a separator or a `Link`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SummaryItem { + /// A link to a chapter. + Link(Link), + /// A separator (`---`). + Separator, + /// A part title. + PartTitle(String), +} + +impl SummaryItem { + fn maybe_link_mut(&mut self) -> Option<&mut Link> { + match *self { + SummaryItem::Link(ref mut l) => Some(l), + _ => None, + } + } +} + +impl From<Link> for SummaryItem { + fn from(other: Link) -> SummaryItem { + SummaryItem::Link(other) + } +} + +/// A recursive descent (-ish) parser for a `SUMMARY.md`. +/// +/// +/// # Grammar +/// +/// The `SUMMARY.md` file has a grammar which looks something like this: +/// +/// ```text +/// summary ::= title prefix_chapters numbered_chapters +/// suffix_chapters +/// title ::= "# " TEXT +/// | EPSILON +/// prefix_chapters ::= item* +/// suffix_chapters ::= item* +/// numbered_chapters ::= part+ +/// part ::= title dotted_item+ +/// dotted_item ::= INDENT* DOT_POINT item +/// item ::= link +/// | separator +/// separator ::= "---" +/// link ::= "[" TEXT "]" "(" TEXT ")" +/// DOT_POINT ::= "-" +/// | "*" +/// ``` +/// +/// > **Note:** the `TEXT` terminal is "normal" text, and should (roughly) +/// > match the following regex: "[^<>\n[]]+". +struct SummaryParser<'a> { + src: &'a str, + stream: pulldown_cmark::OffsetIter<'a, '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 +/// `Event::End` is encountered which matches the `$delimiter` pattern. +/// +/// This is the equivalent of doing +/// `$stream.take_while(|e| e != $delimiter).collect()` but it allows you to +/// use pattern matching and you won't get errors because `take_while()` +/// moves `$stream` out of self. +macro_rules! collect_events { + ($stream:expr,start $delimiter:pat) => { + collect_events!($stream, Event::Start($delimiter)) + }; + ($stream:expr,end $delimiter:pat) => { + collect_events!($stream, Event::End($delimiter)) + }; + ($stream:expr, $delimiter:pat) => {{ + let mut events = Vec::new(); + + loop { + let event = $stream.next().map(|(ev, _range)| ev); + trace!("Next event: {:?}", event); + + match event { + Some($delimiter) => break, + Some(other) => events.push(other), + None => { + debug!( + "Reached end of stream without finding the closing pattern, {}", + stringify!($delimiter) + ); + break; + } + } + } + + events + }}; +} + +impl<'a> SummaryParser<'a> { + fn new(text: &str) -> SummaryParser<'_> { + let pulldown_parser = pulldown_cmark::Parser::new(text).into_offset_iter(); + + SummaryParser { + src: text, + stream: pulldown_parser, + offset: 0, + back: None, + } + } + + /// Get the current line and column to give the user more useful error + /// messages. + fn current_location(&self) -> (usize, usize) { + let previous_text = self.src[..self.offset].as_bytes(); + let line = Memchr::new(b'\n', previous_text).count() + 1; + let start_of_line = memchr::memrchr(b'\n', previous_text).unwrap_or(0); + let col = self.src[start_of_line..self.offset].chars().count(); + + (line, col) + } + + /// Parse the text the `SummaryParser` was created with. + fn parse(mut self) -> Result<Summary> { + let title = self.parse_title(); + + let prefix_chapters = self + .parse_affix(true) + .with_context(|| "There was an error parsing the prefix chapters")?; + let numbered_chapters = self + .parse_parts() + .with_context(|| "There was an error parsing the numbered chapters")?; + let suffix_chapters = self + .parse_affix(false) + .with_context(|| "There was an error parsing the suffix chapters")?; + + Ok(Summary { + title, + prefix_chapters, + numbered_chapters, + suffix_chapters, + }) + } + + /// Parse the affix chapters. + fn parse_affix(&mut self, is_prefix: bool) -> Result<Vec<SummaryItem>> { + let mut items = Vec::new(); + debug!( + "Parsing {} items", + if is_prefix { "prefix" } else { "suffix" } + ); + + loop { + match self.next_event() { + Some(ev @ Event::Start(Tag::List(..))) + | Some(ev @ Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { + 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")); + } + } + Some(Event::Start(Tag::Link(_type, href, _title))) => { + let link = self.parse_link(href.to_string()); + items.push(SummaryItem::Link(link)); + } + Some(Event::Rule) => items.push(SummaryItem::Separator), + Some(_) => {} + None => break, + } + } + + 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(HeadingLevel::H1, ..))) => { + debug!("Found a h1 in the SUMMARY"); + + let tags = collect_events!(self.stream, end Tag::Heading(HeadingLevel::H1, ..)); + 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) + .with_context(|| "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 href = href.replace("%20", " "); + let link_content = collect_events!(self.stream, end Tag::Link(..)); + let name = stringify_events(link_content); + + let path = if href.is_empty() { + None + } else { + Some(PathBuf::from(href)) + }; + + Link { + name, + location: path, + number: None, + nested_items: Vec::new(), + } + } + + /// 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(); + + // 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(HeadingLevel::H1, ..))) => { + // 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); + + // Skip over the contents of this tag + while let Some(event) = self.next_event() { + if event == Event::End(other_tag.clone()) { + break; + } + } + } + Some(Event::Rule) => { + items.push(SummaryItem::Separator); + } + + // something else... ignore + 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.back.take().or_else(|| { + self.stream.next().map(|(ev, range)| { + self.offset = range.start; + ev + }) + }); + + trace!("Next event: {:?}", next); + + next + } + + fn parse_nested_numbered(&mut self, parent: &SectionNumber) -> Result<Vec<SummaryItem>> { + debug!("Parsing numbered chapters at level {}", parent); + let mut items = Vec::new(); + + loop { + match self.next_event() { + Some(Event::Start(Tag::Item)) => { + let item = self.parse_nested_item(parent, items.len())?; + items.push(item); + } + Some(Event::Start(Tag::List(..))) => { + // Skip this tag after comment bacause it is not nested. + if items.is_empty() { + continue; + } + // recurse to parse the nested list + let (_, last_item) = get_last_link(&mut items)?; + let last_item_number = last_item + .number + .as_ref() + .expect("All numbered chapters have numbers"); + + let sub_items = self.parse_nested_numbered(last_item_number)?; + + last_item.nested_items = sub_items; + } + Some(Event::End(Tag::List(..))) => break, + Some(_) => {} + None => break, + } + } + + Ok(items) + } + + fn parse_nested_item( + &mut self, + parent: &SectionNumber, + num_existing_items: usize, + ) -> Result<SummaryItem> { + loop { + match self.next_event() { + Some(Event::Start(Tag::Paragraph)) => continue, + Some(Event::Start(Tag::Link(_type, href, _title))) => { + let mut link = self.parse_link(href.to_string()); + + let mut number = parent.clone(); + number.0.push(num_existing_items as u32 + 1); + trace!( + "Found chapter: {} {} ({})", + number, + link.name, + link.location + .as_ref() + .map(|p| p.to_str().unwrap_or("")) + .unwrap_or("[draft]") + ); + + link.number = Some(number); + + return Ok(SummaryItem::Link(link)); + } + other => { + warn!("Expected a start of a link, actually got {:?}", other); + bail!(self.parse_error( + "The link items for nested chapters must only contain a hyperlink" + )); + } + } + } + } + + fn parse_error<D: Display>(&self, msg: D) -> Error { + let (line, col) = self.current_location(); + anyhow::anyhow!( + "failed to parse SUMMARY.md line {}, column {}: {}", + line, + col, + msg + ) + } + + /// Try to parse the title line. + fn parse_title(&mut self) -> Option<String> { + loop { + match self.next_event() { + Some(Event::Start(Tag::Heading(HeadingLevel::H1, ..))) => { + debug!("Found a h1 in the SUMMARY"); + + let tags = collect_events!(self.stream, end Tag::Heading(HeadingLevel::H1, ..)); + return Some(stringify_events(tags)); + } + // Skip a HTML element such as a comment line. + Some(Event::Html(_)) => {} + // Otherwise, no title. + Some(ev) => { + self.back(ev); + return None; + } + _ => return None, + } + } + } +} + +fn update_section_numbers(sections: &mut [SummaryItem], level: usize, by: u32) { + for section in sections { + if let SummaryItem::Link(ref mut link) = *section { + if let Some(ref mut number) = link.number { + number.0[level] += by; + } + + update_section_numbers(&mut link.nested_items, level, by); + } + } +} + +/// Gets a pointer to the last `Link` in a list of `SummaryItem`s, and its +/// index. +fn get_last_link(links: &mut [SummaryItem]) -> Result<(usize, &mut Link)> { + links + .iter_mut() + .enumerate() + .filter_map(|(i, item)| item.maybe_link_mut().map(|l| (i, l))) + .rev() + .next() + .ok_or_else(|| + anyhow::anyhow!("Unable to get last link because the list of SummaryItems doesn't contain any Links") + ) +} + +/// Removes the styling from a list of Markdown events and returns just the +/// plain text. +fn stringify_events(events: Vec<Event<'_>>) -> String { + events + .into_iter() + .filter_map(|t| match t { + Event::Text(text) | Event::Code(text) => Some(text.into_string()), + Event::SoftBreak => Some(String::from(" ")), + _ => None, + }) + .collect() +} + +/// A section number like "1.2.3", basically just a newtype'd `Vec<u32>` with +/// a pretty `Display` impl. +#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] +pub struct SectionNumber(pub Vec<u32>); + +impl Display for SectionNumber { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + write!(f, "0") + } else { + for item in &self.0 { + write!(f, "{}.", item)?; + } + Ok(()) + } + } +} + +impl Deref for SectionNumber { + type Target = Vec<u32>; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for SectionNumber { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl FromIterator<u32> for SectionNumber { + fn from_iter<I: IntoIterator<Item = u32>>(it: I) -> Self { + SectionNumber(it.into_iter().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn section_number_has_correct_dotted_representation() { + let inputs = vec![ + (vec![0], "0."), + (vec![1, 3], "1.3."), + (vec![1, 2, 3], "1.2.3."), + ]; + + for (input, should_be) in inputs { + let section_number = SectionNumber(input).to_string(); + assert_eq!(section_number, should_be); + } + } + + #[test] + fn parse_initial_title() { + let src = "# Summary"; + let should_be = String::from("Summary"); + + let mut parser = SummaryParser::new(src); + let got = parser.parse_title().unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn no_initial_title() { + let src = "[Link]()"; + let mut parser = SummaryParser::new(src); + + assert!(parser.parse_title().is_none()); + assert!(matches!( + parser.next_event(), + Some(Event::Start(Tag::Paragraph)) + )); + } + + #[test] + fn parse_title_with_styling() { + let src = "# My **Awesome** Summary"; + let should_be = String::from("My Awesome Summary"); + + let mut parser = SummaryParser::new(src); + let got = parser.parse_title().unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn convert_markdown_events_to_a_string() { + let src = "Hello *World*, `this` is some text [and a link](./path/to/link)"; + let should_be = "Hello World, this is some text and a link"; + + let events = pulldown_cmark::Parser::new(src).collect(); + let got = stringify_events(events); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_some_prefix_items() { + let src = "[First](./first.md)\n[Second](./second.md)\n"; + let mut parser = SummaryParser::new(src); + + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + ..Default::default() + }), + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + ..Default::default() + }), + ]; + + let got = parser.parse_affix(true).unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_prefix_items_with_a_separator() { + let src = "[First](./first.md)\n\n---\n\n[Second](./second.md)\n"; + let mut parser = SummaryParser::new(src); + + let got = parser.parse_affix(true).unwrap(); + + assert_eq!(got.len(), 3); + assert_eq!(got[1], SummaryItem::Separator); + } + + #[test] + fn suffix_items_cannot_be_followed_by_a_list() { + let src = "[First](./first.md)\n- [Second](./second.md)\n"; + let mut parser = SummaryParser::new(src); + + let got = parser.parse_affix(false); + + assert!(got.is_err()); + } + + #[test] + fn parse_a_link() { + let src = "[First](./first.md)"; + let should_be = Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + ..Default::default() + }; + + let mut parser = SummaryParser::new(src); + 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(), + other => panic!("Unreachable, {:?}", other), + }; + + let got = parser.parse_link(href); + assert_eq!(got, should_be); + } + + #[test] + fn parse_a_numbered_chapter() { + let src = "- [First](./first.md)\n"; + let link = Link { + name: String::from("First"), + location: Some(PathBuf::from("./first.md")), + number: Some(SectionNumber(vec![1])), + ..Default::default() + }; + let should_be = vec![SummaryItem::Link(link)]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_nested_numbered_chapters() { + let src = "- [First](./first.md)\n - [Nested](./nested.md)\n- [Second](./second.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![SummaryItem::Link(Link { + name: String::from("Nested"), + location: Some(PathBuf::from("./nested.md")), + number: Some(SectionNumber(vec![1, 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(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn parse_numbered_chapters_separated_by_comment() { + let src = "- [First](./first.md)\n<!-- this is a comment -->\n- [Second](./second.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(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .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); + } + + /// This test ensures the book will continue to pass because it breaks the + /// `SUMMARY.md` up using level 2 headers ([example]). + /// + /// [example]: https://github.com/rust-lang/book/blob/2c942dc094f4ddcdc7aba7564f80782801197c99/second-edition/src/SUMMARY.md#basic-rust-literacy + #[test] + fn can_have_a_subheader_between_nested_items() { + let src = "- [First](./first.md)\n\n## Subheading\n\n- [Second](./second.md)\n"; + 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(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn an_empty_link_location_is_a_draft_chapter() { + let src = "- [Empty]()\n"; + let mut parser = SummaryParser::new(src); + + let got = parser.parse_numbered(&mut 0, &mut SectionNumber::default()); + let should_be = vec![SummaryItem::Link(Link { + name: String::from("Empty"), + location: None, + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + })]; + + assert!(got.is_ok()); + assert_eq!(got.unwrap(), should_be); + } + + /// Regression test for https://github.com/rust-lang/mdBook/issues/779 + /// Ensure section numbers are correctly incremented after a horizontal separator. + #[test] + fn keep_numbering_after_separator() { + let src = + "- [First](./first.md)\n---\n- [Second](./second.md)\n---\n- [Third](./third.md)\n"; + 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::Separator, + SummaryItem::Link(Link { + name: String::from("Second"), + location: Some(PathBuf::from("./second.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + SummaryItem::Separator, + SummaryItem::Link(Link { + name: String::from("Third"), + location: Some(PathBuf::from("./third.md")), + number: Some(SectionNumber(vec![3])), + nested_items: Vec::new(), + }), + ]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + /// Regression test for https://github.com/rust-lang/mdBook/issues/1218 + /// Ensure chapter names spread across multiple lines have spaces between all the words. + #[test] + fn add_space_for_multi_line_chapter_names() { + let src = "- [Chapter\ntitle](./chapter.md)"; + let should_be = vec![SummaryItem::Link(Link { + name: String::from("Chapter title"), + location: Some(PathBuf::from("./chapter.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + })]; + + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn allow_space_in_link_destination() { + let src = "- [test1](./test%20link1.md)\n- [test2](<./test link2.md>)"; + let should_be = vec![ + SummaryItem::Link(Link { + name: String::from("test1"), + location: Some(PathBuf::from("./test link1.md")), + number: Some(SectionNumber(vec![1])), + nested_items: Vec::new(), + }), + SummaryItem::Link(Link { + name: String::from("test2"), + location: Some(PathBuf::from("./test link2.md")), + number: Some(SectionNumber(vec![2])), + nested_items: Vec::new(), + }), + ]; + let mut parser = SummaryParser::new(src); + let got = parser + .parse_numbered(&mut 0, &mut SectionNumber::default()) + .unwrap(); + + assert_eq!(got, should_be); + } + + #[test] + fn skip_html_comments() { + let src = r#"<!-- +# Title - En +--> +# Title - Local + +<!-- +[Prefix 00-01 - En](ch00-01.md) +[Prefix 00-02 - En](ch00-02.md) +--> +[Prefix 00-01 - Local](ch00-01.md) +[Prefix 00-02 - Local](ch00-02.md) + +<!-- +## Section Title - En +--> +## Section Title - Localized + +<!-- +- [Ch 01-00 - En](ch01-00.md) + - [Ch 01-01 - En](ch01-01.md) + - [Ch 01-02 - En](ch01-02.md) +--> +- [Ch 01-00 - Local](ch01-00.md) + - [Ch 01-01 - Local](ch01-01.md) + - [Ch 01-02 - Local](ch01-02.md) + +<!-- +- [Ch 02-00 - En](ch02-00.md) +--> +- [Ch 02-00 - Local](ch02-00.md) + +<!-- +[Appendix A - En](appendix-01.md) +[Appendix B - En](appendix-02.md) +-->` +[Appendix A - Local](appendix-01.md) +[Appendix B - Local](appendix-02.md) +"#; + + let mut parser = SummaryParser::new(src); + + // ---- Title ---- + let title = parser.parse_title(); + assert_eq!(title, Some(String::from("Title - Local"))); + + // ---- Prefix Chapters ---- + + let new_affix_item = |name, location| { + SummaryItem::Link(Link { + name: String::from(name), + location: Some(PathBuf::from(location)), + ..Default::default() + }) + }; + + let should_be = vec![ + new_affix_item("Prefix 00-01 - Local", "ch00-01.md"), + new_affix_item("Prefix 00-02 - Local", "ch00-02.md"), + ]; + + let got = parser.parse_affix(true).unwrap(); + assert_eq!(got, should_be); + + // ---- Numbered Chapters ---- + + let new_numbered_item = |name, location, numbers: &[u32], nested_items| { + SummaryItem::Link(Link { + name: String::from(name), + location: Some(PathBuf::from(location)), + number: Some(SectionNumber(numbers.to_vec())), + nested_items, + }) + }; + + let ch01_nested = vec![ + new_numbered_item("Ch 01-01 - Local", "ch01-01.md", &[1, 1], vec![]), + new_numbered_item("Ch 01-02 - Local", "ch01-02.md", &[1, 2], vec![]), + ]; + + let should_be = vec![ + new_numbered_item("Ch 01-00 - Local", "ch01-00.md", &[1], ch01_nested), + new_numbered_item("Ch 02-00 - Local", "ch02-00.md", &[2], vec![]), + ]; + let got = parser.parse_parts().unwrap(); + assert_eq!(got, should_be); + + // ---- Suffix Chapters ---- + + let should_be = vec![ + new_affix_item("Appendix A - Local", "appendix-01.md"), + new_affix_item("Appendix B - Local", "appendix-02.md"), + ]; + + let got = parser.parse_affix(false).unwrap(); + assert_eq!(got, should_be); + } +} diff --git a/vendor/mdbook/src/cmd/build.rs b/vendor/mdbook/src/cmd/build.rs new file mode 100644 index 000000000..5fe73236c --- /dev/null +++ b/vendor/mdbook/src/cmd/build.rs @@ -0,0 +1,50 @@ +use crate::{get_book_dir, open}; +use clap::{arg, App, Arg, ArgMatches}; +use mdbook::errors::Result; +use mdbook::MDBook; + +// Create clap subcommand arguments +pub fn make_subcommand<'help>() -> App<'help> { + App::new("build") + .about("Builds a book from its markdown files") + .arg( + Arg::new("dest-dir") + .short('d') + .long("dest-dir") + .value_name("dest-dir") + .help( + "Output directory for the book{n}\ + Relative paths are interpreted relative to the book's root directory.{n}\ + If omitted, mdBook uses build.build-dir from book.toml or defaults to `./book`.", + ), + ) + .arg(arg!([dir] + "Root directory for the book{n}\ + (Defaults to the Current Directory when omitted)" + )) + .arg(arg!(-o --open "Opens the compiled book in a web browser")) +} + +// Build command implementation +pub fn execute(args: &ArgMatches) -> Result<()> { + let book_dir = get_book_dir(args); + let mut book = MDBook::load(&book_dir)?; + + if let Some(dest_dir) = args.value_of("dest-dir") { + book.config.build.build_dir = dest_dir.into(); + } + + book.build()?; + + if args.is_present("open") { + // FIXME: What's the right behaviour if we don't use the HTML renderer? + let path = book.build_dir_for("html").join("index.html"); + if !path.exists() { + error!("No chapter available to open"); + std::process::exit(1) + } + open(path); + } + + Ok(()) +} diff --git a/vendor/mdbook/src/cmd/clean.rs b/vendor/mdbook/src/cmd/clean.rs new file mode 100644 index 000000000..0569726e1 --- /dev/null +++ b/vendor/mdbook/src/cmd/clean.rs @@ -0,0 +1,44 @@ +use crate::get_book_dir; +use anyhow::Context; +use clap::{arg, App, Arg, ArgMatches}; +use mdbook::MDBook; +use std::fs; + +// Create clap subcommand arguments +pub fn make_subcommand<'help>() -> App<'help> { + App::new("clean") + .about("Deletes a built book") + .arg( + Arg::new("dest-dir") + .short('d') + .long("dest-dir") + .value_name("dest-dir") + .help( + "Output directory for the book{n}\ + Relative paths are interpreted relative to the book's root directory.{n}\ + If omitted, mdBook uses build.build-dir from book.toml or defaults to `./book`.", + ), + ) + .arg(arg!([dir] + "Root directory for the book{n}\ + (Defaults to the Current Directory when omitted)" + )) +} + +// Clean command implementation +pub fn execute(args: &ArgMatches) -> mdbook::errors::Result<()> { + let book_dir = get_book_dir(args); + let book = MDBook::load(&book_dir)?; + + let dir_to_remove = match args.value_of("dest-dir") { + Some(dest_dir) => dest_dir.into(), + None => book.root.join(&book.config.build.build_dir), + }; + + if dir_to_remove.exists() { + fs::remove_dir_all(&dir_to_remove) + .with_context(|| "Unable to remove the build directory")?; + } + + Ok(()) +} diff --git a/vendor/mdbook/src/cmd/init.rs b/vendor/mdbook/src/cmd/init.rs new file mode 100644 index 000000000..c964dcc13 --- /dev/null +++ b/vendor/mdbook/src/cmd/init.rs @@ -0,0 +1,126 @@ +use crate::get_book_dir; +use clap::{arg, App, Arg, ArgMatches}; +use mdbook::config; +use mdbook::errors::Result; +use mdbook::MDBook; +use std::io; +use std::io::Write; +use std::process::Command; + +// Create clap subcommand arguments +pub fn make_subcommand<'help>() -> App<'help> { + App::new("init") + .about("Creates the boilerplate structure and files for a new book") + // the {n} denotes a newline which will properly aligned in all help messages + .arg(arg!([dir] + "Directory to create the book in{n}\ + (Defaults to the Current Directory when omitted)" + )) + .arg(arg!(--theme "Copies the default theme into your source folder")) + .arg(arg!(--force "Skips confirmation prompts")) + .arg( + Arg::new("title") + .long("title") + .takes_value(true) + .help("Sets the book title") + .required(false), + ) + .arg( + Arg::new("ignore") + .long("ignore") + .takes_value(true) + .possible_values(&["none", "git"]) + .help("Creates a VCS ignore file (i.e. .gitignore)") + .required(false), + ) +} + +// Init command implementation +pub fn execute(args: &ArgMatches) -> Result<()> { + let book_dir = get_book_dir(args); + let mut builder = MDBook::init(&book_dir); + let mut config = config::Config::default(); + // If flag `--theme` is present, copy theme to src + if args.is_present("theme") { + let theme_dir = book_dir.join("theme"); + println!(); + println!("Copying the default theme to {}", theme_dir.display()); + // Skip this if `--force` is present + if !args.is_present("force") && theme_dir.exists() { + println!("This could potentially overwrite files already present in that directory."); + print!("\nAre you sure you want to continue? (y/n) "); + + // Read answer from user and exit if it's not 'yes' + if confirm() { + builder.copy_theme(true); + } + } else { + builder.copy_theme(true); + } + } + + if let Some(ignore) = args.value_of("ignore") { + match ignore { + "git" => builder.create_gitignore(true), + _ => builder.create_gitignore(false), + }; + } else { + println!("\nDo you want a .gitignore to be created? (y/n)"); + if confirm() { + builder.create_gitignore(true); + } + } + + config.book.title = if args.is_present("title") { + args.value_of("title").map(String::from) + } else { + request_book_title() + }; + + if let Some(author) = get_author_name() { + debug!("Obtained user name from gitconfig: {:?}", author); + config.book.authors.push(author); + builder.with_config(config); + } + + builder.build()?; + println!("\nAll done, no errors..."); + + Ok(()) +} + +/// Obtains author name from git config file by running the `git config` command. +fn get_author_name() -> Option<String> { + let output = Command::new("git") + .args(&["config", "--get", "user.name"]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + } else { + None + } +} + +/// Request book title from user and return if provided. +fn request_book_title() -> Option<String> { + println!("What title would you like to give the book? "); + io::stdout().flush().unwrap(); + let mut resp = String::new(); + io::stdin().read_line(&mut resp).unwrap(); + let resp = resp.trim(); + if resp.is_empty() { + None + } else { + Some(resp.into()) + } +} + +// Simple function for user confirmation +fn confirm() -> bool { + io::stdout().flush().unwrap(); + let mut s = String::new(); + io::stdin().read_line(&mut s).ok(); + matches!(&*s.trim(), "Y" | "y" | "yes" | "Yes") +} diff --git a/vendor/mdbook/src/cmd/mod.rs b/vendor/mdbook/src/cmd/mod.rs new file mode 100644 index 000000000..c5b6730f1 --- /dev/null +++ b/vendor/mdbook/src/cmd/mod.rs @@ -0,0 +1,10 @@ +//! Subcommand modules for the `mdbook` binary. + +pub mod build; +pub mod clean; +pub mod init; +#[cfg(feature = "serve")] +pub mod serve; +pub mod test; +#[cfg(feature = "watch")] +pub mod watch; diff --git a/vendor/mdbook/src/cmd/serve.rs b/vendor/mdbook/src/cmd/serve.rs new file mode 100644 index 000000000..bafbfd52e --- /dev/null +++ b/vendor/mdbook/src/cmd/serve.rs @@ -0,0 +1,177 @@ +#[cfg(feature = "watch")] +use super::watch; +use crate::{get_book_dir, open}; +use clap::{arg, App, Arg, ArgMatches}; +use futures_util::sink::SinkExt; +use futures_util::StreamExt; +use mdbook::errors::*; +use mdbook::utils; +use mdbook::utils::fs::get_404_output_file; +use mdbook::MDBook; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::PathBuf; +use tokio::sync::broadcast; +use warp::ws::Message; +use warp::Filter; + +/// The HTTP endpoint for the websocket used to trigger reloads when a file changes. +const LIVE_RELOAD_ENDPOINT: &str = "__livereload"; + +// Create clap subcommand arguments +pub fn make_subcommand<'help>() -> App<'help> { + App::new("serve") + .about("Serves a book at http://localhost:3000, and rebuilds it on changes") + .arg( + Arg::new("dest-dir") + .short('d') + .long("dest-dir") + .value_name("dest-dir") + .help( + "Output directory for the book{n}\ + Relative paths are interpreted relative to the book's root directory.{n}\ + If omitted, mdBook uses build.build-dir from book.toml or defaults to `./book`.", + ), + ) + .arg(arg!([dir] + "Root directory for the book{n}\ + (Defaults to the Current Directory when omitted)" + )) + .arg( + Arg::new("hostname") + .short('n') + .long("hostname") + .takes_value(true) + .default_value("localhost") + .forbid_empty_values(true) + .help("Hostname to listen on for HTTP connections"), + ) + .arg( + Arg::new("port") + .short('p') + .long("port") + .takes_value(true) + .default_value("3000") + .forbid_empty_values(true) + .help("Port to use for HTTP connections"), + ) + .arg(arg!(-o --open "Opens the compiled book in a web browser")) +} + +// Serve command implementation +pub fn execute(args: &ArgMatches) -> Result<()> { + let book_dir = get_book_dir(args); + let mut book = MDBook::load(&book_dir)?; + + let port = args.value_of("port").unwrap(); + let hostname = args.value_of("hostname").unwrap(); + let open_browser = args.is_present("open"); + + let address = format!("{}:{}", hostname, port); + + let update_config = |book: &mut MDBook| { + book.config + .set("output.html.live-reload-endpoint", &LIVE_RELOAD_ENDPOINT) + .expect("live-reload-endpoint update failed"); + if let Some(dest_dir) = args.value_of("dest-dir") { + book.config.build.build_dir = dest_dir.into(); + } + // Override site-url for local serving of the 404 file + book.config.set("output.html.site-url", "/").unwrap(); + }; + update_config(&mut book); + book.build()?; + + let sockaddr: SocketAddr = address + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("no address found for {}", address))?; + let build_dir = book.build_dir_for("html"); + let input_404 = book + .config + .get("output.html.input-404") + .map(toml::Value::as_str) + .and_then(std::convert::identity) // flatten + .map(ToString::to_string); + let file_404 = get_404_output_file(&input_404); + + // A channel used to broadcast to any websockets to reload when a file changes. + let (tx, _rx) = tokio::sync::broadcast::channel::<Message>(100); + + let reload_tx = tx.clone(); + let thread_handle = std::thread::spawn(move || { + serve(build_dir, sockaddr, reload_tx, &file_404); + }); + + let serving_url = format!("http://{}", address); + info!("Serving on: {}", serving_url); + + if open_browser { + open(serving_url); + } + + #[cfg(feature = "watch")] + watch::trigger_on_change(&book, move |paths, book_dir| { + info!("Files changed: {:?}", paths); + info!("Building book..."); + + // FIXME: This area is really ugly because we need to re-set livereload :( + let result = MDBook::load(&book_dir).and_then(|mut b| { + update_config(&mut b); + b.build() + }); + + if let Err(e) = result { + error!("Unable to load the book"); + utils::log_backtrace(&e); + } else { + let _ = tx.send(Message::text("reload")); + } + }); + + let _ = thread_handle.join(); + + Ok(()) +} + +#[tokio::main] +async fn serve( + build_dir: PathBuf, + address: SocketAddr, + reload_tx: broadcast::Sender<Message>, + file_404: &str, +) { + // A warp Filter which captures `reload_tx` and provides an `rx` copy to + // receive reload messages. + let sender = warp::any().map(move || reload_tx.subscribe()); + + // A warp Filter to handle the livereload endpoint. This upgrades to a + // websocket, and then waits for any filesystem change notifications, and + // relays them over the websocket. + let livereload = warp::path(LIVE_RELOAD_ENDPOINT) + .and(warp::ws()) + .and(sender) + .map(|ws: warp::ws::Ws, mut rx: broadcast::Receiver<Message>| { + ws.on_upgrade(move |ws| async move { + let (mut user_ws_tx, _user_ws_rx) = ws.split(); + trace!("websocket got connection"); + if let Ok(m) = rx.recv().await { + trace!("notify of reload"); + let _ = user_ws_tx.send(m).await; + } + }) + }); + // A warp Filter that serves from the filesystem. + let book_route = warp::fs::dir(build_dir.clone()); + // The fallback route for 404 errors + let fallback_route = warp::fs::file(build_dir.join(file_404)) + .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NOT_FOUND)); + let routes = livereload.or(book_route).or(fallback_route); + + std::panic::set_hook(Box::new(move |panic_info| { + // exit if serve panics + error!("Unable to serve: {}", panic_info); + std::process::exit(1); + })); + + warp::serve(routes).run(address).await; +} diff --git a/vendor/mdbook/src/cmd/test.rs b/vendor/mdbook/src/cmd/test.rs new file mode 100644 index 000000000..02f982a49 --- /dev/null +++ b/vendor/mdbook/src/cmd/test.rs @@ -0,0 +1,54 @@ +use crate::get_book_dir; +use clap::{arg, App, Arg, ArgMatches}; +use mdbook::errors::Result; +use mdbook::MDBook; + +// Create clap subcommand arguments +pub fn make_subcommand<'help>() -> App<'help> { + App::new("test") + .about("Tests that a book's Rust code samples compile") + .arg( + Arg::new("dest-dir") + .short('d') + .long("dest-dir") + .value_name("dest-dir") + .help( + "Output directory for the book{n}\ + Relative paths are interpreted relative to the book's root directory.{n}\ + If omitted, mdBook uses build.build-dir from book.toml or defaults to `./book`.", + ), + ) + .arg(arg!([dir] + "Root directory for the book{n}\ + (Defaults to the Current Directory when omitted)" + )) + .arg(Arg::new("library-path") + .short('L') + .long("library-path") + .value_name("dir") + .takes_value(true) + .use_delimiter(true) + .require_delimiter(true) + .multiple_values(true) + .multiple_occurrences(true) + .forbid_empty_values(true) + .help("A comma-separated list of directories to add to {n}the crate search path when building tests")) +} + +// test command implementation +pub fn execute(args: &ArgMatches) -> Result<()> { + let library_paths: Vec<&str> = args + .values_of("library-path") + .map(std::iter::Iterator::collect) + .unwrap_or_default(); + let book_dir = get_book_dir(args); + let mut book = MDBook::load(&book_dir)?; + + if let Some(dest_dir) = args.value_of("dest-dir") { + book.config.build.build_dir = dest_dir.into(); + } + + book.test(library_paths)?; + + Ok(()) +} diff --git a/vendor/mdbook/src/cmd/watch.rs b/vendor/mdbook/src/cmd/watch.rs new file mode 100644 index 000000000..9336af779 --- /dev/null +++ b/vendor/mdbook/src/cmd/watch.rs @@ -0,0 +1,175 @@ +use crate::{get_book_dir, open}; +use clap::{arg, App, Arg, ArgMatches}; +use mdbook::errors::Result; +use mdbook::utils; +use mdbook::MDBook; +use notify::Watcher; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::channel; +use std::thread::sleep; +use std::time::Duration; + +// Create clap subcommand arguments +pub fn make_subcommand<'help>() -> App<'help> { + App::new("watch") + .about("Watches a book's files and rebuilds it on changes") + .arg( + Arg::new("dest-dir") + .short('d') + .long("dest-dir") + .value_name("dest-dir") + .help( + "Output directory for the book{n}\ + Relative paths are interpreted relative to the book's root directory.{n}\ + If omitted, mdBook uses build.build-dir from book.toml or defaults to `./book`.", + ), + ) + .arg(arg!([dir] + "Root directory for the book{n}\ + (Defaults to the Current Directory when omitted)" + )) + .arg(arg!(-o --open "Opens the compiled book in a web browser")) +} + +// Watch command implementation +pub fn execute(args: &ArgMatches) -> Result<()> { + let book_dir = get_book_dir(args); + let mut book = MDBook::load(&book_dir)?; + + let update_config = |book: &mut MDBook| { + if let Some(dest_dir) = args.value_of("dest-dir") { + book.config.build.build_dir = dest_dir.into(); + } + }; + update_config(&mut book); + + if args.is_present("open") { + book.build()?; + let path = book.build_dir_for("html").join("index.html"); + if !path.exists() { + error!("No chapter available to open"); + std::process::exit(1) + } + open(path); + } + + trigger_on_change(&book, |paths, book_dir| { + info!("Files changed: {:?}\nBuilding book...\n", paths); + let result = MDBook::load(&book_dir).and_then(|mut b| { + update_config(&mut b); + b.build() + }); + + if let Err(e) = result { + error!("Unable to build the book"); + utils::log_backtrace(&e); + } + }); + + Ok(()) +} + +fn remove_ignored_files(book_root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> { + if paths.is_empty() { + return vec![]; + } + + match find_gitignore(book_root) { + Some(gitignore_path) => { + match gitignore::File::new(gitignore_path.as_path()) { + Ok(exclusion_checker) => filter_ignored_files(exclusion_checker, paths), + Err(_) => { + // We're unable to read the .gitignore file, so we'll silently allow everything. + // Please see discussion: https://github.com/rust-lang/mdBook/pull/1051 + paths.iter().map(|path| path.to_path_buf()).collect() + } + } + } + None => { + // There is no .gitignore file. + paths.iter().map(|path| path.to_path_buf()).collect() + } + } +} + +fn find_gitignore(book_root: &Path) -> Option<PathBuf> { + book_root + .ancestors() + .map(|p| p.join(".gitignore")) + .find(|p| p.exists()) +} + +fn filter_ignored_files(exclusion_checker: gitignore::File, paths: &[PathBuf]) -> Vec<PathBuf> { + paths + .iter() + .filter(|path| match exclusion_checker.is_excluded(path) { + Ok(exclude) => !exclude, + Err(error) => { + warn!( + "Unable to determine if {:?} is excluded: {:?}. Including it.", + &path, error + ); + true + } + }) + .map(|path| path.to_path_buf()) + .collect() +} + +/// Calls the closure when a book source file is changed, blocking indefinitely. +pub fn trigger_on_change<F>(book: &MDBook, closure: F) +where + F: Fn(Vec<PathBuf>, &Path), +{ + use notify::DebouncedEvent::*; + use notify::RecursiveMode::*; + + // Create a channel to receive the events. + let (tx, rx) = channel(); + + let mut watcher = match notify::watcher(tx, Duration::from_secs(1)) { + Ok(w) => w, + Err(e) => { + error!("Error while trying to watch the files:\n\n\t{:?}", e); + std::process::exit(1) + } + }; + + // Add the source directory to the watcher + if let Err(e) = watcher.watch(book.source_dir(), Recursive) { + error!("Error while watching {:?}:\n {:?}", book.source_dir(), e); + std::process::exit(1); + }; + + let _ = watcher.watch(book.theme_dir(), Recursive); + + // Add the book.toml file to the watcher if it exists + let _ = watcher.watch(book.root.join("book.toml"), NonRecursive); + + info!("Listening for changes..."); + + loop { + let first_event = rx.recv().unwrap(); + sleep(Duration::from_millis(50)); + let other_events = rx.try_iter(); + + let all_events = std::iter::once(first_event).chain(other_events); + + let paths = all_events + .filter_map(|event| { + debug!("Received filesystem event: {:?}", event); + + match event { + Create(path) | Write(path) | Remove(path) | Rename(_, path) => Some(path), + _ => None, + } + }) + .collect::<Vec<_>>(); + + let paths = remove_ignored_files(&book.root, &paths[..]); + + if !paths.is_empty() { + closure(paths, &book.root); + } + } +} diff --git a/vendor/mdbook/src/config.rs b/vendor/mdbook/src/config.rs new file mode 100644 index 000000000..b7d03d1a2 --- /dev/null +++ b/vendor/mdbook/src/config.rs @@ -0,0 +1,1190 @@ +//! Mdbook's configuration system. +//! +//! The main entrypoint of the `config` module is the `Config` struct. This acts +//! essentially as a bag of configuration information, with a couple +//! pre-determined tables ([`BookConfig`] and [`BuildConfig`]) as well as support +//! for arbitrary data which is exposed to plugins and alternative backends. +//! +//! +//! # Examples +//! +//! ```rust +//! # use mdbook::errors::*; +//! use std::path::PathBuf; +//! use std::str::FromStr; +//! use mdbook::Config; +//! use toml::Value; +//! +//! # fn run() -> Result<()> { +//! let src = r#" +//! [book] +//! title = "My Book" +//! authors = ["Michael-F-Bryan"] +//! +//! [build] +//! src = "out" +//! +//! [other-table.foo] +//! bar = 123 +//! "#; +//! +//! // load the `Config` from a toml string +//! let mut cfg = Config::from_str(src)?; +//! +//! // retrieve a nested value +//! let bar = cfg.get("other-table.foo.bar").cloned(); +//! assert_eq!(bar, Some(Value::Integer(123))); +//! +//! // Set the `output.html.theme` directory +//! assert!(cfg.get("output.html").is_none()); +//! cfg.set("output.html.theme", "./themes"); +//! +//! // then load it again, automatically deserializing to a `PathBuf`. +//! let got: Option<PathBuf> = cfg.get_deserialized_opt("output.html.theme")?; +//! assert_eq!(got, Some(PathBuf::from("./themes"))); +//! # Ok(()) +//! # } +//! # run().unwrap() +//! ``` + +#![deny(missing_docs)] + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::HashMap; +use std::env; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use toml::value::Table; +use toml::{self, Value}; + +use crate::errors::*; +use crate::utils::{self, toml_ext::TomlExt}; + +/// The overall configuration object for MDBook, essentially an in-memory +/// representation of `book.toml`. +#[derive(Debug, Clone, PartialEq)] +pub struct Config { + /// Metadata about the book. + pub book: BookConfig, + /// Information about the build environment. + pub build: BuildConfig, + /// Information about Rust language support. + pub rust: RustConfig, + rest: Value, +} + +impl FromStr for Config { + type Err = Error; + + /// Load a `Config` from some string. + fn from_str(src: &str) -> Result<Self> { + toml::from_str(src).with_context(|| "Invalid configuration file") + } +} + +impl Config { + /// Load the configuration file from disk. + pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> { + let mut buffer = String::new(); + File::open(config_file) + .with_context(|| "Unable to open the configuration file")? + .read_to_string(&mut buffer) + .with_context(|| "Couldn't read the file")?; + + Config::from_str(&buffer) + } + + /// Updates the `Config` from the available environment variables. + /// + /// Variables starting with `MDBOOK_` are used for configuration. The key is + /// created by removing the `MDBOOK_` prefix and turning the resulting + /// string into `kebab-case`. Double underscores (`__`) separate nested + /// keys, while a single underscore (`_`) is replaced with a dash (`-`). + /// + /// For example: + /// + /// - `MDBOOK_foo` -> `foo` + /// - `MDBOOK_FOO` -> `foo` + /// - `MDBOOK_FOO__BAR` -> `foo.bar` + /// - `MDBOOK_FOO_BAR` -> `foo-bar` + /// - `MDBOOK_FOO_bar__baz` -> `foo-bar.baz` + /// + /// So by setting the `MDBOOK_BOOK__TITLE` environment variable you can + /// override the book's title without needing to touch your `book.toml`. + /// + /// > **Note:** To facilitate setting more complex config items, the value + /// > of an environment variable is first parsed as JSON, falling back to a + /// > string if the parse fails. + /// > + /// > This means, if you so desired, you could override all book metadata + /// > when building the book with something like + /// > + /// > ```text + /// > $ export MDBOOK_BOOK='{"title": "My Awesome Book", "authors": ["Michael-F-Bryan"]}' + /// > $ mdbook build + /// > ``` + /// + /// The latter case may be useful in situations where `mdbook` is invoked + /// from a script or CI, where it sometimes isn't possible to update the + /// `book.toml` before building. + pub fn update_from_env(&mut self) { + debug!("Updating the config from environment variables"); + + let overrides = + env::vars().filter_map(|(key, value)| parse_env(&key).map(|index| (index, value))); + + for (key, value) in overrides { + trace!("{} => {}", key, value); + let parsed_value = serde_json::from_str(&value) + .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); + + if key == "book" || key == "build" { + if let serde_json::Value::Object(ref map) = parsed_value { + // To `set` each `key`, we wrap them as `prefix.key` + for (k, v) in map { + let full_key = format!("{}.{}", key, k); + self.set(&full_key, v).expect("unreachable"); + } + return; + } + } + + self.set(key, parsed_value).expect("unreachable"); + } + } + + /// Fetch an arbitrary item from the `Config` as a `toml::Value`. + /// + /// You can use dotted indices to access nested items (e.g. + /// `output.html.playground` will fetch the "playground" out of the html output + /// table). + pub fn get(&self, key: &str) -> Option<&Value> { + self.rest.read(key) + } + + /// Fetch a value from the `Config` so you can mutate it. + pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> { + self.rest.read_mut(key) + } + + /// Convenience method for getting the html renderer's configuration. + /// + /// # Note + /// + /// This is for compatibility only. It will be removed completely once the + /// HTML renderer is refactored to be less coupled to `mdbook` internals. + #[doc(hidden)] + pub fn html_config(&self) -> Option<HtmlConfig> { + match self + .get_deserialized_opt("output.html") + .with_context(|| "Parsing configuration [output.html]") + { + Ok(Some(config)) => Some(config), + Ok(None) => None, + Err(e) => { + utils::log_backtrace(&e); + None + } + } + } + + /// Deprecated, use get_deserialized_opt instead. + #[deprecated = "use get_deserialized_opt instead"] + pub fn get_deserialized<'de, T: Deserialize<'de>, S: AsRef<str>>(&self, name: S) -> Result<T> { + let name = name.as_ref(); + match self.get_deserialized_opt(name)? { + Some(value) => Ok(value), + None => bail!("Key not found, {:?}", name), + } + } + + /// Convenience function to fetch a value from the config and deserialize it + /// into some arbitrary type. + pub fn get_deserialized_opt<'de, T: Deserialize<'de>, S: AsRef<str>>( + &self, + name: S, + ) -> Result<Option<T>> { + let name = name.as_ref(); + self.get(name) + .map(|value| { + value + .clone() + .try_into() + .with_context(|| "Couldn't deserialize the value") + }) + .transpose() + } + + /// Set a config key, clobbering any existing values along the way. + /// + /// The only way this can fail is if we can't serialize `value` into a + /// `toml::Value`. + pub fn set<S: Serialize, I: AsRef<str>>(&mut self, index: I, value: S) -> Result<()> { + let index = index.as_ref(); + + let value = Value::try_from(value) + .with_context(|| "Unable to represent the item as a JSON Value")?; + + if let Some(key) = index.strip_prefix("book.") { + self.book.update_value(key, value); + } else if let Some(key) = index.strip_prefix("build.") { + self.build.update_value(key, value); + } else { + self.rest.insert(index, value); + } + + Ok(()) + } + + /// Get the table associated with a particular renderer. + pub fn get_renderer<I: AsRef<str>>(&self, index: I) -> Option<&Table> { + let key = format!("output.{}", index.as_ref()); + self.get(&key).and_then(Value::as_table) + } + + /// Get the table associated with a particular preprocessor. + pub fn get_preprocessor<I: AsRef<str>>(&self, index: I) -> Option<&Table> { + let key = format!("preprocessor.{}", index.as_ref()); + self.get(&key).and_then(Value::as_table) + } + + fn from_legacy(mut table: Value) -> Config { + let mut cfg = Config::default(); + + // we use a macro here instead of a normal loop because the $out + // variable can be different types. This way we can make type inference + // figure out what try_into() deserializes to. + macro_rules! get_and_insert { + ($table:expr, $key:expr => $out:expr) => { + let got = $table + .as_table_mut() + .and_then(|t| t.remove($key)) + .and_then(|v| v.try_into().ok()); + if let Some(value) = got { + $out = value; + } + }; + } + + get_and_insert!(table, "title" => cfg.book.title); + get_and_insert!(table, "authors" => cfg.book.authors); + get_and_insert!(table, "source" => cfg.book.src); + get_and_insert!(table, "description" => cfg.book.description); + + if let Some(dest) = table.delete("output.html.destination") { + if let Ok(destination) = dest.try_into() { + cfg.build.build_dir = destination; + } + } + + cfg.rest = table; + cfg + } +} + +impl Default for Config { + fn default() -> Config { + Config { + book: BookConfig::default(), + build: BuildConfig::default(), + rust: RustConfig::default(), + rest: Value::Table(Table::default()), + } + } +} + +impl<'de> Deserialize<'de> for Config { + fn deserialize<D: Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> { + let raw = Value::deserialize(de)?; + + if is_legacy_format(&raw) { + warn!("It looks like you are using the legacy book.toml format."); + warn!("We'll parse it for now, but you should probably convert to the new format."); + warn!("See the mdbook documentation for more details, although as a rule of thumb"); + warn!("just move all top level configuration entries like `title`, `author` and"); + warn!("`description` under a table called `[book]`, move the `destination` entry"); + warn!("from `[output.html]`, renamed to `build-dir`, under a table called"); + warn!("`[build]`, and it should all work."); + warn!("Documentation: http://rust-lang.github.io/mdBook/format/config.html"); + return Ok(Config::from_legacy(raw)); + } + + use serde::de::Error; + let mut table = match raw { + Value::Table(t) => t, + _ => { + return Err(D::Error::custom( + "A config file should always be a toml table", + )); + } + }; + + let book: BookConfig = table + .remove("book") + .map(|book| book.try_into().map_err(D::Error::custom)) + .transpose()? + .unwrap_or_default(); + + let build: BuildConfig = table + .remove("build") + .map(|build| build.try_into().map_err(D::Error::custom)) + .transpose()? + .unwrap_or_default(); + + let rust: RustConfig = table + .remove("rust") + .map(|rust| rust.try_into().map_err(D::Error::custom)) + .transpose()? + .unwrap_or_default(); + + Ok(Config { + book, + build, + rust, + rest: Value::Table(table), + }) + } +} + +impl Serialize for Config { + fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> { + // TODO: This should probably be removed and use a derive instead. + let mut table = self.rest.clone(); + + let book_config = Value::try_from(&self.book).expect("should always be serializable"); + table.insert("book", book_config); + + if self.build != BuildConfig::default() { + let build_config = Value::try_from(&self.build).expect("should always be serializable"); + table.insert("build", build_config); + } + + if self.rust != RustConfig::default() { + let rust_config = Value::try_from(&self.rust).expect("should always be serializable"); + table.insert("rust", rust_config); + } + + table.serialize(s) + } +} + +fn parse_env(key: &str) -> Option<String> { + key.strip_prefix("MDBOOK_") + .map(|key| key.to_lowercase().replace("__", ".").replace('_', "-")) +} + +fn is_legacy_format(table: &Value) -> bool { + let legacy_items = [ + "title", + "authors", + "source", + "description", + "output.html.destination", + ]; + + for item in &legacy_items { + if table.read(item).is_some() { + return true; + } + } + + false +} + +/// Configuration options which are specific to the book and required for +/// loading it from disk. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct BookConfig { + /// The book's title. + pub title: Option<String>, + /// The book's authors. + pub authors: Vec<String>, + /// An optional description for the book. + pub description: Option<String>, + /// Location of the book source relative to the book's root directory. + pub src: PathBuf, + /// Does this book support more than one language? + pub multilingual: bool, + /// The main language of the book. + pub language: Option<String>, +} + +impl Default for BookConfig { + fn default() -> BookConfig { + BookConfig { + title: None, + authors: Vec::new(), + description: None, + src: PathBuf::from("src"), + multilingual: false, + language: Some(String::from("en")), + } + } +} + +/// Configuration for the build procedure. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct BuildConfig { + /// Where to put built artefacts relative to the book's root directory. + pub build_dir: PathBuf, + /// Should non-existent markdown files specified in `SUMMARY.md` be created + /// if they don't exist? + pub create_missing: bool, + /// Should the default preprocessors always be used when they are + /// compatible with the renderer? + pub use_default_preprocessors: bool, +} + +impl Default for BuildConfig { + fn default() -> BuildConfig { + BuildConfig { + build_dir: PathBuf::from("book"), + create_missing: true, + use_default_preprocessors: true, + } + } +} + +/// Configuration for the Rust compiler(e.g., for playground) +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct RustConfig { + /// Rust edition used in playground + pub edition: Option<RustEdition>, +} + +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +/// Rust edition to use for the code. +pub enum RustEdition { + /// The 2021 edition of Rust + #[serde(rename = "2021")] + E2021, + /// The 2018 edition of Rust + #[serde(rename = "2018")] + E2018, + /// The 2015 edition of Rust + #[serde(rename = "2015")] + E2015, +} + +/// Configuration for the HTML renderer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct HtmlConfig { + /// The theme directory, if specified. + pub theme: Option<PathBuf>, + /// The default theme to use, defaults to 'light' + pub default_theme: Option<String>, + /// The theme to use if the browser requests the dark version of the site. + /// Defaults to 'navy'. + pub preferred_dark_theme: Option<String>, + /// Use "smart quotes" instead of the usual `"` character. + pub curly_quotes: bool, + /// Should mathjax be enabled? + pub mathjax_support: bool, + /// Whether to fonts.css and respective font files to the output directory. + pub copy_fonts: bool, + /// An optional google analytics code. + pub google_analytics: Option<String>, + /// Additional CSS stylesheets to include in the rendered page's `<head>`. + pub additional_css: Vec<PathBuf>, + /// Additional JS scripts to include at the bottom of the rendered page's + /// `<body>`. + pub additional_js: Vec<PathBuf>, + /// Fold settings. + pub fold: Fold, + /// Playground settings. + #[serde(alias = "playpen")] + pub playground: Playground, + /// Print settings. + pub print: Print, + /// Don't render section labels. + pub no_section_label: bool, + /// Search settings. If `None`, the default will be used. + pub search: Option<Search>, + /// Git repository url. If `None`, the git button will not be shown. + pub git_repository_url: Option<String>, + /// FontAwesome icon class to use for the Git repository link. + /// Defaults to `fa-github` if `None`. + pub git_repository_icon: Option<String>, + /// Input path for the 404 file, defaults to 404.md, set to "" to disable 404 file output + pub input_404: Option<String>, + /// Absolute url to site, used to emit correct paths for the 404 page, which might be accessed in a deeply nested directory + pub site_url: Option<String>, + /// The DNS subdomain or apex domain at which your book will be hosted. This + /// string will be written to a file named CNAME in the root of your site, + /// as required by GitHub Pages (see [*Managing a custom domain for your + /// GitHub Pages site*][custom domain]). + /// + /// [custom domain]: https://docs.github.com/en/github/working-with-github-pages/managing-a-custom-domain-for-your-github-pages-site + pub cname: Option<String>, + /// Edit url template, when set shows a "Suggest an edit" button for + /// directly jumping to editing the currently viewed page. + /// Contains {path} that is replaced with chapter source file path + pub edit_url_template: Option<String>, + /// Endpoint of websocket, for livereload usage. Value loaded from .toml file + /// is ignored, because our code overrides this field with the value [`LIVE_RELOAD_ENDPOINT`] + /// + /// [`LIVE_RELOAD_ENDPOINT`]: cmd::serve::LIVE_RELOAD_ENDPOINT + /// + /// This config item *should not be edited* by the end user. + #[doc(hidden)] + pub live_reload_endpoint: Option<String>, + /// The mapping from old pages to new pages/URLs to use when generating + /// redirects. + pub redirect: HashMap<String, String>, +} + +impl Default for HtmlConfig { + fn default() -> HtmlConfig { + HtmlConfig { + theme: None, + default_theme: None, + preferred_dark_theme: None, + curly_quotes: false, + mathjax_support: false, + copy_fonts: true, + google_analytics: None, + additional_css: Vec::new(), + additional_js: Vec::new(), + fold: Fold::default(), + playground: Playground::default(), + print: Print::default(), + no_section_label: false, + search: None, + git_repository_url: None, + git_repository_icon: None, + edit_url_template: None, + input_404: None, + site_url: None, + cname: None, + live_reload_endpoint: None, + redirect: HashMap::new(), + } + } +} + +impl HtmlConfig { + /// Returns the directory of theme from the provided root directory. If the + /// directory is not present it will append the default directory of "theme" + pub fn theme_dir(&self, root: &Path) -> PathBuf { + match self.theme { + Some(ref d) => root.join(d), + None => root.join("theme"), + } + } +} + +/// Configuration for how to render the print icon, print.html, and print.css. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Print { + /// Whether print support is enabled. + pub enable: bool, + /// Insert page breaks between chapters. Default: `true`. + pub page_break: bool, +} + +impl Default for Print { + fn default() -> Self { + Self { + enable: true, + page_break: true, + } + } +} + +/// Configuration for how to fold chapters of sidebar. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Fold { + /// When off, all folds are open. Default: `false`. + pub enable: bool, + /// The higher the more folded regions are open. When level is 0, all folds + /// are closed. + /// Default: `0`. + pub level: u8, +} + +/// Configuration for tweaking how the the HTML renderer handles the playground. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Playground { + /// Should playground snippets be editable? Default: `false`. + pub editable: bool, + /// Display the copy button. Default: `true`. + pub copyable: bool, + /// Copy JavaScript files for the editor to the output directory? + /// Default: `true`. + pub copy_js: bool, + /// Display line numbers on playground snippets. Default: `false`. + pub line_numbers: bool, + /// Display the run button. Default: `true` + pub runnable: bool, +} + +impl Default for Playground { + fn default() -> Playground { + Playground { + editable: false, + copyable: true, + copy_js: true, + line_numbers: false, + runnable: true, + } + } +} + +/// Configuration of the search functionality of the HTML renderer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Search { + /// Enable the search feature. Default: `true`. + pub enable: bool, + /// Maximum number of visible results. Default: `30`. + pub limit_results: u32, + /// The number of words used for a search result teaser. Default: `30`. + pub teaser_word_count: u32, + /// Define the logical link between multiple search words. + /// If true, all search words must appear in each result. Default: `false`. + pub use_boolean_and: bool, + /// Boost factor for the search result score if a search word appears in the header. + /// Default: `2`. + pub boost_title: u8, + /// Boost factor for the search result score if a search word appears in the hierarchy. + /// The hierarchy contains all titles of the parent documents and all parent headings. + /// Default: `1`. + pub boost_hierarchy: u8, + /// Boost factor for the search result score if a search word appears in the text. + /// Default: `1`. + pub boost_paragraph: u8, + /// True if the searchword `micro` should match `microwave`. Default: `true`. + pub expand: bool, + /// Documents are split into smaller parts, separated by headings. This defines, until which + /// level of heading documents should be split. Default: `3`. (`### This is a level 3 heading`) + pub heading_split_level: u8, + /// Copy JavaScript files for the search functionality to the output directory? + /// Default: `true`. + pub copy_js: bool, +} + +impl Default for Search { + fn default() -> Search { + // Please update the documentation of `Search` when changing values! + Search { + enable: true, + limit_results: 30, + teaser_word_count: 30, + use_boolean_and: false, + boost_title: 2, + boost_hierarchy: 1, + boost_paragraph: 1, + expand: true, + heading_split_level: 3, + copy_js: true, + } + } +} + +/// Allows you to "update" any arbitrary field in a struct by round-tripping via +/// a `toml::Value`. +/// +/// This is definitely not the most performant way to do things, which means you +/// should probably keep it away from tight loops... +trait Updateable<'de>: Serialize + Deserialize<'de> { + fn update_value<S: Serialize>(&mut self, key: &str, value: S) { + let mut raw = Value::try_from(&self).expect("unreachable"); + + if let Ok(value) = Value::try_from(value) { + let _ = raw.insert(key, value); + } else { + return; + } + + if let Ok(updated) = raw.try_into() { + *self = updated; + } + } +} + +impl<'de, T> Updateable<'de> for T where T: Serialize + Deserialize<'de> {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::fs::get_404_output_file; + + const COMPLEX_CONFIG: &str = r#" + [book] + title = "Some Book" + authors = ["Michael-F-Bryan <michaelfbryan@gmail.com>"] + description = "A completely useless book" + multilingual = true + src = "source" + language = "ja" + + [build] + build-dir = "outputs" + create-missing = false + use-default-preprocessors = true + + [output.html] + theme = "./themedir" + default-theme = "rust" + curly-quotes = true + google-analytics = "123456" + additional-css = ["./foo/bar/baz.css"] + git-repository-url = "https://foo.com/" + git-repository-icon = "fa-code-fork" + + [output.html.playground] + editable = true + editor = "ace" + + [output.html.redirect] + "index.html" = "overview.html" + "nexted/page.md" = "https://rust-lang.org/" + + [preprocessor.first] + + [preprocessor.second] + "#; + + #[test] + fn load_a_complex_config_file() { + let src = COMPLEX_CONFIG; + + let book_should_be = BookConfig { + title: Some(String::from("Some Book")), + authors: vec![String::from("Michael-F-Bryan <michaelfbryan@gmail.com>")], + description: Some(String::from("A completely useless book")), + multilingual: true, + src: PathBuf::from("source"), + language: Some(String::from("ja")), + }; + let build_should_be = BuildConfig { + build_dir: PathBuf::from("outputs"), + create_missing: false, + use_default_preprocessors: true, + }; + let rust_should_be = RustConfig { edition: None }; + let playground_should_be = Playground { + editable: true, + copyable: true, + copy_js: true, + line_numbers: false, + runnable: true, + }; + let html_should_be = HtmlConfig { + curly_quotes: true, + google_analytics: Some(String::from("123456")), + additional_css: vec![PathBuf::from("./foo/bar/baz.css")], + theme: Some(PathBuf::from("./themedir")), + default_theme: Some(String::from("rust")), + playground: playground_should_be, + git_repository_url: Some(String::from("https://foo.com/")), + git_repository_icon: Some(String::from("fa-code-fork")), + redirect: vec![ + (String::from("index.html"), String::from("overview.html")), + ( + String::from("nexted/page.md"), + String::from("https://rust-lang.org/"), + ), + ] + .into_iter() + .collect(), + ..Default::default() + }; + + let got = Config::from_str(src).unwrap(); + + assert_eq!(got.book, book_should_be); + assert_eq!(got.build, build_should_be); + assert_eq!(got.rust, rust_should_be); + assert_eq!(got.html_config().unwrap(), html_should_be); + } + + #[test] + fn disable_runnable() { + let src = r#" + [book] + title = "Some Book" + description = "book book book" + authors = ["Shogo Takata"] + + [output.html.playground] + runnable = false + "#; + + let got = Config::from_str(src).unwrap(); + assert!(!got.html_config().unwrap().playground.runnable); + } + + #[test] + fn edition_2015() { + let src = r#" + [book] + title = "mdBook Documentation" + description = "Create book from markdown files. Like Gitbook but implemented in Rust" + authors = ["Mathieu David"] + src = "./source" + [rust] + edition = "2015" + "#; + + let book_should_be = BookConfig { + title: Some(String::from("mdBook Documentation")), + description: Some(String::from( + "Create book from markdown files. Like Gitbook but implemented in Rust", + )), + authors: vec![String::from("Mathieu David")], + src: PathBuf::from("./source"), + ..Default::default() + }; + + let got = Config::from_str(src).unwrap(); + assert_eq!(got.book, book_should_be); + + let rust_should_be = RustConfig { + edition: Some(RustEdition::E2015), + }; + let got = Config::from_str(src).unwrap(); + assert_eq!(got.rust, rust_should_be); + } + + #[test] + fn edition_2018() { + let src = r#" + [book] + title = "mdBook Documentation" + description = "Create book from markdown files. Like Gitbook but implemented in Rust" + authors = ["Mathieu David"] + src = "./source" + [rust] + edition = "2018" + "#; + + let rust_should_be = RustConfig { + edition: Some(RustEdition::E2018), + }; + + let got = Config::from_str(src).unwrap(); + assert_eq!(got.rust, rust_should_be); + } + + #[test] + fn edition_2021() { + let src = r#" + [book] + title = "mdBook Documentation" + description = "Create book from markdown files. Like Gitbook but implemented in Rust" + authors = ["Mathieu David"] + src = "./source" + [rust] + edition = "2021" + "#; + + let rust_should_be = RustConfig { + edition: Some(RustEdition::E2021), + }; + + let got = Config::from_str(src).unwrap(); + assert_eq!(got.rust, rust_should_be); + } + + #[test] + fn load_arbitrary_output_type() { + #[derive(Debug, Deserialize, PartialEq)] + struct RandomOutput { + foo: u32, + bar: String, + baz: Vec<bool>, + } + + let src = r#" + [output.random] + foo = 5 + bar = "Hello World" + baz = [true, true, false] + "#; + + let should_be = RandomOutput { + foo: 5, + bar: String::from("Hello World"), + baz: vec![true, true, false], + }; + + let cfg = Config::from_str(src).unwrap(); + let got: RandomOutput = cfg.get_deserialized_opt("output.random").unwrap().unwrap(); + + assert_eq!(got, should_be); + + let got_baz: Vec<bool> = cfg + .get_deserialized_opt("output.random.baz") + .unwrap() + .unwrap(); + let baz_should_be = vec![true, true, false]; + + assert_eq!(got_baz, baz_should_be); + } + + #[test] + fn mutate_some_stuff() { + // really this is just a sanity check to make sure the borrow checker + // is happy... + let src = COMPLEX_CONFIG; + let mut config = Config::from_str(src).unwrap(); + let key = "output.html.playground.editable"; + + assert_eq!(config.get(key).unwrap(), &Value::Boolean(true)); + *config.get_mut(key).unwrap() = Value::Boolean(false); + assert_eq!(config.get(key).unwrap(), &Value::Boolean(false)); + } + + /// The config file format has slightly changed (metadata stuff is now under + /// the `book` table instead of being at the top level) so we're adding a + /// **temporary** compatibility check. You should be able to still load the + /// old format, emitting a warning. + #[test] + fn can_still_load_the_previous_format() { + let src = r#" + title = "mdBook Documentation" + description = "Create book from markdown files. Like Gitbook but implemented in Rust" + authors = ["Mathieu David"] + source = "./source" + + [output.html] + destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book` + theme = "my-theme" + curly-quotes = true + google-analytics = "123456" + additional-css = ["custom.css", "custom2.css"] + additional-js = ["custom.js"] + "#; + + let book_should_be = BookConfig { + title: Some(String::from("mdBook Documentation")), + description: Some(String::from( + "Create book from markdown files. Like Gitbook but implemented in Rust", + )), + authors: vec![String::from("Mathieu David")], + src: PathBuf::from("./source"), + ..Default::default() + }; + + let build_should_be = BuildConfig { + build_dir: PathBuf::from("my-book"), + create_missing: true, + use_default_preprocessors: true, + }; + + let html_should_be = HtmlConfig { + theme: Some(PathBuf::from("my-theme")), + curly_quotes: true, + google_analytics: Some(String::from("123456")), + additional_css: vec![PathBuf::from("custom.css"), PathBuf::from("custom2.css")], + additional_js: vec![PathBuf::from("custom.js")], + ..Default::default() + }; + + let got = Config::from_str(src).unwrap(); + assert_eq!(got.book, book_should_be); + assert_eq!(got.build, build_should_be); + assert_eq!(got.html_config().unwrap(), html_should_be); + } + + #[test] + fn set_a_config_item() { + let mut cfg = Config::default(); + let key = "foo.bar.baz"; + let value = "Something Interesting"; + + assert!(cfg.get(key).is_none()); + cfg.set(key, value).unwrap(); + + let got: String = cfg.get_deserialized_opt(key).unwrap().unwrap(); + assert_eq!(got, value); + } + + #[test] + fn parse_env_vars() { + let inputs = vec![ + ("FOO", None), + ("MDBOOK_foo", Some("foo")), + ("MDBOOK_FOO__bar__baz", Some("foo.bar.baz")), + ("MDBOOK_FOO_bar__baz", Some("foo-bar.baz")), + ]; + + for (src, should_be) in inputs { + let got = parse_env(src); + let should_be = should_be.map(ToString::to_string); + + assert_eq!(got, should_be); + } + } + + fn encode_env_var(key: &str) -> String { + format!( + "MDBOOK_{}", + key.to_uppercase().replace('.', "__").replace('-', "_") + ) + } + + #[test] + fn update_config_using_env_var() { + let mut cfg = Config::default(); + let key = "foo.bar"; + let value = "baz"; + + assert!(cfg.get(key).is_none()); + + let encoded_key = encode_env_var(key); + env::set_var(encoded_key, value); + + cfg.update_from_env(); + + assert_eq!( + cfg.get_deserialized_opt::<String, _>(key).unwrap().unwrap(), + value + ); + } + + #[test] + fn update_config_using_env_var_and_complex_value() { + let mut cfg = Config::default(); + let key = "foo-bar.baz"; + let value = json!({"array": [1, 2, 3], "number": 13.37}); + let value_str = serde_json::to_string(&value).unwrap(); + + assert!(cfg.get(key).is_none()); + + let encoded_key = encode_env_var(key); + env::set_var(encoded_key, value_str); + + cfg.update_from_env(); + + assert_eq!( + cfg.get_deserialized_opt::<serde_json::Value, _>(key) + .unwrap() + .unwrap(), + value + ); + } + + #[test] + fn update_book_title_via_env() { + let mut cfg = Config::default(); + let should_be = "Something else".to_string(); + + assert_ne!(cfg.book.title, Some(should_be.clone())); + + env::set_var("MDBOOK_BOOK__TITLE", &should_be); + cfg.update_from_env(); + + assert_eq!(cfg.book.title, Some(should_be)); + } + + #[test] + fn file_404_default() { + let src = r#" + [output.html] + destination = "my-book" + "#; + + let got = Config::from_str(src).unwrap(); + let html_config = got.html_config().unwrap(); + assert_eq!(html_config.input_404, None); + assert_eq!(&get_404_output_file(&html_config.input_404), "404.html"); + } + + #[test] + fn file_404_custom() { + let src = r#" + [output.html] + input-404= "missing.md" + output-404= "missing.html" + "#; + + let got = Config::from_str(src).unwrap(); + let html_config = got.html_config().unwrap(); + assert_eq!(html_config.input_404, Some("missing.md".to_string())); + assert_eq!(&get_404_output_file(&html_config.input_404), "missing.html"); + } + + #[test] + #[should_panic(expected = "Invalid configuration file")] + fn invalid_language_type_error() { + let src = r#" + [book] + title = "mdBook Documentation" + language = ["en", "pt-br"] + description = "Create book from markdown files. Like Gitbook but implemented in Rust" + authors = ["Mathieu David"] + src = "./source" + "#; + + Config::from_str(src).unwrap(); + } + + #[test] + #[should_panic(expected = "Invalid configuration file")] + fn invalid_title_type() { + let src = r#" + [book] + title = 20 + language = "en" + description = "Create book from markdown files. Like Gitbook but implemented in Rust" + authors = ["Mathieu David"] + src = "./source" + "#; + + Config::from_str(src).unwrap(); + } + + #[test] + #[should_panic(expected = "Invalid configuration file")] + fn invalid_build_dir_type() { + let src = r#" + [build] + build-dir = 99 + create-missing = false + "#; + + Config::from_str(src).unwrap(); + } + + #[test] + #[should_panic(expected = "Invalid configuration file")] + fn invalid_rust_edition() { + let src = r#" + [rust] + edition = "1999" + "#; + + Config::from_str(src).unwrap(); + } + + #[test] + fn print_config() { + let src = r#" + [output.html.print] + enable = false + "#; + let got = Config::from_str(src).unwrap(); + let html_config = got.html_config().unwrap(); + assert!(!html_config.print.enable); + assert!(html_config.print.page_break); + let src = r#" + [output.html.print] + page-break = false + "#; + let got = Config::from_str(src).unwrap(); + let html_config = got.html_config().unwrap(); + assert!(html_config.print.enable); + assert!(!html_config.print.page_break); + } +} diff --git a/vendor/mdbook/src/lib.rs b/vendor/mdbook/src/lib.rs new file mode 100644 index 000000000..cc62b0abd --- /dev/null +++ b/vendor/mdbook/src/lib.rs @@ -0,0 +1,119 @@ +//! # mdBook +//! +//! **mdBook** is a tool for rendering a collection of markdown documents into +//! a form more suitable for end users like HTML or EPUB. It offers a command +//! line interface, but this crate can be used if more control is required. +//! +//! This is the API doc, the [user guide] is also available if you want +//! information about the command line tool, format, structure etc. It is also +//! rendered with mdBook to showcase the features and default theme. +//! +//! Some reasons why you would want to use the crate (over the cli): +//! +//! - Integrate mdbook in a current project +//! - Extend the capabilities of mdBook +//! - Do some processing or test before building your book +//! - Accessing the public API to help create a new Renderer +//! - ... +//! +//! > **Note:** While we try to ensure `mdbook`'s command-line interface and +//! > behaviour are backwards compatible, the tool's internals are still +//! > evolving and being iterated on. If you wish to prevent accidental +//! > breakages it is recommended to pin any tools building on top of the +//! > `mdbook` crate to a specific release. +//! +//! # Examples +//! +//! If creating a new book from scratch, you'll want to get a `BookBuilder` via +//! the `MDBook::init()` method. +//! +//! ```rust,no_run +//! use mdbook::MDBook; +//! use mdbook::config::Config; +//! +//! let root_dir = "/path/to/book/root"; +//! +//! // create a default config and change a couple things +//! let mut cfg = Config::default(); +//! cfg.book.title = Some("My Book".to_string()); +//! cfg.book.authors.push("Michael-F-Bryan".to_string()); +//! +//! MDBook::init(root_dir) +//! .create_gitignore(true) +//! .with_config(cfg) +//! .build() +//! .expect("Book generation failed"); +//! ``` +//! +//! You can also load an existing book and build it. +//! +//! ```rust,no_run +//! use mdbook::MDBook; +//! +//! let root_dir = "/path/to/book/root"; +//! +//! let mut md = MDBook::load(root_dir) +//! .expect("Unable to load the book"); +//! md.build().expect("Building failed"); +//! ``` +//! +//! ## Implementing a new Backend +//! +//! `mdbook` has a fairly flexible mechanism for creating additional backends +//! for your book. The general idea is you'll add an extra table in the book's +//! `book.toml` which specifies an executable to be invoked by `mdbook`. This +//! executable will then be called during a build, with an in-memory +//! representation ([`RenderContext`]) of the book being passed to the +//! subprocess via `stdin`. +//! +//! The [`RenderContext`] gives the backend access to the contents of +//! `book.toml` and lets it know which directory all generated artefacts should +//! be placed in. For a much more in-depth explanation, consult the [relevant +//! chapter] in the *For Developers* section of the user guide. +//! +//! To make creating a backend easier, the `mdbook` crate can be imported +//! directly, making deserializing the `RenderContext` easy and giving you +//! access to the various methods for working with the [`Config`]. +//! +//! [user guide]: https://rust-lang.github.io/mdBook/ +//! [`RenderContext`]: renderer::RenderContext +//! [relevant chapter]: https://rust-lang.github.io/mdBook/for_developers/backends.html +//! [`Config`]: config::Config + +#![deny(missing_docs)] +#![deny(rust_2018_idioms)] + +#[macro_use] +extern crate lazy_static; +#[macro_use] +extern crate log; +#[macro_use] +extern crate serde_json; + +#[cfg(test)] +#[macro_use] +extern crate pretty_assertions; + +pub mod book; +pub mod config; +pub mod preprocess; +pub mod renderer; +pub mod theme; +pub mod utils; + +/// The current version of `mdbook`. +/// +/// This is provided as a way for custom preprocessors and renderers to do +/// compatibility checks. +pub const MDBOOK_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub use crate::book::BookItem; +pub use crate::book::MDBook; +pub use crate::config::Config; +pub use crate::renderer::Renderer; + +/// The error types used through out this crate. +pub mod errors { + pub(crate) use anyhow::{bail, ensure, Context}; + pub use anyhow::{Error, Result}; +} diff --git a/vendor/mdbook/src/main.rs b/vendor/mdbook/src/main.rs new file mode 100644 index 000000000..35562e64b --- /dev/null +++ b/vendor/mdbook/src/main.rs @@ -0,0 +1,150 @@ +#[macro_use] +extern crate clap; +#[macro_use] +extern crate log; + +use anyhow::anyhow; +use chrono::Local; +use clap::{App, AppSettings, Arg, ArgMatches}; +use clap_complete::Shell; +use env_logger::Builder; +use log::LevelFilter; +use mdbook::utils; +use std::env; +use std::ffi::OsStr; +use std::io::Write; +use std::path::{Path, PathBuf}; + +mod cmd; + +const VERSION: &str = concat!("v", crate_version!()); + +fn main() { + init_logger(); + + let app = create_clap_app(); + + // Check which subcomamnd the user ran... + let res = match app.get_matches().subcommand() { + Some(("init", sub_matches)) => cmd::init::execute(sub_matches), + Some(("build", sub_matches)) => cmd::build::execute(sub_matches), + Some(("clean", sub_matches)) => cmd::clean::execute(sub_matches), + #[cfg(feature = "watch")] + Some(("watch", sub_matches)) => cmd::watch::execute(sub_matches), + #[cfg(feature = "serve")] + Some(("serve", sub_matches)) => cmd::serve::execute(sub_matches), + Some(("test", sub_matches)) => cmd::test::execute(sub_matches), + Some(("completions", sub_matches)) => (|| { + let shell: Shell = sub_matches + .value_of("shell") + .ok_or_else(|| anyhow!("Shell name missing."))? + .parse() + .map_err(|s| anyhow!("Invalid shell: {}", s))?; + + let mut complete_app = create_clap_app(); + clap_complete::generate( + shell, + &mut complete_app, + "mdbook", + &mut std::io::stdout().lock(), + ); + Ok(()) + })(), + _ => unreachable!(), + }; + + if let Err(e) = res { + utils::log_backtrace(&e); + + std::process::exit(101); + } +} + +/// Create a list of valid arguments and sub-commands +fn create_clap_app() -> App<'static> { + let app = App::new(crate_name!()) + .about(crate_description!()) + .author("Mathieu David <mathieudavid@mathieudavid.org>") + .version(VERSION) + .setting(AppSettings::PropagateVersion) + .setting(AppSettings::ArgRequiredElseHelp) + .after_help( + "For more information about a specific command, try `mdbook <command> --help`\n\ + The source code for mdBook is available at: https://github.com/rust-lang/mdBook", + ) + .subcommand(cmd::init::make_subcommand()) + .subcommand(cmd::build::make_subcommand()) + .subcommand(cmd::test::make_subcommand()) + .subcommand(cmd::clean::make_subcommand()) + .subcommand( + App::new("completions") + .about("Generate shell completions for your shell to stdout") + .arg( + Arg::new("shell") + .takes_value(true) + .possible_values(Shell::possible_values()) + .help("the shell to generate completions for") + .value_name("SHELL") + .required(true), + ), + ); + + #[cfg(feature = "watch")] + let app = app.subcommand(cmd::watch::make_subcommand()); + #[cfg(feature = "serve")] + let app = app.subcommand(cmd::serve::make_subcommand()); + + app +} + +fn init_logger() { + let mut builder = Builder::new(); + + builder.format(|formatter, record| { + writeln!( + formatter, + "{} [{}] ({}): {}", + Local::now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + record.target(), + record.args() + ) + }); + + if let Ok(var) = env::var("RUST_LOG") { + builder.parse_filters(&var); + } else { + // if no RUST_LOG provided, default to logging at the Info level + builder.filter(None, LevelFilter::Info); + // Filter extraneous html5ever not-implemented messages + builder.filter(Some("html5ever"), LevelFilter::Error); + } + + builder.init(); +} + +fn get_book_dir(args: &ArgMatches) -> PathBuf { + if let Some(dir) = args.value_of("dir") { + // Check if path is relative from current dir, or absolute... + let p = Path::new(dir); + if p.is_relative() { + env::current_dir().unwrap().join(dir) + } else { + p.to_path_buf() + } + } else { + env::current_dir().expect("Unable to determine the current directory") + } +} + +fn open<P: AsRef<OsStr>>(path: P) { + info!("Opening web browser"); + if let Err(e) = opener::open(path) { + error!("Error opening web browser: {}", e); + } +} + +#[test] +fn verify_app() { + create_clap_app().debug_assert(); +} diff --git a/vendor/mdbook/src/preprocess/cmd.rs b/vendor/mdbook/src/preprocess/cmd.rs new file mode 100644 index 000000000..c47fd5d22 --- /dev/null +++ b/vendor/mdbook/src/preprocess/cmd.rs @@ -0,0 +1,207 @@ +use super::{Preprocessor, PreprocessorContext}; +use crate::book::Book; +use crate::errors::*; +use shlex::Shlex; +use std::io::{self, Read, Write}; +use std::process::{Child, Command, Stdio}; + +/// A custom preprocessor which will shell out to a 3rd-party program. +/// +/// # Preprocessing Protocol +/// +/// When the `supports_renderer()` method is executed, `CmdPreprocessor` will +/// execute the shell command `$cmd supports $renderer`. If the renderer is +/// supported, custom preprocessors should exit with a exit code of `0`, +/// any other exit code be considered as unsupported. +/// +/// The `run()` method is implemented by passing a `(PreprocessorContext, Book)` +/// tuple to the spawned command (`$cmd`) as JSON via `stdin`. Preprocessors +/// should then "return" a processed book by printing it to `stdout` as JSON. +/// For convenience, the `CmdPreprocessor::parse_input()` function can be used +/// to parse the input provided by `mdbook`. +/// +/// Exiting with a non-zero exit code while preprocessing is considered an +/// error. `stderr` is passed directly through to the user, so it can be used +/// for logging or emitting warnings if desired. +/// +/// # Examples +/// +/// An example preprocessor is available in this project's `examples/` +/// directory. +#[derive(Debug, Clone, PartialEq)] +pub struct CmdPreprocessor { + name: String, + cmd: String, +} + +impl CmdPreprocessor { + /// Create a new `CmdPreprocessor`. + pub fn new(name: String, cmd: String) -> CmdPreprocessor { + CmdPreprocessor { name, cmd } + } + + /// A convenience function custom preprocessors can use to parse the input + /// written to `stdin` by a `CmdRenderer`. + pub fn parse_input<R: Read>(reader: R) -> Result<(PreprocessorContext, Book)> { + serde_json::from_reader(reader).with_context(|| "Unable to parse the input") + } + + fn write_input_to_child(&self, child: &mut Child, book: &Book, ctx: &PreprocessorContext) { + let stdin = child.stdin.take().expect("Child has stdin"); + + if let Err(e) = self.write_input(stdin, book, ctx) { + // Looks like the backend hung up before we could finish + // sending it the render context. Log the error and keep going + warn!("Error writing the RenderContext to the backend, {}", e); + } + } + + fn write_input<W: Write>( + &self, + writer: W, + book: &Book, + ctx: &PreprocessorContext, + ) -> Result<()> { + serde_json::to_writer(writer, &(ctx, book)).map_err(Into::into) + } + + /// The command this `Preprocessor` will invoke. + pub fn cmd(&self) -> &str { + &self.cmd + } + + fn command(&self) -> Result<Command> { + let mut words = Shlex::new(&self.cmd); + let executable = match words.next() { + Some(e) => e, + None => bail!("Command string was empty"), + }; + + let mut cmd = Command::new(executable); + + for arg in words { + cmd.arg(arg); + } + + Ok(cmd) + } +} + +impl Preprocessor for CmdPreprocessor { + fn name(&self) -> &str { + &self.name + } + + fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book> { + let mut cmd = self.command()?; + + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .with_context(|| { + format!( + "Unable to start the \"{}\" preprocessor. Is it installed?", + self.name() + ) + })?; + + self.write_input_to_child(&mut child, &book, ctx); + + let output = child.wait_with_output().with_context(|| { + format!( + "Error waiting for the \"{}\" preprocessor to complete", + self.name + ) + })?; + + trace!("{} exited with output: {:?}", self.cmd, output); + ensure!( + output.status.success(), + format!( + "The \"{}\" preprocessor exited unsuccessfully with {} status", + self.name, output.status + ) + ); + + serde_json::from_slice(&output.stdout).with_context(|| { + format!( + "Unable to parse the preprocessed book from \"{}\" processor", + self.name + ) + }) + } + + fn supports_renderer(&self, renderer: &str) -> bool { + debug!( + "Checking if the \"{}\" preprocessor supports \"{}\"", + self.name(), + renderer + ); + + let mut cmd = match self.command() { + Ok(c) => c, + Err(e) => { + warn!( + "Unable to create the command for the \"{}\" preprocessor, {}", + self.name(), + e + ); + return false; + } + }; + + let outcome = cmd + .arg("supports") + .arg(renderer) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map(|status| status.code() == Some(0)); + + if let Err(ref e) = outcome { + if e.kind() == io::ErrorKind::NotFound { + warn!( + "The command wasn't found, is the \"{}\" preprocessor installed?", + self.name + ); + warn!("\tCommand: {}", self.cmd); + } + } + + outcome.unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MDBook; + use std::path::Path; + + fn guide() -> MDBook { + let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("guide"); + MDBook::load(example).unwrap() + } + + #[test] + fn round_trip_write_and_parse_input() { + let cmd = CmdPreprocessor::new("test".to_string(), "test".to_string()); + let md = guide(); + let ctx = PreprocessorContext::new( + md.root.clone(), + md.config.clone(), + "some-renderer".to_string(), + ); + + let mut buffer = Vec::new(); + cmd.write_input(&mut buffer, &md.book, &ctx).unwrap(); + + let (got_ctx, got_book) = CmdPreprocessor::parse_input(buffer.as_slice()).unwrap(); + + assert_eq!(got_book, md.book); + assert_eq!(got_ctx, ctx); + } +} diff --git a/vendor/mdbook/src/preprocess/index.rs b/vendor/mdbook/src/preprocess/index.rs new file mode 100644 index 000000000..fd60ad4da --- /dev/null +++ b/vendor/mdbook/src/preprocess/index.rs @@ -0,0 +1,105 @@ +use regex::Regex; +use std::path::Path; + +use crate::errors::*; + +use super::{Preprocessor, PreprocessorContext}; +use crate::book::{Book, BookItem}; + +/// A preprocessor for converting file name `README.md` to `index.md` since +/// `README.md` is the de facto index file in markdown-based documentation. +#[derive(Default)] +pub struct IndexPreprocessor; + +impl IndexPreprocessor { + pub(crate) const NAME: &'static str = "index"; + + /// Create a new `IndexPreprocessor`. + pub fn new() -> Self { + IndexPreprocessor + } +} + +impl Preprocessor for IndexPreprocessor { + fn name(&self) -> &str { + Self::NAME + } + + fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> { + let source_dir = ctx.root.join(&ctx.config.book.src); + book.for_each_mut(|section: &mut BookItem| { + if let BookItem::Chapter(ref mut ch) = *section { + if let Some(ref mut path) = ch.path { + if is_readme_file(&path) { + let mut index_md = source_dir.join(path.with_file_name("index.md")); + if index_md.exists() { + warn_readme_name_conflict(&path, &&mut index_md); + } + + path.set_file_name("index.md"); + } + } + } + }); + + Ok(book) + } +} + +fn warn_readme_name_conflict<P: AsRef<Path>>(readme_path: P, index_path: P) { + let file_name = readme_path.as_ref().file_name().unwrap_or_default(); + let parent_dir = index_path + .as_ref() + .parent() + .unwrap_or_else(|| index_path.as_ref()); + warn!( + "It seems that there are both {:?} and index.md under \"{}\".", + file_name, + parent_dir.display() + ); + warn!( + "mdbook converts {:?} into index.html by default. It may cause", + file_name + ); + warn!("unexpected behavior if putting both files under the same directory."); + warn!("To solve the warning, try to rearrange the book structure or disable"); + warn!("\"index\" preprocessor to stop the conversion."); +} + +fn is_readme_file<P: AsRef<Path>>(path: P) -> bool { + lazy_static! { + static ref RE: Regex = Regex::new(r"(?i)^readme$").unwrap(); + } + RE.is_match( + path.as_ref() + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or_default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_stem_exactly_matches_readme_case_insensitively() { + let path = "path/to/Readme.md"; + assert!(is_readme_file(path)); + + let path = "path/to/README.md"; + assert!(is_readme_file(path)); + + let path = "path/to/rEaDmE.md"; + assert!(is_readme_file(path)); + + let path = "path/to/README.markdown"; + assert!(is_readme_file(path)); + + let path = "path/to/README"; + assert!(is_readme_file(path)); + + let path = "path/to/README-README.md"; + assert!(!is_readme_file(path)); + } +} diff --git a/vendor/mdbook/src/preprocess/links.rs b/vendor/mdbook/src/preprocess/links.rs new file mode 100644 index 000000000..7ca6fd345 --- /dev/null +++ b/vendor/mdbook/src/preprocess/links.rs @@ -0,0 +1,937 @@ +use crate::errors::*; +use crate::utils::{ + take_anchored_lines, take_lines, take_rustdoc_include_anchored_lines, + take_rustdoc_include_lines, +}; +use regex::{CaptureMatches, Captures, Regex}; +use std::fs; +use std::ops::{Bound, Range, RangeBounds, RangeFrom, RangeFull, RangeTo}; +use std::path::{Path, PathBuf}; + +use super::{Preprocessor, PreprocessorContext}; +use crate::book::{Book, BookItem}; + +const ESCAPE_CHAR: char = '\\'; +const MAX_LINK_NESTED_DEPTH: usize = 10; + +/// A preprocessor for expanding helpers in a chapter. Supported helpers are: +/// +/// - `{{# include}}` - Insert an external file of any type. Include the whole file, only particular +///. lines, or only between the specified anchors. +/// - `{{# rustdoc_include}}` - Insert an external Rust file, showing the particular lines +///. specified or the lines between specified anchors, and include the rest of the file behind `#`. +/// This hides the lines from initial display but shows them when the reader expands the code +/// block and provides them to Rustdoc for testing. +/// - `{{# playground}}` - Insert runnable Rust files +/// - `{{# title}}` - Override \<title\> of a webpage. +#[derive(Default)] +pub struct LinkPreprocessor; + +impl LinkPreprocessor { + pub(crate) const NAME: &'static str = "links"; + + /// Create a new `LinkPreprocessor`. + pub fn new() -> Self { + LinkPreprocessor + } +} + +impl Preprocessor for LinkPreprocessor { + fn name(&self) -> &str { + Self::NAME + } + + fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book> { + let src_dir = ctx.root.join(&ctx.config.book.src); + + book.for_each_mut(|section: &mut BookItem| { + if let BookItem::Chapter(ref mut ch) = *section { + if let Some(ref chapter_path) = ch.path { + let base = chapter_path + .parent() + .map(|dir| src_dir.join(dir)) + .expect("All book items have a parent"); + + let mut chapter_title = ch.name.clone(); + let content = + replace_all(&ch.content, base, chapter_path, 0, &mut chapter_title); + ch.content = content; + if chapter_title != ch.name { + ctx.chapter_titles + .borrow_mut() + .insert(chapter_path.clone(), chapter_title); + } + } + } + }); + + Ok(book) + } +} + +fn replace_all<P1, P2>( + s: &str, + path: P1, + source: P2, + depth: usize, + chapter_title: &mut String, +) -> String +where + P1: AsRef<Path>, + P2: AsRef<Path>, +{ + // When replacing one thing in a string by something with a different length, + // the indices after that will not correspond, + // we therefore have to store the difference to correct this + let path = path.as_ref(); + let source = source.as_ref(); + let mut previous_end_index = 0; + let mut replaced = String::new(); + + for link in find_links(s) { + replaced.push_str(&s[previous_end_index..link.start_index]); + + match link.render_with_path(&path, chapter_title) { + Ok(new_content) => { + if depth < MAX_LINK_NESTED_DEPTH { + if let Some(rel_path) = link.link_type.relative_path(path) { + replaced.push_str(&replace_all( + &new_content, + rel_path, + source, + depth + 1, + chapter_title, + )); + } else { + replaced.push_str(&new_content); + } + } else { + error!( + "Stack depth exceeded in {}. Check for cyclic includes", + source.display() + ); + } + previous_end_index = link.end_index; + } + Err(e) => { + error!("Error updating \"{}\", {}", link.link_text, e); + for cause in e.chain().skip(1) { + warn!("Caused By: {}", cause); + } + + // This should make sure we include the raw `{{# ... }}` snippet + // in the page content if there are any errors. + previous_end_index = link.start_index; + } + } + } + + replaced.push_str(&s[previous_end_index..]); + replaced +} + +#[derive(PartialEq, Debug, Clone)] +enum LinkType<'a> { + Escaped, + Include(PathBuf, RangeOrAnchor), + Playground(PathBuf, Vec<&'a str>), + RustdocInclude(PathBuf, RangeOrAnchor), + Title(&'a str), +} + +#[derive(PartialEq, Debug, Clone)] +enum RangeOrAnchor { + Range(LineRange), + Anchor(String), +} + +// A range of lines specified with some include directive. +#[allow(clippy::enum_variant_names)] // The prefix can't be removed, and is meant to mirror the contained type +#[derive(PartialEq, Debug, Clone)] +enum LineRange { + Range(Range<usize>), + RangeFrom(RangeFrom<usize>), + RangeTo(RangeTo<usize>), + RangeFull(RangeFull), +} + +impl RangeBounds<usize> for LineRange { + fn start_bound(&self) -> Bound<&usize> { + match self { + LineRange::Range(r) => r.start_bound(), + LineRange::RangeFrom(r) => r.start_bound(), + LineRange::RangeTo(r) => r.start_bound(), + LineRange::RangeFull(r) => r.start_bound(), + } + } + + fn end_bound(&self) -> Bound<&usize> { + match self { + LineRange::Range(r) => r.end_bound(), + LineRange::RangeFrom(r) => r.end_bound(), + LineRange::RangeTo(r) => r.end_bound(), + LineRange::RangeFull(r) => r.end_bound(), + } + } +} + +impl From<Range<usize>> for LineRange { + fn from(r: Range<usize>) -> LineRange { + LineRange::Range(r) + } +} + +impl From<RangeFrom<usize>> for LineRange { + fn from(r: RangeFrom<usize>) -> LineRange { + LineRange::RangeFrom(r) + } +} + +impl From<RangeTo<usize>> for LineRange { + fn from(r: RangeTo<usize>) -> LineRange { + LineRange::RangeTo(r) + } +} + +impl From<RangeFull> for LineRange { + fn from(r: RangeFull) -> LineRange { + LineRange::RangeFull(r) + } +} + +impl<'a> LinkType<'a> { + fn relative_path<P: AsRef<Path>>(self, base: P) -> Option<PathBuf> { + let base = base.as_ref(); + match self { + LinkType::Escaped => None, + LinkType::Include(p, _) => Some(return_relative_path(base, &p)), + LinkType::Playground(p, _) => Some(return_relative_path(base, &p)), + LinkType::RustdocInclude(p, _) => Some(return_relative_path(base, &p)), + LinkType::Title(_) => None, + } + } +} +fn return_relative_path<P: AsRef<Path>>(base: P, relative: P) -> PathBuf { + base.as_ref() + .join(relative) + .parent() + .expect("Included file should not be /") + .to_path_buf() +} + +fn parse_range_or_anchor(parts: Option<&str>) -> RangeOrAnchor { + let mut parts = parts.unwrap_or("").splitn(3, ':').fuse(); + + let next_element = parts.next(); + let start = if let Some(value) = next_element.and_then(|s| s.parse::<usize>().ok()) { + // subtract 1 since line numbers usually begin with 1 + Some(value.saturating_sub(1)) + } else if let Some("") = next_element { + None + } else if let Some(anchor) = next_element { + return RangeOrAnchor::Anchor(String::from(anchor)); + } else { + None + }; + + let end = parts.next(); + // If `end` is empty string or any other value that can't be parsed as a usize, treat this + // include as a range with only a start bound. However, if end isn't specified, include only + // the single line specified by `start`. + let end = end.map(|s| s.parse::<usize>()); + + match (start, end) { + (Some(start), Some(Ok(end))) => RangeOrAnchor::Range(LineRange::from(start..end)), + (Some(start), Some(Err(_))) => RangeOrAnchor::Range(LineRange::from(start..)), + (Some(start), None) => RangeOrAnchor::Range(LineRange::from(start..start + 1)), + (None, Some(Ok(end))) => RangeOrAnchor::Range(LineRange::from(..end)), + (None, None) | (None, Some(Err(_))) => RangeOrAnchor::Range(LineRange::from(RangeFull)), + } +} + +fn parse_include_path(path: &str) -> LinkType<'static> { + let mut parts = path.splitn(2, ':'); + + let path = parts.next().unwrap().into(); + let range_or_anchor = parse_range_or_anchor(parts.next()); + + LinkType::Include(path, range_or_anchor) +} + +fn parse_rustdoc_include_path(path: &str) -> LinkType<'static> { + let mut parts = path.splitn(2, ':'); + + let path = parts.next().unwrap().into(); + let range_or_anchor = parse_range_or_anchor(parts.next()); + + LinkType::RustdocInclude(path, range_or_anchor) +} + +#[derive(PartialEq, Debug, Clone)] +struct Link<'a> { + start_index: usize, + end_index: usize, + link_type: LinkType<'a>, + link_text: &'a str, +} + +impl<'a> Link<'a> { + fn from_capture(cap: Captures<'a>) -> Option<Link<'a>> { + let link_type = match (cap.get(0), cap.get(1), cap.get(2)) { + (_, Some(typ), Some(title)) if typ.as_str() == "title" => { + Some(LinkType::Title(title.as_str())) + } + (_, Some(typ), Some(rest)) => { + let mut path_props = rest.as_str().split_whitespace(); + let file_arg = path_props.next(); + let props: Vec<&str> = path_props.collect(); + + match (typ.as_str(), file_arg) { + ("include", Some(pth)) => Some(parse_include_path(pth)), + ("playground", Some(pth)) => Some(LinkType::Playground(pth.into(), props)), + ("playpen", Some(pth)) => { + warn!( + "the {{{{#playpen}}}} expression has been \ + renamed to {{{{#playground}}}}, \ + please update your book to use the new name" + ); + Some(LinkType::Playground(pth.into(), props)) + } + ("rustdoc_include", Some(pth)) => Some(parse_rustdoc_include_path(pth)), + _ => None, + } + } + (Some(mat), None, None) if mat.as_str().starts_with(ESCAPE_CHAR) => { + Some(LinkType::Escaped) + } + _ => None, + }; + + link_type.and_then(|lnk_type| { + cap.get(0).map(|mat| Link { + start_index: mat.start(), + end_index: mat.end(), + link_type: lnk_type, + link_text: mat.as_str(), + }) + }) + } + + fn render_with_path<P: AsRef<Path>>( + &self, + base: P, + chapter_title: &mut String, + ) -> Result<String> { + let base = base.as_ref(); + match self.link_type { + // omit the escape char + LinkType::Escaped => Ok((&self.link_text[1..]).to_owned()), + LinkType::Include(ref pat, ref range_or_anchor) => { + let target = base.join(pat); + + fs::read_to_string(&target) + .map(|s| match range_or_anchor { + RangeOrAnchor::Range(range) => take_lines(&s, range.clone()), + RangeOrAnchor::Anchor(anchor) => take_anchored_lines(&s, anchor), + }) + .with_context(|| { + format!( + "Could not read file for link {} ({})", + self.link_text, + target.display(), + ) + }) + } + LinkType::RustdocInclude(ref pat, ref range_or_anchor) => { + let target = base.join(pat); + + fs::read_to_string(&target) + .map(|s| match range_or_anchor { + RangeOrAnchor::Range(range) => { + take_rustdoc_include_lines(&s, range.clone()) + } + RangeOrAnchor::Anchor(anchor) => { + take_rustdoc_include_anchored_lines(&s, anchor) + } + }) + .with_context(|| { + format!( + "Could not read file for link {} ({})", + self.link_text, + target.display(), + ) + }) + } + LinkType::Playground(ref pat, ref attrs) => { + let target = base.join(pat); + + let mut contents = fs::read_to_string(&target).with_context(|| { + format!( + "Could not read file for link {} ({})", + self.link_text, + target.display() + ) + })?; + let ftype = if !attrs.is_empty() { "rust," } else { "rust" }; + if !contents.ends_with('\n') { + contents.push('\n'); + } + Ok(format!( + "```{}{}\n{}```\n", + ftype, + attrs.join(","), + contents + )) + } + LinkType::Title(title) => { + *chapter_title = title.to_owned(); + Ok(String::new()) + } + } + } +} + +struct LinkIter<'a>(CaptureMatches<'a, 'a>); + +impl<'a> Iterator for LinkIter<'a> { + type Item = Link<'a>; + fn next(&mut self) -> Option<Link<'a>> { + for cap in &mut self.0 { + if let Some(inc) = Link::from_capture(cap) { + return Some(inc); + } + } + None + } +} + +fn find_links(contents: &str) -> LinkIter<'_> { + // lazily compute following regex + // r"\\\{\{#.*\}\}|\{\{#([a-zA-Z0-9]+)\s*([^}]+)\}\}")?; + lazy_static! { + static ref RE: Regex = Regex::new( + r"(?x) # insignificant whitespace mode + \\\{\{\#.*\}\} # match escaped link + | # or + \{\{\s* # link opening parens and whitespace + \#([a-zA-Z0-9_]+) # link type + \s+ # separating whitespace + ([^}]+) # link target path and space separated properties + \}\} # link closing parens" + ) + .unwrap(); + } + LinkIter(RE.captures_iter(contents)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_replace_all_escaped() { + let start = r" + Some text over here. + ```hbs + \{{#include file.rs}} << an escaped link! + ```"; + let end = r" + Some text over here. + ```hbs + {{#include file.rs}} << an escaped link! + ```"; + let mut chapter_title = "test_replace_all_escaped".to_owned(); + assert_eq!(replace_all(start, "", "", 0, &mut chapter_title), end); + } + + #[test] + fn test_set_chapter_title() { + let start = r"{{#title My Title}} + # My Chapter + "; + let end = r" + # My Chapter + "; + let mut chapter_title = "test_set_chapter_title".to_owned(); + assert_eq!(replace_all(start, "", "", 0, &mut chapter_title), end); + assert_eq!(chapter_title, "My Title"); + } + + #[test] + fn test_find_links_no_link() { + let s = "Some random text without link..."; + assert!(find_links(s).collect::<Vec<_>>() == vec![]); + } + + #[test] + fn test_find_links_partial_link() { + let s = "Some random text with {{#playground..."; + assert!(find_links(s).collect::<Vec<_>>() == vec![]); + let s = "Some random text with {{#include..."; + assert!(find_links(s).collect::<Vec<_>>() == vec![]); + let s = "Some random text with \\{{#include..."; + assert!(find_links(s).collect::<Vec<_>>() == vec![]); + } + + #[test] + fn test_find_links_empty_link() { + let s = "Some random text with {{#playground}} and {{#playground }} {{}} {{#}}..."; + assert!(find_links(s).collect::<Vec<_>>() == vec![]); + } + + #[test] + fn test_find_links_unknown_link_type() { + let s = "Some random text with {{#playgroundz ar.rs}} and {{#incn}} {{baz}} {{#bar}}..."; + assert!(find_links(s).collect::<Vec<_>>() == vec![]); + } + + #[test] + fn test_find_links_simple_link() { + let s = "Some random text with {{#playground file.rs}} and {{#playground test.rs }}..."; + + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + + assert_eq!( + res, + vec![ + Link { + start_index: 22, + end_index: 45, + link_type: LinkType::Playground(PathBuf::from("file.rs"), vec![]), + link_text: "{{#playground file.rs}}", + }, + Link { + start_index: 50, + end_index: 74, + link_type: LinkType::Playground(PathBuf::from("test.rs"), vec![]), + link_text: "{{#playground test.rs }}", + }, + ] + ); + } + + #[test] + fn test_find_links_with_special_characters() { + let s = "Some random text with {{#playground foo-bar\\baz/_c++.rs}}..."; + + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 57, + link_type: LinkType::Playground(PathBuf::from("foo-bar\\baz/_c++.rs"), vec![]), + link_text: "{{#playground foo-bar\\baz/_c++.rs}}", + },] + ); + } + + #[test] + fn test_find_links_with_range() { + let s = "Some random text with {{#include file.rs:10:20}}..."; + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 48, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Range(LineRange::from(9..20)) + ), + link_text: "{{#include file.rs:10:20}}", + }] + ); + } + + #[test] + fn test_find_links_with_line_number() { + let s = "Some random text with {{#include file.rs:10}}..."; + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 45, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Range(LineRange::from(9..10)) + ), + link_text: "{{#include file.rs:10}}", + }] + ); + } + + #[test] + fn test_find_links_with_from_range() { + let s = "Some random text with {{#include file.rs:10:}}..."; + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 46, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Range(LineRange::from(9..)) + ), + link_text: "{{#include file.rs:10:}}", + }] + ); + } + + #[test] + fn test_find_links_with_to_range() { + let s = "Some random text with {{#include file.rs::20}}..."; + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 46, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Range(LineRange::from(..20)) + ), + link_text: "{{#include file.rs::20}}", + }] + ); + } + + #[test] + fn test_find_links_with_full_range() { + let s = "Some random text with {{#include file.rs::}}..."; + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 44, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Range(LineRange::from(..)) + ), + link_text: "{{#include file.rs::}}", + }] + ); + } + + #[test] + fn test_find_links_with_no_range_specified() { + let s = "Some random text with {{#include file.rs}}..."; + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 42, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Range(LineRange::from(..)) + ), + link_text: "{{#include file.rs}}", + }] + ); + } + + #[test] + fn test_find_links_with_anchor() { + let s = "Some random text with {{#include file.rs:anchor}}..."; + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![Link { + start_index: 22, + end_index: 49, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Anchor(String::from("anchor")) + ), + link_text: "{{#include file.rs:anchor}}", + }] + ); + } + + #[test] + fn test_find_links_escaped_link() { + let s = "Some random text with escaped playground \\{{#playground file.rs editable}} ..."; + + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + + assert_eq!( + res, + vec![Link { + start_index: 41, + end_index: 74, + link_type: LinkType::Escaped, + link_text: "\\{{#playground file.rs editable}}", + }] + ); + } + + #[test] + fn test_find_playgrounds_with_properties() { + let s = + "Some random text with escaped playground {{#playground file.rs editable }} and some \ + more\n text {{#playground my.rs editable no_run should_panic}} ..."; + + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!( + res, + vec![ + Link { + start_index: 41, + end_index: 74, + link_type: LinkType::Playground(PathBuf::from("file.rs"), vec!["editable"]), + link_text: "{{#playground file.rs editable }}", + }, + Link { + start_index: 95, + end_index: 145, + link_type: LinkType::Playground( + PathBuf::from("my.rs"), + vec!["editable", "no_run", "should_panic"], + ), + link_text: "{{#playground my.rs editable no_run should_panic}}", + }, + ] + ); + } + + #[test] + fn test_find_all_link_types() { + let s = + "Some random text with escaped playground {{#include file.rs}} and \\{{#contents are \ + insignifficant in escaped link}} some more\n text {{#playground my.rs editable \ + no_run should_panic}} ..."; + + let res = find_links(s).collect::<Vec<_>>(); + println!("\nOUTPUT: {:?}\n", res); + assert_eq!(res.len(), 3); + assert_eq!( + res[0], + Link { + start_index: 41, + end_index: 61, + link_type: LinkType::Include( + PathBuf::from("file.rs"), + RangeOrAnchor::Range(LineRange::from(..)) + ), + link_text: "{{#include file.rs}}", + } + ); + assert_eq!( + res[1], + Link { + start_index: 66, + end_index: 115, + link_type: LinkType::Escaped, + link_text: "\\{{#contents are insignifficant in escaped link}}", + } + ); + assert_eq!( + res[2], + Link { + start_index: 133, + end_index: 183, + link_type: LinkType::Playground( + PathBuf::from("my.rs"), + vec!["editable", "no_run", "should_panic"] + ), + link_text: "{{#playground my.rs editable no_run should_panic}}", + } + ); + } + + #[test] + fn parse_without_colon_includes_all() { + let link_type = parse_include_path("arbitrary"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(RangeFull)) + ) + ); + } + + #[test] + fn parse_with_nothing_after_colon_includes_all() { + let link_type = parse_include_path("arbitrary:"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(RangeFull)) + ) + ); + } + + #[test] + fn parse_with_two_colons_includes_all() { + let link_type = parse_include_path("arbitrary::"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(RangeFull)) + ) + ); + } + + #[test] + fn parse_with_garbage_after_two_colons_includes_all() { + let link_type = parse_include_path("arbitrary::NaN"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(RangeFull)) + ) + ); + } + + #[test] + fn parse_with_one_number_after_colon_only_that_line() { + let link_type = parse_include_path("arbitrary:5"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(4..5)) + ) + ); + } + + #[test] + fn parse_with_one_based_start_becomes_zero_based() { + let link_type = parse_include_path("arbitrary:1"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(0..1)) + ) + ); + } + + #[test] + fn parse_with_zero_based_start_stays_zero_based_but_is_probably_an_error() { + let link_type = parse_include_path("arbitrary:0"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(0..1)) + ) + ); + } + + #[test] + fn parse_start_only_range() { + let link_type = parse_include_path("arbitrary:5:"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(4..)) + ) + ); + } + + #[test] + fn parse_start_with_garbage_interpreted_as_start_only_range() { + let link_type = parse_include_path("arbitrary:5:NaN"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(4..)) + ) + ); + } + + #[test] + fn parse_end_only_range() { + let link_type = parse_include_path("arbitrary::5"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(..5)) + ) + ); + } + + #[test] + fn parse_start_and_end_range() { + let link_type = parse_include_path("arbitrary:5:10"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(4..10)) + ) + ); + } + + #[test] + fn parse_with_negative_interpreted_as_anchor() { + let link_type = parse_include_path("arbitrary:-5"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Anchor("-5".to_string()) + ) + ); + } + + #[test] + fn parse_with_floating_point_interpreted_as_anchor() { + let link_type = parse_include_path("arbitrary:-5.7"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Anchor("-5.7".to_string()) + ) + ); + } + + #[test] + fn parse_with_anchor_followed_by_colon() { + let link_type = parse_include_path("arbitrary:some-anchor:this-gets-ignored"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Anchor("some-anchor".to_string()) + ) + ); + } + + #[test] + fn parse_with_more_than_three_colons_ignores_everything_after_third_colon() { + let link_type = parse_include_path("arbitrary:5:10:17:anything:"); + assert_eq!( + link_type, + LinkType::Include( + PathBuf::from("arbitrary"), + RangeOrAnchor::Range(LineRange::from(4..10)) + ) + ); + } +} diff --git a/vendor/mdbook/src/preprocess/mod.rs b/vendor/mdbook/src/preprocess/mod.rs new file mode 100644 index 000000000..894e20035 --- /dev/null +++ b/vendor/mdbook/src/preprocess/mod.rs @@ -0,0 +1,70 @@ +//! Book preprocessing. + +pub use self::cmd::CmdPreprocessor; +pub use self::index::IndexPreprocessor; +pub use self::links::LinkPreprocessor; + +mod cmd; +mod index; +mod links; + +use crate::book::Book; +use crate::config::Config; +use crate::errors::*; + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Extra information for a `Preprocessor` to give them more context when +/// processing a book. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PreprocessorContext { + /// The location of the book directory on disk. + pub root: PathBuf, + /// The book configuration (`book.toml`). + pub config: Config, + /// The `Renderer` this preprocessor is being used with. + pub renderer: String, + /// The calling `mdbook` version. + pub mdbook_version: String, + #[serde(skip)] + pub(crate) chapter_titles: RefCell<HashMap<PathBuf, String>>, + #[serde(skip)] + __non_exhaustive: (), +} + +impl PreprocessorContext { + /// Create a new `PreprocessorContext`. + pub(crate) fn new(root: PathBuf, config: Config, renderer: String) -> Self { + PreprocessorContext { + root, + config, + renderer, + mdbook_version: crate::MDBOOK_VERSION.to_string(), + chapter_titles: RefCell::new(HashMap::new()), + __non_exhaustive: (), + } + } +} + +/// An operation which is run immediately after loading a book into memory and +/// before it gets rendered. +pub trait Preprocessor { + /// Get the `Preprocessor`'s name. + fn name(&self) -> &str; + + /// Run this `Preprocessor`, allowing it to update the book before it is + /// given to a renderer. + fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book>; + + /// A hint to `MDBook` whether this preprocessor is compatible with a + /// particular renderer. + /// + /// By default, always returns `true`. + fn supports_renderer(&self, _renderer: &str) -> bool { + true + } +} diff --git a/vendor/mdbook/src/renderer/html_handlebars/hbs_renderer.rs b/vendor/mdbook/src/renderer/html_handlebars/hbs_renderer.rs new file mode 100644 index 000000000..b933a359a --- /dev/null +++ b/vendor/mdbook/src/renderer/html_handlebars/hbs_renderer.rs @@ -0,0 +1,1106 @@ +use crate::book::{Book, BookItem}; +use crate::config::{BookConfig, Config, HtmlConfig, Playground, RustEdition}; +use crate::errors::*; +use crate::renderer::html_handlebars::helpers; +use crate::renderer::{RenderContext, Renderer}; +use crate::theme::{self, playground_editor, Theme}; +use crate::utils; + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +use crate::utils::fs::get_404_output_file; +use handlebars::Handlebars; +use regex::{Captures, Regex}; + +#[derive(Default)] +pub struct HtmlHandlebars; + +impl HtmlHandlebars { + pub fn new() -> Self { + HtmlHandlebars + } + + fn render_item( + &self, + item: &BookItem, + mut ctx: RenderItemContext<'_>, + print_content: &mut String, + ) -> Result<()> { + // FIXME: This should be made DRY-er and rely less on mutable state + + let (ch, path) = match item { + BookItem::Chapter(ch) if !ch.is_draft_chapter() => (ch, ch.path.as_ref().unwrap()), + _ => return Ok(()), + }; + + if let Some(ref edit_url_template) = ctx.html_config.edit_url_template { + let full_path = ctx.book_config.src.to_str().unwrap_or_default().to_owned() + + "/" + + ch.source_path + .clone() + .unwrap_or_default() + .to_str() + .unwrap_or_default(); + + let edit_url = edit_url_template.replace("{path}", &full_path); + ctx.data + .insert("git_repository_edit_url".to_owned(), json!(edit_url)); + } + + let content = ch.content.clone(); + let content = utils::render_markdown(&content, ctx.html_config.curly_quotes); + + let fixed_content = + utils::render_markdown_with_path(&ch.content, ctx.html_config.curly_quotes, Some(path)); + if !ctx.is_index && ctx.html_config.print.page_break { + // Add page break between chapters + // See https://developer.mozilla.org/en-US/docs/Web/CSS/break-before and https://developer.mozilla.org/en-US/docs/Web/CSS/page-break-before + // Add both two CSS properties because of the compatibility issue + print_content + .push_str(r#"<div style="break-before: page; page-break-before: always;"></div>"#); + } + print_content.push_str(&fixed_content); + + // Update the context with data for this file + let ctx_path = path + .to_str() + .with_context(|| "Could not convert path to str")?; + let filepath = Path::new(&ctx_path).with_extension("html"); + + // "print.html" is used for the print page. + if path == Path::new("print.md") { + bail!("{} is reserved for internal use", path.display()); + }; + + let book_title = ctx + .data + .get("book_title") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + + let title = if let Some(title) = ctx.chapter_titles.get(path) { + title.clone() + } else if book_title.is_empty() { + ch.name.clone() + } else { + ch.name.clone() + " - " + book_title + }; + + ctx.data.insert("path".to_owned(), json!(path)); + ctx.data.insert("content".to_owned(), json!(content)); + ctx.data.insert("chapter_title".to_owned(), json!(ch.name)); + ctx.data.insert("title".to_owned(), json!(title)); + ctx.data.insert( + "path_to_root".to_owned(), + json!(utils::fs::path_to_root(&path)), + ); + if let Some(ref section) = ch.number { + ctx.data + .insert("section".to_owned(), json!(section.to_string())); + } + + // Render the handlebars template with the data + debug!("Render template"); + let rendered = ctx.handlebars.render("index", &ctx.data)?; + + let rendered = self.post_process(rendered, &ctx.html_config.playground, ctx.edition); + + // Write to file + debug!("Creating {}", filepath.display()); + utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?; + + if ctx.is_index { + ctx.data.insert("path".to_owned(), json!("index.md")); + ctx.data.insert("path_to_root".to_owned(), json!("")); + ctx.data.insert("is_index".to_owned(), json!(true)); + let rendered_index = ctx.handlebars.render("index", &ctx.data)?; + let rendered_index = + self.post_process(rendered_index, &ctx.html_config.playground, ctx.edition); + debug!("Creating index.html from {}", ctx_path); + utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?; + } + + Ok(()) + } + + fn render_404( + &self, + ctx: &RenderContext, + html_config: &HtmlConfig, + src_dir: &Path, + handlebars: &mut Handlebars<'_>, + data: &mut serde_json::Map<String, serde_json::Value>, + ) -> Result<()> { + let destination = &ctx.destination; + let content_404 = if let Some(ref filename) = html_config.input_404 { + let path = src_dir.join(filename); + std::fs::read_to_string(&path) + .with_context(|| format!("unable to open 404 input file {:?}", path))? + } else { + // 404 input not explicitly configured try the default file 404.md + let default_404_location = src_dir.join("404.md"); + if default_404_location.exists() { + std::fs::read_to_string(&default_404_location).with_context(|| { + format!("unable to open 404 input file {:?}", default_404_location) + })? + } else { + "# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \ + navigation bar or search to continue." + .to_string() + } + }; + let html_content_404 = utils::render_markdown(&content_404, html_config.curly_quotes); + + let mut data_404 = data.clone(); + let base_url = if let Some(site_url) = &html_config.site_url { + site_url + } else { + debug!( + "HTML 'site-url' parameter not set, defaulting to '/'. Please configure \ + this to ensure the 404 page work correctly, especially if your site is hosted in a \ + subdirectory on the HTTP server." + ); + "/" + }; + data_404.insert("base_url".to_owned(), json!(base_url)); + // Set a dummy path to ensure other paths (e.g. in the TOC) are generated correctly + data_404.insert("path".to_owned(), json!("404.md")); + data_404.insert("content".to_owned(), json!(html_content_404)); + + let mut title = String::from("Page not found"); + if let Some(book_title) = &ctx.config.book.title { + title.push_str(" - "); + title.push_str(book_title); + } + data_404.insert("title".to_owned(), json!(title)); + let rendered = handlebars.render("index", &data_404)?; + + let rendered = + self.post_process(rendered, &html_config.playground, ctx.config.rust.edition); + let output_file = get_404_output_file(&html_config.input_404); + utils::fs::write_file(destination, output_file, rendered.as_bytes())?; + debug!("Creating 404.html ✓"); + Ok(()) + } + + #[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))] + fn post_process( + &self, + rendered: String, + playground_config: &Playground, + edition: Option<RustEdition>, + ) -> String { + let rendered = build_header_links(&rendered); + let rendered = fix_code_blocks(&rendered); + let rendered = add_playground_pre(&rendered, playground_config, edition); + + rendered + } + + fn copy_static_files( + &self, + destination: &Path, + theme: &Theme, + html_config: &HtmlConfig, + ) -> Result<()> { + use crate::utils::fs::write_file; + + write_file( + destination, + ".nojekyll", + b"This file makes sure that Github Pages doesn't process mdBook's output.\n", + )?; + + if let Some(cname) = &html_config.cname { + write_file(destination, "CNAME", format!("{}\n", cname).as_bytes())?; + } + + write_file(destination, "book.js", &theme.js)?; + write_file(destination, "css/general.css", &theme.general_css)?; + write_file(destination, "css/chrome.css", &theme.chrome_css)?; + if html_config.print.enable { + write_file(destination, "css/print.css", &theme.print_css)?; + } + write_file(destination, "css/variables.css", &theme.variables_css)?; + if let Some(contents) = &theme.favicon_png { + write_file(destination, "favicon.png", contents)?; + } + if let Some(contents) = &theme.favicon_svg { + write_file(destination, "favicon.svg", contents)?; + } + write_file(destination, "highlight.css", &theme.highlight_css)?; + write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?; + write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?; + write_file(destination, "highlight.js", &theme.highlight_js)?; + write_file(destination, "clipboard.min.js", &theme.clipboard_js)?; + write_file( + destination, + "FontAwesome/css/font-awesome.css", + theme::FONT_AWESOME, + )?; + write_file( + destination, + "FontAwesome/fonts/fontawesome-webfont.eot", + theme::FONT_AWESOME_EOT, + )?; + write_file( + destination, + "FontAwesome/fonts/fontawesome-webfont.svg", + theme::FONT_AWESOME_SVG, + )?; + write_file( + destination, + "FontAwesome/fonts/fontawesome-webfont.ttf", + theme::FONT_AWESOME_TTF, + )?; + write_file( + destination, + "FontAwesome/fonts/fontawesome-webfont.woff", + theme::FONT_AWESOME_WOFF, + )?; + write_file( + destination, + "FontAwesome/fonts/fontawesome-webfont.woff2", + theme::FONT_AWESOME_WOFF2, + )?; + write_file( + destination, + "FontAwesome/fonts/FontAwesome.ttf", + theme::FONT_AWESOME_TTF, + )?; + if html_config.copy_fonts { + write_file(destination, "fonts/fonts.css", theme::fonts::CSS)?; + for (file_name, contents) in theme::fonts::LICENSES.iter() { + write_file(destination, file_name, contents)?; + } + for (file_name, contents) in theme::fonts::OPEN_SANS.iter() { + write_file(destination, file_name, contents)?; + } + write_file( + destination, + theme::fonts::SOURCE_CODE_PRO.0, + theme::fonts::SOURCE_CODE_PRO.1, + )?; + } + + let playground_config = &html_config.playground; + + // Ace is a very large dependency, so only load it when requested + if playground_config.editable && playground_config.copy_js { + // Load the editor + write_file(destination, "editor.js", playground_editor::JS)?; + write_file(destination, "ace.js", playground_editor::ACE_JS)?; + write_file(destination, "mode-rust.js", playground_editor::MODE_RUST_JS)?; + write_file( + destination, + "theme-dawn.js", + playground_editor::THEME_DAWN_JS, + )?; + write_file( + destination, + "theme-tomorrow_night.js", + playground_editor::THEME_TOMORROW_NIGHT_JS, + )?; + } + + Ok(()) + } + + /// Update the context with data for this file + fn configure_print_version( + &self, + data: &mut serde_json::Map<String, serde_json::Value>, + print_content: &str, + ) { + // Make sure that the Print chapter does not display the title from + // the last rendered chapter by removing it from its context + data.remove("title"); + data.insert("is_print".to_owned(), json!(true)); + data.insert("path".to_owned(), json!("print.md")); + data.insert("content".to_owned(), json!(print_content)); + data.insert( + "path_to_root".to_owned(), + json!(utils::fs::path_to_root(Path::new("print.md"))), + ); + } + + fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) { + handlebars.register_helper( + "toc", + Box::new(helpers::toc::RenderToc { + no_section_label: html_config.no_section_label, + }), + ); + handlebars.register_helper("previous", Box::new(helpers::navigation::previous)); + handlebars.register_helper("next", Box::new(helpers::navigation::next)); + handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option)); + } + + /// Copy across any additional CSS and JavaScript files which the book + /// has been configured to use. + fn copy_additional_css_and_js( + &self, + html: &HtmlConfig, + root: &Path, + destination: &Path, + ) -> Result<()> { + let custom_files = html.additional_css.iter().chain(html.additional_js.iter()); + + debug!("Copying additional CSS and JS"); + + for custom_file in custom_files { + let input_location = root.join(custom_file); + let output_location = destination.join(custom_file); + if let Some(parent) = output_location.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Unable to create {}", parent.display()))?; + } + debug!( + "Copying {} -> {}", + input_location.display(), + output_location.display() + ); + + fs::copy(&input_location, &output_location).with_context(|| { + format!( + "Unable to copy {} to {}", + input_location.display(), + output_location.display() + ) + })?; + } + + Ok(()) + } + + fn emit_redirects( + &self, + root: &Path, + handlebars: &Handlebars<'_>, + redirects: &HashMap<String, String>, + ) -> Result<()> { + if redirects.is_empty() { + return Ok(()); + } + + log::debug!("Emitting redirects"); + + for (original, new) in redirects { + log::debug!("Redirecting \"{}\" → \"{}\"", original, new); + // Note: all paths are relative to the build directory, so the + // leading slash in an absolute path means nothing (and would mess + // up `root.join(original)`). + let original = original.trim_start_matches('/'); + let filename = root.join(original); + self.emit_redirect(handlebars, &filename, new)?; + } + + Ok(()) + } + + fn emit_redirect( + &self, + handlebars: &Handlebars<'_>, + original: &Path, + destination: &str, + ) -> Result<()> { + if original.exists() { + // sanity check to avoid accidentally overwriting a real file. + let msg = format!( + "Not redirecting \"{}\" to \"{}\" because it already exists. Are you sure it needs to be redirected?", + original.display(), + destination, + ); + return Err(Error::msg(msg)); + } + + if let Some(parent) = original.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Unable to ensure \"{}\" exists", parent.display()))?; + } + + let ctx = json!({ + "url": destination, + }); + let f = File::create(original)?; + handlebars + .render_to_write("redirect", &ctx, f) + .with_context(|| { + format!( + "Unable to create a redirect file at \"{}\"", + original.display() + ) + })?; + + Ok(()) + } +} + +// TODO(mattico): Remove some time after the 0.1.8 release +fn maybe_wrong_theme_dir(dir: &Path) -> Result<bool> { + fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result<bool> { + Ok(entry.file_type()?.is_file() + && entry.path().extension().map_or(false, |ext| ext == "md")) + } + + if dir.is_dir() { + for entry in fs::read_dir(dir)? { + if entry_is_maybe_book_file(entry?).unwrap_or(false) { + return Ok(false); + } + } + Ok(true) + } else { + Ok(false) + } +} + +impl Renderer for HtmlHandlebars { + fn name(&self) -> &str { + "html" + } + + fn render(&self, ctx: &RenderContext) -> Result<()> { + let book_config = &ctx.config.book; + let html_config = ctx.config.html_config().unwrap_or_default(); + let src_dir = ctx.root.join(&ctx.config.book.src); + let destination = &ctx.destination; + let book = &ctx.book; + let build_dir = ctx.root.join(&ctx.config.build.build_dir); + + if destination.exists() { + utils::fs::remove_dir_content(destination) + .with_context(|| "Unable to remove stale HTML output")?; + } + + trace!("render"); + let mut handlebars = Handlebars::new(); + + let theme_dir = match html_config.theme { + Some(ref theme) => { + let dir = ctx.root.join(theme); + if !dir.is_dir() { + bail!("theme dir {} does not exist", dir.display()); + } + dir + } + None => ctx.root.join("theme"), + }; + + if html_config.theme.is_none() + && maybe_wrong_theme_dir(&src_dir.join("theme")).unwrap_or(false) + { + warn!( + "Previous versions of mdBook erroneously accepted `./src/theme` as an automatic \ + theme directory" + ); + warn!("Please move your theme files to `./theme` for them to continue being used"); + } + + let theme = theme::Theme::new(theme_dir); + + debug!("Register the index handlebars template"); + handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?; + + debug!("Register the head handlebars template"); + handlebars.register_partial("head", String::from_utf8(theme.head.clone())?)?; + + debug!("Register the redirect handlebars template"); + handlebars + .register_template_string("redirect", String::from_utf8(theme.redirect.clone())?)?; + + debug!("Register the header handlebars template"); + handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?; + + debug!("Register handlebars helpers"); + self.register_hbs_helpers(&mut handlebars, &html_config); + + let mut data = make_data(&ctx.root, book, &ctx.config, &html_config, &theme)?; + + // Print version + let mut print_content = String::new(); + + fs::create_dir_all(&destination) + .with_context(|| "Unexpected error when constructing destination path")?; + + let mut is_index = true; + for item in book.iter() { + let ctx = RenderItemContext { + handlebars: &handlebars, + destination: destination.to_path_buf(), + data: data.clone(), + is_index, + book_config: book_config.clone(), + html_config: html_config.clone(), + edition: ctx.config.rust.edition, + chapter_titles: &ctx.chapter_titles, + }; + self.render_item(item, ctx, &mut print_content)?; + // Only the first non-draft chapter item should be treated as the "index" + is_index &= !matches!(item, BookItem::Chapter(ch) if !ch.is_draft_chapter()); + } + + // Render 404 page + if html_config.input_404 != Some("".to_string()) { + self.render_404(ctx, &html_config, &src_dir, &mut handlebars, &mut data)?; + } + + // Print version + self.configure_print_version(&mut data, &print_content); + if let Some(ref title) = ctx.config.book.title { + data.insert("title".to_owned(), json!(title)); + } + + // Render the handlebars template with the data + if html_config.print.enable { + debug!("Render template"); + let rendered = handlebars.render("index", &data)?; + + let rendered = + self.post_process(rendered, &html_config.playground, ctx.config.rust.edition); + + utils::fs::write_file(destination, "print.html", rendered.as_bytes())?; + debug!("Creating print.html ✓"); + } + + debug!("Copy static files"); + self.copy_static_files(destination, &theme, &html_config) + .with_context(|| "Unable to copy across static files")?; + self.copy_additional_css_and_js(&html_config, &ctx.root, destination) + .with_context(|| "Unable to copy across additional CSS and JS")?; + + // Render search index + #[cfg(feature = "search")] + { + let search = html_config.search.unwrap_or_default(); + if search.enable { + super::search::create_files(&search, destination, book)?; + } + } + + self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect) + .context("Unable to emit redirects")?; + + // Copy all remaining files, avoid a recursive copy from/to the book build dir + utils::fs::copy_files_except_ext(&src_dir, destination, true, Some(&build_dir), &["md"])?; + + Ok(()) + } +} + +fn make_data( + root: &Path, + book: &Book, + config: &Config, + html_config: &HtmlConfig, + theme: &Theme, +) -> Result<serde_json::Map<String, serde_json::Value>> { + trace!("make_data"); + + let mut data = serde_json::Map::new(); + data.insert( + "language".to_owned(), + json!(config.book.language.clone().unwrap_or_default()), + ); + data.insert( + "book_title".to_owned(), + json!(config.book.title.clone().unwrap_or_default()), + ); + data.insert( + "description".to_owned(), + json!(config.book.description.clone().unwrap_or_default()), + ); + if theme.favicon_png.is_some() { + data.insert("favicon_png".to_owned(), json!("favicon.png")); + } + if theme.favicon_svg.is_some() { + data.insert("favicon_svg".to_owned(), json!("favicon.svg")); + } + if let Some(ref live_reload_endpoint) = html_config.live_reload_endpoint { + data.insert( + "live_reload_endpoint".to_owned(), + json!(live_reload_endpoint), + ); + } + + let default_theme = match html_config.default_theme { + Some(ref theme) => theme.to_lowercase(), + None => "light".to_string(), + }; + data.insert("default_theme".to_owned(), json!(default_theme)); + + let preferred_dark_theme = match html_config.preferred_dark_theme { + Some(ref theme) => theme.to_lowercase(), + None => "navy".to_string(), + }; + data.insert( + "preferred_dark_theme".to_owned(), + json!(preferred_dark_theme), + ); + + // Add google analytics tag + if let Some(ref ga) = html_config.google_analytics { + data.insert("google_analytics".to_owned(), json!(ga)); + } + + if html_config.mathjax_support { + data.insert("mathjax_support".to_owned(), json!(true)); + } + + if html_config.copy_fonts { + data.insert("copy_fonts".to_owned(), json!(true)); + } + + // Add check to see if there is an additional style + if !html_config.additional_css.is_empty() { + let mut css = Vec::new(); + for style in &html_config.additional_css { + match style.strip_prefix(root) { + Ok(p) => css.push(p.to_str().expect("Could not convert to str")), + Err(_) => css.push(style.to_str().expect("Could not convert to str")), + } + } + data.insert("additional_css".to_owned(), json!(css)); + } + + // Add check to see if there is an additional script + if !html_config.additional_js.is_empty() { + let mut js = Vec::new(); + for script in &html_config.additional_js { + match script.strip_prefix(root) { + Ok(p) => js.push(p.to_str().expect("Could not convert to str")), + Err(_) => js.push(script.to_str().expect("Could not convert to str")), + } + } + data.insert("additional_js".to_owned(), json!(js)); + } + + if html_config.playground.editable && html_config.playground.copy_js { + data.insert("playground_js".to_owned(), json!(true)); + if html_config.playground.line_numbers { + data.insert("playground_line_numbers".to_owned(), json!(true)); + } + } + if html_config.playground.copyable { + data.insert("playground_copyable".to_owned(), json!(true)); + } + + data.insert("print_enable".to_owned(), json!(html_config.print.enable)); + data.insert("fold_enable".to_owned(), json!(html_config.fold.enable)); + data.insert("fold_level".to_owned(), json!(html_config.fold.level)); + + let search = html_config.search.clone(); + if cfg!(feature = "search") { + let search = search.unwrap_or_default(); + data.insert("search_enabled".to_owned(), json!(search.enable)); + data.insert( + "search_js".to_owned(), + json!(search.enable && search.copy_js), + ); + } else if search.is_some() { + warn!("mdBook compiled without search support, ignoring `output.html.search` table"); + warn!( + "please reinstall with `cargo install mdbook --force --features search`to use the \ + search feature" + ) + } + + if let Some(ref git_repository_url) = html_config.git_repository_url { + data.insert("git_repository_url".to_owned(), json!(git_repository_url)); + } + + let git_repository_icon = match html_config.git_repository_icon { + Some(ref git_repository_icon) => git_repository_icon, + None => "fa-github", + }; + data.insert("git_repository_icon".to_owned(), json!(git_repository_icon)); + + let mut chapters = vec![]; + + for item in book.iter() { + // Create the data to inject in the template + 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())); + } + + chapter.insert( + "has_sub_items".to_owned(), + json!((!ch.sub_items.is_empty()).to_string()), + ); + + chapter.insert("name".to_owned(), json!(ch.name)); + if let Some(ref path) = ch.path { + let p = path + .to_str() + .with_context(|| "Could not convert path to str")?; + chapter.insert("path".to_owned(), json!(p)); + } + } + BookItem::Separator => { + chapter.insert("spacer".to_owned(), json!("_spacer_")); + } + } + + chapters.push(chapter); + } + + data.insert("chapters".to_owned(), json!(chapters)); + + debug!("[*]: JSON constructed"); + Ok(data) +} + +/// Goes through the rendered HTML, making sure all header tags have +/// an anchor respectively so people can link to sections directly. +fn build_header_links(html: &str) -> String { + lazy_static! { + static ref BUILD_HEADER_LINKS: Regex = Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap(); + } + + let mut id_counter = HashMap::new(); + + BUILD_HEADER_LINKS + .replace_all(html, |caps: &Captures<'_>| { + let level = caps[1] + .parse() + .expect("Regex should ensure we only ever get numbers here"); + + insert_link_into_header(level, &caps[2], &mut id_counter) + }) + .into_owned() +} + +/// Insert a sinle link into a header, making sure each link gets its own +/// unique ID by appending an auto-incremented number (if necessary). +fn insert_link_into_header( + level: usize, + content: &str, + id_counter: &mut HashMap<String, usize>, +) -> String { + let id = utils::unique_id_from_content(content, id_counter); + + format!( + r##"<h{level} id="{id}"><a class="header" href="#{id}">{text}</a></h{level}>"##, + level = level, + id = id, + text = content + ) +} + +// The rust book uses annotations for rustdoc to test code snippets, +// like the following: +// ```rust,should_panic +// fn main() { +// // Code here +// } +// ``` +// This function replaces all commas by spaces in the code block classes +fn fix_code_blocks(html: &str) -> String { + lazy_static! { + static ref FIX_CODE_BLOCKS: Regex = + Regex::new(r##"<code([^>]+)class="([^"]+)"([^>]*)>"##).unwrap(); + } + + FIX_CODE_BLOCKS + .replace_all(html, |caps: &Captures<'_>| { + let before = &caps[1]; + let classes = &caps[2].replace(',', " "); + let after = &caps[3]; + + format!( + r#"<code{before}class="{classes}"{after}>"#, + before = before, + classes = classes, + after = after + ) + }) + .into_owned() +} + +fn add_playground_pre( + html: &str, + playground_config: &Playground, + edition: Option<RustEdition>, +) -> String { + lazy_static! { + static ref ADD_PLAYGROUND_PRE: Regex = + Regex::new(r##"((?s)<code[^>]?class="([^"]+)".*?>(.*?)</code>)"##).unwrap(); + } + ADD_PLAYGROUND_PRE + .replace_all(html, |caps: &Captures<'_>| { + let text = &caps[1]; + let classes = &caps[2]; + let code = &caps[3]; + + if classes.contains("language-rust") { + if (!classes.contains("ignore") + && !classes.contains("noplayground") + && !classes.contains("noplaypen") + && playground_config.runnable) + || classes.contains("mdbook-runnable") + { + let contains_e2015 = classes.contains("edition2015"); + let contains_e2018 = classes.contains("edition2018"); + let contains_e2021 = classes.contains("edition2021"); + let edition_class = if contains_e2015 || contains_e2018 || contains_e2021 { + // the user forced edition, we should not overwrite it + "" + } else { + match edition { + Some(RustEdition::E2015) => " edition2015", + Some(RustEdition::E2018) => " edition2018", + Some(RustEdition::E2021) => " edition2021", + None => "", + } + }; + + // wrap the contents in an external pre block + format!( + "<pre class=\"playground\"><code class=\"{}{}\">{}</code></pre>", + classes, + edition_class, + { + let content: Cow<'_, str> = if playground_config.editable + && classes.contains("editable") + || text.contains("fn main") + || text.contains("quick_main!") + { + code.into() + } else { + // we need to inject our own main + let (attrs, code) = partition_source(code); + + format!("# #![allow(unused)]\n{}#fn main() {{\n{}#}}", attrs, code) + .into() + }; + hide_lines(&content) + } + ) + } else { + format!("<code class=\"{}\">{}</code>", classes, hide_lines(code)) + } + } else { + // not language-rust, so no-op + text.to_owned() + } + }) + .into_owned() +} + +fn hide_lines(content: &str) -> String { + lazy_static! { + static ref BORING_LINES_REGEX: Regex = Regex::new(r"^(\s*)#(.?)(.*)$").unwrap(); + } + + let mut result = String::with_capacity(content.len()); + for line in content.lines() { + if let Some(caps) = BORING_LINES_REGEX.captures(line) { + if &caps[2] == "#" { + result += &caps[1]; + result += &caps[2]; + result += &caps[3]; + result += "\n"; + continue; + } else if &caps[2] != "!" && &caps[2] != "[" { + result += "<span class=\"boring\">"; + result += &caps[1]; + if &caps[2] != " " { + result += &caps[2]; + } + result += &caps[3]; + result += "\n"; + result += "</span>"; + continue; + } + } + result += line; + result += "\n"; + } + result +} + +fn partition_source(s: &str) -> (String, String) { + let mut after_header = false; + let mut before = String::new(); + let mut after = String::new(); + + for line in s.lines() { + let trimline = line.trim(); + let header = trimline.chars().all(char::is_whitespace) || trimline.starts_with("#!["); + if !header || after_header { + after_header = true; + after.push_str(line); + after.push('\n'); + } else { + before.push_str(line); + before.push('\n'); + } + } + + (before, after) +} + +struct RenderItemContext<'a> { + handlebars: &'a Handlebars<'a>, + destination: PathBuf, + data: serde_json::Map<String, serde_json::Value>, + is_index: bool, + book_config: BookConfig, + html_config: HtmlConfig, + edition: Option<RustEdition>, + chapter_titles: &'a HashMap<PathBuf, String>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn original_build_header_links() { + let inputs = vec![ + ( + "blah blah <h1>Foo</h1>", + r##"blah blah <h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##, + ), + ( + "<h1>Foo</h1>", + r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##, + ), + ( + "<h3>Foo^bar</h3>", + r##"<h3 id="foobar"><a class="header" href="#foobar">Foo^bar</a></h3>"##, + ), + ( + "<h4></h4>", + r##"<h4 id=""><a class="header" href="#"></a></h4>"##, + ), + ( + "<h4><em>Hï</em></h4>", + r##"<h4 id="hï"><a class="header" href="#hï"><em>Hï</em></a></h4>"##, + ), + ( + "<h1>Foo</h1><h3>Foo</h3>", + r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1><h3 id="foo-1"><a class="header" href="#foo-1">Foo</a></h3>"##, + ), + ]; + + for (src, should_be) in inputs { + let got = build_header_links(src); + assert_eq!(got, should_be); + } + } + + #[test] + fn add_playground() { + let inputs = [ + ("<code class=\"language-rust\">x()</code>", + "<pre class=\"playground\"><code class=\"language-rust\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"), + ("<code class=\"language-rust\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust\">fn main() {}\n</code></pre>"), + ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code>", + "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code></pre>"), + ("<code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code>", + "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";\n</code></pre>"), + ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code>", + "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span><span class=\"boring\">\n</span>\";\n</code></pre>"), + ("<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>", + "<code class=\"language-rust ignore\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code>"), + ("<code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code>", + "<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]\n</code></pre>"), + ]; + for (src, should_be) in &inputs { + let got = add_playground_pre( + src, + &Playground { + editable: true, + ..Playground::default() + }, + None, + ); + assert_eq!(&*got, *should_be); + } + } + #[test] + fn add_playground_edition2015() { + let inputs = [ + ("<code class=\"language-rust\">x()</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2015\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"), + ("<code class=\"language-rust\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"), + ("<code class=\"language-rust edition2015\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"), + ("<code class=\"language-rust edition2018\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"), + ]; + for (src, should_be) in &inputs { + let got = add_playground_pre( + src, + &Playground { + editable: true, + ..Playground::default() + }, + Some(RustEdition::E2015), + ); + assert_eq!(&*got, *should_be); + } + } + #[test] + fn add_playground_edition2018() { + let inputs = [ + ("<code class=\"language-rust\">x()</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2018\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"), + ("<code class=\"language-rust\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"), + ("<code class=\"language-rust edition2015\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"), + ("<code class=\"language-rust edition2018\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"), + ]; + for (src, should_be) in &inputs { + let got = add_playground_pre( + src, + &Playground { + editable: true, + ..Playground::default() + }, + Some(RustEdition::E2018), + ); + assert_eq!(&*got, *should_be); + } + } + #[test] + fn add_playground_edition2021() { + let inputs = [ + ("<code class=\"language-rust\">x()</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2021\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"), + ("<code class=\"language-rust\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2021\">fn main() {}\n</code></pre>"), + ("<code class=\"language-rust edition2015\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}\n</code></pre>"), + ("<code class=\"language-rust edition2018\">fn main() {}</code>", + "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}\n</code></pre>"), + ]; + for (src, should_be) in &inputs { + let got = add_playground_pre( + src, + &Playground { + editable: true, + ..Playground::default() + }, + Some(RustEdition::E2021), + ); + assert_eq!(&*got, *should_be); + } + } +} diff --git a/vendor/mdbook/src/renderer/html_handlebars/helpers/mod.rs b/vendor/mdbook/src/renderer/html_handlebars/helpers/mod.rs new file mode 100644 index 000000000..52be6d204 --- /dev/null +++ b/vendor/mdbook/src/renderer/html_handlebars/helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod navigation; +pub mod theme; +pub mod toc; diff --git a/vendor/mdbook/src/renderer/html_handlebars/helpers/navigation.rs b/vendor/mdbook/src/renderer/html_handlebars/helpers/navigation.rs new file mode 100644 index 000000000..65929bbfc --- /dev/null +++ b/vendor/mdbook/src/renderer/html_handlebars/helpers/navigation.rs @@ -0,0 +1,290 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use handlebars::{Context, Handlebars, Helper, Output, RenderContext, RenderError, Renderable}; + +use crate::utils; + +type StringMap = BTreeMap<String, String>; + +/// Target for `find_chapter`. +enum Target { + Previous, + Next, +} + +impl Target { + /// Returns target if found. + fn find( + &self, + base_path: &str, + current_path: &str, + current_item: &StringMap, + previous_item: &StringMap, + ) -> Result<Option<StringMap>, RenderError> { + match *self { + Target::Next => { + let previous_path = previous_item + .get("path") + .ok_or_else(|| RenderError::new("No path found for chapter in JSON data"))?; + + if previous_path == base_path { + return Ok(Some(current_item.clone())); + } + } + + Target::Previous => { + if current_path == base_path { + return Ok(Some(previous_item.clone())); + } + } + } + + Ok(None) + } +} + +fn find_chapter( + ctx: &Context, + rc: &mut RenderContext<'_, '_>, + target: Target, +) -> Result<Option<StringMap>, RenderError> { + debug!("Get data from context"); + + let chapters = rc.evaluate(ctx, "@root/chapters").and_then(|c| { + serde_json::value::from_value::<Vec<StringMap>>(c.as_json().clone()) + .map_err(|_| RenderError::new("Could not decode the JSON data")) + })?; + + let base_path = rc + .evaluate(ctx, "@root/path")? + .as_json() + .as_str() + .ok_or_else(|| RenderError::new("Type error for `path`, string expected"))? + .replace('\"', ""); + + if !rc.evaluate(ctx, "@root/is_index")?.is_missing() { + // Special case for index.md which may be a synthetic page. + // Target::find won't match because there is no page with the path + // "index.md" (unless there really is an index.md in SUMMARY.md). + match target { + Target::Previous => return Ok(None), + Target::Next => match chapters + .iter() + .filter(|chapter| { + // Skip things like "spacer" + chapter.contains_key("path") + }) + .nth(1) + { + Some(chapter) => return Ok(Some(chapter.clone())), + None => return Ok(None), + }, + } + } + + let mut previous: Option<StringMap> = None; + + debug!("Search for chapter"); + + for item in chapters { + match item.get("path") { + Some(path) if !path.is_empty() => { + if let Some(previous) = previous { + if let Some(item) = target.find(&base_path, path, &item, &previous)? { + return Ok(Some(item)); + } + } + + previous = Some(item.clone()); + } + _ => continue, + } + } + + Ok(None) +} + +fn render( + _h: &Helper<'_, '_>, + r: &Handlebars<'_>, + ctx: &Context, + rc: &mut RenderContext<'_, '_>, + out: &mut dyn Output, + chapter: &StringMap, +) -> Result<(), RenderError> { + trace!("Creating BTreeMap to inject in context"); + + let mut context = BTreeMap::new(); + let base_path = rc + .evaluate(ctx, "@root/path")? + .as_json() + .as_str() + .ok_or_else(|| RenderError::new("Type error for `path`, string expected"))? + .replace('\"', ""); + + context.insert( + "path_to_root".to_owned(), + json!(utils::fs::path_to_root(&base_path)), + ); + + chapter + .get("name") + .ok_or_else(|| RenderError::new("No title found for chapter in JSON data")) + .map(|name| context.insert("title".to_owned(), json!(name)))?; + + chapter + .get("path") + .ok_or_else(|| RenderError::new("No path found for chapter in JSON data")) + .and_then(|p| { + Path::new(p) + .with_extension("html") + .to_str() + .ok_or_else(|| RenderError::new("Link could not be converted to str")) + .map(|p| context.insert("link".to_owned(), json!(p.replace('\\', "/")))) + })?; + + trace!("Render template"); + + _h.template() + .ok_or_else(|| RenderError::new("Error with the handlebars template")) + .and_then(|t| { + let local_ctx = Context::wraps(&context)?; + let mut local_rc = rc.clone(); + t.render(r, &local_ctx, &mut local_rc, out) + })?; + + Ok(()) +} + +pub fn previous( + _h: &Helper<'_, '_>, + r: &Handlebars<'_>, + ctx: &Context, + rc: &mut RenderContext<'_, '_>, + out: &mut dyn Output, +) -> Result<(), RenderError> { + trace!("previous (handlebars helper)"); + + if let Some(previous) = find_chapter(ctx, rc, Target::Previous)? { + render(_h, r, ctx, rc, out, &previous)?; + } + + Ok(()) +} + +pub fn next( + _h: &Helper<'_, '_>, + r: &Handlebars<'_>, + ctx: &Context, + rc: &mut RenderContext<'_, '_>, + out: &mut dyn Output, +) -> Result<(), RenderError> { + trace!("next (handlebars helper)"); + + if let Some(next) = find_chapter(ctx, rc, Target::Next)? { + render(_h, r, ctx, rc, out, &next)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + static TEMPLATE: &str = + "{{#previous}}{{title}}: {{link}}{{/previous}}|{{#next}}{{title}}: {{link}}{{/next}}"; + + #[test] + fn test_next_previous() { + let data = json!({ + "name": "two", + "path": "two.path", + "chapters": [ + { + "name": "one", + "path": "one.path" + }, + { + "name": "two", + "path": "two.path", + }, + { + "name": "three", + "path": "three.path" + } + ] + }); + + let mut h = Handlebars::new(); + h.register_helper("previous", Box::new(previous)); + h.register_helper("next", Box::new(next)); + + assert_eq!( + h.render_template(TEMPLATE, &data).unwrap(), + "one: one.html|three: three.html" + ); + } + + #[test] + fn test_first() { + let data = json!({ + "name": "one", + "path": "one.path", + "chapters": [ + { + "name": "one", + "path": "one.path" + }, + { + "name": "two", + "path": "two.path", + }, + { + "name": "three", + "path": "three.path" + } + ] + }); + + let mut h = Handlebars::new(); + h.register_helper("previous", Box::new(previous)); + h.register_helper("next", Box::new(next)); + + assert_eq!( + h.render_template(TEMPLATE, &data).unwrap(), + "|two: two.html" + ); + } + #[test] + fn test_last() { + let data = json!({ + "name": "three", + "path": "three.path", + "chapters": [ + { + "name": "one", + "path": "one.path" + }, + { + "name": "two", + "path": "two.path", + }, + { + "name": "three", + "path": "three.path" + } + ] + }); + + let mut h = Handlebars::new(); + h.register_helper("previous", Box::new(previous)); + h.register_helper("next", Box::new(next)); + + assert_eq!( + h.render_template(TEMPLATE, &data).unwrap(), + "two: two.html|" + ); + } +} diff --git a/vendor/mdbook/src/renderer/html_handlebars/helpers/theme.rs b/vendor/mdbook/src/renderer/html_handlebars/helpers/theme.rs new file mode 100644 index 000000000..809ee1176 --- /dev/null +++ b/vendor/mdbook/src/renderer/html_handlebars/helpers/theme.rs @@ -0,0 +1,28 @@ +use handlebars::{Context, Handlebars, Helper, Output, RenderContext, RenderError}; + +pub fn theme_option( + h: &Helper<'_, '_>, + _r: &Handlebars<'_>, + ctx: &Context, + rc: &mut RenderContext<'_, '_>, + out: &mut dyn Output, +) -> Result<(), RenderError> { + trace!("theme_option (handlebars helper)"); + + let param = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { + RenderError::new("Param 0 with String type is required for theme_option helper.") + })?; + + let default_theme = rc.evaluate(ctx, "@root/default_theme")?; + let default_theme_name = default_theme + .as_json() + .as_str() + .ok_or_else(|| RenderError::new("Type error for `default_theme`, string expected"))?; + + out.write(param)?; + if param.to_lowercase() == default_theme_name.to_lowercase() { + out.write(" (default)")?; + } + + Ok(()) +} diff --git a/vendor/mdbook/src/renderer/html_handlebars/helpers/toc.rs b/vendor/mdbook/src/renderer/html_handlebars/helpers/toc.rs new file mode 100644 index 000000000..0884d30ad --- /dev/null +++ b/vendor/mdbook/src/renderer/html_handlebars/helpers/toc.rs @@ -0,0 +1,203 @@ +use std::path::Path; +use std::{cmp::Ordering, collections::BTreeMap}; + +use crate::utils; +use crate::utils::bracket_escape; + +use handlebars::{Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError}; + +// Handlebars helper to construct TOC +#[derive(Clone, Copy)] +pub struct RenderToc { + pub no_section_label: bool, +} + +impl HelperDef for RenderToc { + fn call<'reg: 'rc, 'rc>( + &self, + _h: &Helper<'reg, 'rc>, + _r: &'reg Handlebars<'_>, + ctx: &'rc Context, + rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, + ) -> Result<(), RenderError> { + // get value from context data + // rc.get_path() is current json parent path, you should always use it like this + // param is the key of value you want to display + let chapters = rc.evaluate(ctx, "@root/chapters").and_then(|c| { + serde_json::value::from_value::<Vec<BTreeMap<String, String>>>(c.as_json().clone()) + .map_err(|_| RenderError::new("Could not decode the JSON data")) + })?; + let current_path = rc + .evaluate(ctx, "@root/path")? + .as_json() + .as_str() + .ok_or_else(|| RenderError::new("Type error for `path`, string expected"))? + .replace('\"', ""); + + let current_section = rc + .evaluate(ctx, "@root/section")? + .as_json() + .as_str() + .map(str::to_owned) + .unwrap_or_default(); + + let fold_enable = rc + .evaluate(ctx, "@root/fold_enable")? + .as_json() + .as_bool() + .ok_or_else(|| RenderError::new("Type error for `fold_enable`, bool expected"))?; + + let fold_level = rc + .evaluate(ctx, "@root/fold_level")? + .as_json() + .as_u64() + .ok_or_else(|| RenderError::new("Type error for `fold_level`, u64 expected"))?; + + out.write("<ol class=\"chapter\">")?; + + let mut current_level = 1; + // The "index" page, which has this attribute set, is supposed to alias the first chapter in + // the book, i.e. the first link. There seems to be no easy way to determine which chapter + // the "index" is aliasing from within the renderer, so this is used instead to force the + // first link to be active. See further below. + let mut is_first_chapter = ctx.data().get("is_index").is_some(); + + for item in chapters { + // Spacer + if item.get("spacer").is_some() { + out.write("<li class=\"spacer\"></li>")?; + continue; + } + + let (section, level) = if let Some(s) = item.get("section") { + (s.as_str(), s.matches('.').count()) + } else { + ("", 1) + }; + + let is_expanded = + if !fold_enable || (!section.is_empty() && current_section.starts_with(section)) { + // Expand if folding is disabled, or if the section is an + // ancestor or the current section itself. + true + } else { + // Levels that are larger than this would be folded. + level - 1 < fold_level as usize + }; + + match level.cmp(¤t_level) { + Ordering::Greater => { + while level > current_level { + out.write("<li>")?; + out.write("<ol class=\"section\">")?; + current_level += 1; + } + write_li_open_tag(out, is_expanded, false)?; + } + Ordering::Less => { + while level < current_level { + out.write("</ol>")?; + out.write("</li>")?; + current_level -= 1; + } + write_li_open_tag(out, is_expanded, false)?; + } + Ordering::Equal => { + 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\">")?; + out.write(&bracket_escape(title))?; + out.write("</li>")?; + continue; + } + + // Link + let path_exists = if let Some(path) = + item.get("path") + .and_then(|p| if p.is_empty() { None } else { Some(p) }) + { + out.write("<a href=\"")?; + + let tmp = Path::new(item.get("path").expect("Error: path should be Some(_)")) + .with_extension("html") + .to_str() + .unwrap() + // Hack for windows who tends to use `\` as separator instead of `/` + .replace('\\', "/"); + + // Add link + out.write(&utils::fs::path_to_root(¤t_path))?; + out.write(&tmp)?; + out.write("\"")?; + + if path == ¤t_path || is_first_chapter { + is_first_chapter = false; + out.write(" class=\"active\"")?; + } + + out.write(">")?; + true + } else { + out.write("<div>")?; + false + }; + + if !self.no_section_label { + // Section does not necessarily exist + if let Some(section) = item.get("section") { + out.write("<strong aria-hidden=\"true\">")?; + out.write(section)?; + out.write("</strong> ")?; + } + } + + if let Some(name) = item.get("name") { + out.write(&bracket_escape(name))? + } + + if path_exists { + out.write("</a>")?; + } else { + out.write("</div>")?; + } + + // Render expand/collapse toggle + if let Some(flag) = item.get("has_sub_items") { + let has_sub_items = flag.parse::<bool>().unwrap_or_default(); + if fold_enable && has_sub_items { + out.write("<a class=\"toggle\"><div>❱</div></a>")?; + } + } + out.write("</li>")?; + } + while current_level > 1 { + out.write("</ol>")?; + out.write("</li>")?; + current_level -= 1; + } + + out.write("</ol>")?; + Ok(()) + } +} + +fn write_li_open_tag( + out: &mut dyn Output, + is_expanded: bool, + is_affix: bool, +) -> Result<(), std::io::Error> { + let mut li = String::from("<li class=\"chapter-item "); + if is_expanded { + li.push_str("expanded "); + } + if is_affix { + li.push_str("affix "); + } + li.push_str("\">"); + out.write(&li) +} diff --git a/vendor/mdbook/src/renderer/html_handlebars/mod.rs b/vendor/mdbook/src/renderer/html_handlebars/mod.rs new file mode 100644 index 000000000..f1155ed75 --- /dev/null +++ b/vendor/mdbook/src/renderer/html_handlebars/mod.rs @@ -0,0 +1,9 @@ +#![allow(missing_docs)] // FIXME: Document this + +pub use self::hbs_renderer::HtmlHandlebars; + +mod hbs_renderer; +mod helpers; + +#[cfg(feature = "search")] +mod search; diff --git a/vendor/mdbook/src/renderer/html_handlebars/search.rs b/vendor/mdbook/src/renderer/html_handlebars/search.rs new file mode 100644 index 000000000..c3b944c9d --- /dev/null +++ b/vendor/mdbook/src/renderer/html_handlebars/search.rs @@ -0,0 +1,286 @@ +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use elasticlunr::{Index, IndexBuilder}; +use pulldown_cmark::*; + +use crate::book::{Book, BookItem}; +use crate::config::Search; +use crate::errors::*; +use crate::theme::searcher; +use crate::utils; + +use serde::Serialize; + +const MAX_WORD_LENGTH_TO_INDEX: usize = 80; + +/// Tokenizes in the same way as elasticlunr-rs (for English), but also drops long tokens. +fn tokenize(text: &str) -> Vec<String> { + text.split(|c: char| c.is_whitespace() || c == '-') + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_lowercase()) + .filter(|s| s.len() <= MAX_WORD_LENGTH_TO_INDEX) + .collect() +} + +/// Creates all files required for search. +pub fn create_files(search_config: &Search, destination: &Path, book: &Book) -> Result<()> { + let mut index = IndexBuilder::new() + .add_field_with_tokenizer("title", Box::new(&tokenize)) + .add_field_with_tokenizer("body", Box::new(&tokenize)) + .add_field_with_tokenizer("breadcrumbs", Box::new(&tokenize)) + .build(); + + let mut doc_urls = Vec::with_capacity(book.sections.len()); + + for item in book.iter() { + render_item(&mut index, search_config, &mut doc_urls, item)?; + } + + let index = write_to_json(index, search_config, doc_urls)?; + debug!("Writing search index ✓"); + if index.len() > 10_000_000 { + warn!("searchindex.json is very large ({} bytes)", index.len()); + } + + if search_config.copy_js { + utils::fs::write_file(destination, "searchindex.json", index.as_bytes())?; + utils::fs::write_file( + destination, + "searchindex.js", + format!("Object.assign(window.search, {});", index).as_bytes(), + )?; + utils::fs::write_file(destination, "searcher.js", searcher::JS)?; + utils::fs::write_file(destination, "mark.min.js", searcher::MARK_JS)?; + utils::fs::write_file(destination, "elasticlunr.min.js", searcher::ELASTICLUNR_JS)?; + debug!("Copying search files ✓"); + } + + Ok(()) +} + +/// Uses the given arguments to construct a search document, then inserts it to the given index. +fn add_doc( + index: &mut Index, + doc_urls: &mut Vec<String>, + anchor_base: &str, + section_id: &Option<String>, + items: &[&str], +) { + let url = if let Some(ref id) = *section_id { + Cow::Owned(format!("{}#{}", anchor_base, id)) + } else { + Cow::Borrowed(anchor_base) + }; + let url = utils::collapse_whitespace(url.trim()); + let doc_ref = doc_urls.len().to_string(); + doc_urls.push(url.into()); + + let items = items.iter().map(|&x| utils::collapse_whitespace(x.trim())); + index.add_doc(&doc_ref, items); +} + +/// Renders markdown into flat unformatted text and adds it to the search index. +fn render_item( + index: &mut Index, + search_config: &Search, + doc_urls: &mut Vec<String>, + item: &BookItem, +) -> Result<()> { + let chapter = match *item { + BookItem::Chapter(ref ch) if !ch.is_draft_chapter() => ch, + _ => return Ok(()), + }; + + let chapter_path = chapter + .path + .as_ref() + .expect("Checked that path exists above"); + let filepath = Path::new(&chapter_path).with_extension("html"); + let filepath = filepath + .to_str() + .with_context(|| "Could not convert HTML path to str")?; + let anchor_base = utils::fs::normalize_path(filepath); + + let mut p = utils::new_cmark_parser(&chapter.content, false).peekable(); + + let mut in_heading = false; + let max_section_depth = u32::from(search_config.heading_split_level); + let mut section_id = None; + let mut heading = String::new(); + let mut body = String::new(); + let mut breadcrumbs = chapter.parent_names.clone(); + let mut footnote_numbers = HashMap::new(); + + breadcrumbs.push(chapter.name.clone()); + + let mut id_counter = HashMap::new(); + while let Some(event) = p.next() { + match event { + Event::Start(Tag::Heading(i, ..)) if i as u32 <= max_section_depth => { + if !heading.is_empty() { + // Section finished, the next heading is following now + // Write the data to the index, and clear it for the next section + add_doc( + index, + doc_urls, + &anchor_base, + §ion_id, + &[&heading, &body, &breadcrumbs.join(" » ")], + ); + section_id = None; + heading.clear(); + body.clear(); + breadcrumbs.pop(); + } + + in_heading = true; + } + Event::End(Tag::Heading(i, ..)) if i as u32 <= max_section_depth => { + in_heading = false; + section_id = Some(utils::unique_id_from_content(&heading, &mut id_counter)); + breadcrumbs.push(heading.clone()); + } + Event::Start(Tag::FootnoteDefinition(name)) => { + let number = footnote_numbers.len() + 1; + footnote_numbers.entry(name).or_insert(number); + } + Event::Html(html) => { + let mut html_block = html.into_string(); + + // As of pulldown_cmark 0.6, html events are no longer contained + // in an HtmlBlock tag. We must collect consecutive Html events + // into a block ourselves. + while let Some(Event::Html(html)) = p.peek() { + html_block.push_str(html); + p.next(); + } + + body.push_str(&clean_html(&html_block)); + } + Event::Start(_) | Event::End(_) | Event::Rule | Event::SoftBreak | Event::HardBreak => { + // Insert spaces where HTML output would usually separate text + // to ensure words don't get merged together + if in_heading { + heading.push(' '); + } else { + body.push(' '); + } + } + Event::Text(text) | Event::Code(text) => { + if in_heading { + heading.push_str(&text); + } else { + body.push_str(&text); + } + } + Event::FootnoteReference(name) => { + let len = footnote_numbers.len() + 1; + let number = footnote_numbers.entry(name).or_insert(len); + body.push_str(&format!(" [{}] ", number)); + } + Event::TaskListMarker(_checked) => {} + } + } + + if !body.is_empty() || !heading.is_empty() { + if heading.is_empty() { + if let Some(chapter) = breadcrumbs.first() { + heading = chapter.clone(); + } + } + // Make sure the last section is added to the index + add_doc( + index, + doc_urls, + &anchor_base, + §ion_id, + &[&heading, &body, &breadcrumbs.join(" » ")], + ); + } + + Ok(()) +} + +fn write_to_json(index: Index, search_config: &Search, doc_urls: Vec<String>) -> Result<String> { + use elasticlunr::config::{SearchBool, SearchOptions, SearchOptionsField}; + use std::collections::BTreeMap; + + #[derive(Serialize)] + struct ResultsOptions { + limit_results: u32, + teaser_word_count: u32, + } + + #[derive(Serialize)] + struct SearchindexJson { + /// The options used for displaying search results + results_options: ResultsOptions, + /// The searchoptions for elasticlunr.js + search_options: SearchOptions, + /// Used to lookup a document's URL from an integer document ref. + doc_urls: Vec<String>, + /// The index for elasticlunr.js + index: elasticlunr::Index, + } + + let mut fields = BTreeMap::new(); + let mut opt = SearchOptionsField::default(); + let mut insert_boost = |key: &str, boost| { + opt.boost = Some(boost); + fields.insert(key.into(), opt); + }; + insert_boost("title", search_config.boost_title); + insert_boost("body", search_config.boost_paragraph); + insert_boost("breadcrumbs", search_config.boost_hierarchy); + + let search_options = SearchOptions { + bool: if search_config.use_boolean_and { + SearchBool::And + } else { + SearchBool::Or + }, + expand: search_config.expand, + fields, + }; + + let results_options = ResultsOptions { + limit_results: search_config.limit_results, + teaser_word_count: search_config.teaser_word_count, + }; + + let json_contents = SearchindexJson { + results_options, + search_options, + doc_urls, + index, + }; + + // By converting to serde_json::Value as an intermediary, we use a + // BTreeMap internally and can force a stable ordering of map keys. + let json_contents = serde_json::to_value(&json_contents)?; + let json_contents = serde_json::to_string(&json_contents)?; + + Ok(json_contents) +} + +fn clean_html(html: &str) -> String { + lazy_static! { + static ref AMMONIA: ammonia::Builder<'static> = { + let mut clean_content = HashSet::new(); + clean_content.insert("script"); + clean_content.insert("style"); + let mut builder = ammonia::Builder::new(); + builder + .tags(HashSet::new()) + .tag_attributes(HashMap::new()) + .generic_attributes(HashSet::new()) + .link_rel(None) + .allowed_classes(HashMap::new()) + .clean_content_tags(clean_content); + builder + }; + } + AMMONIA.clean(html).to_string() +} diff --git a/vendor/mdbook/src/renderer/markdown_renderer.rs b/vendor/mdbook/src/renderer/markdown_renderer.rs new file mode 100644 index 000000000..bd5def1f4 --- /dev/null +++ b/vendor/mdbook/src/renderer/markdown_renderer.rs @@ -0,0 +1,52 @@ +use crate::book::BookItem; +use crate::errors::*; +use crate::renderer::{RenderContext, Renderer}; +use crate::utils; + +use std::fs; + +#[derive(Default)] +/// A renderer to output the Markdown after the preprocessors have run. Mostly useful +/// when debugging preprocessors. +pub struct MarkdownRenderer; + +impl MarkdownRenderer { + /// Create a new `MarkdownRenderer` instance. + pub fn new() -> Self { + MarkdownRenderer + } +} + +impl Renderer for MarkdownRenderer { + fn name(&self) -> &str { + "markdown" + } + + fn render(&self, ctx: &RenderContext) -> Result<()> { + let destination = &ctx.destination; + let book = &ctx.book; + + if destination.exists() { + utils::fs::remove_dir_content(destination) + .with_context(|| "Unable to remove stale Markdown output")?; + } + + trace!("markdown render"); + for item in book.iter() { + if let BookItem::Chapter(ref ch) = *item { + if !ch.is_draft_chapter() { + utils::fs::write_file( + &ctx.destination, + &ch.path.as_ref().expect("Checked path exists before"), + ch.content.as_bytes(), + )?; + } + } + } + + fs::create_dir_all(&destination) + .with_context(|| "Unexpected error when constructing destination path")?; + + Ok(()) + } +} diff --git a/vendor/mdbook/src/renderer/mod.rs b/vendor/mdbook/src/renderer/mod.rs new file mode 100644 index 000000000..15465fbce --- /dev/null +++ b/vendor/mdbook/src/renderer/mod.rs @@ -0,0 +1,265 @@ +//! `mdbook`'s low level rendering interface. +//! +//! # Note +//! +//! You usually don't need to work with this module directly. If you want to +//! implement your own backend, then check out the [For Developers] section of +//! the user guide. +//! +//! The definition for [RenderContext] may be useful though. +//! +//! [For Developers]: https://rust-lang.github.io/mdBook/for_developers/index.html +//! [RenderContext]: struct.RenderContext.html + +pub use self::html_handlebars::HtmlHandlebars; +pub use self::markdown_renderer::MarkdownRenderer; + +mod html_handlebars; +mod markdown_renderer; + +use shlex::Shlex; +use std::collections::HashMap; +use std::fs; +use std::io::{self, ErrorKind, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::book::Book; +use crate::config::Config; +use crate::errors::*; +use toml::Value; + +use serde::{Deserialize, Serialize}; + +/// An arbitrary `mdbook` backend. +/// +/// Although it's quite possible for you to import `mdbook` as a library and +/// provide your own renderer, there are two main renderer implementations that +/// 99% of users will ever use: +/// +/// - [`HtmlHandlebars`] - the built-in HTML renderer +/// - [`CmdRenderer`] - a generic renderer which shells out to a program to do the +/// actual rendering +pub trait Renderer { + /// The `Renderer`'s name. + fn name(&self) -> &str; + + /// Invoke the `Renderer`, passing in all the necessary information for + /// describing a book. + fn render(&self, ctx: &RenderContext) -> Result<()>; +} + +/// The context provided to all renderers. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderContext { + /// Which version of `mdbook` did this come from (as written in `mdbook`'s + /// `Cargo.toml`). Useful if you know the renderer is only compatible with + /// certain versions of `mdbook`. + pub version: String, + /// The book's root directory. + pub root: PathBuf, + /// A loaded representation of the book itself. + pub book: Book, + /// The loaded configuration file. + pub config: Config, + /// Where the renderer *must* put any build artefacts generated. To allow + /// renderers to cache intermediate results, this directory is not + /// guaranteed to be empty or even exist. + pub destination: PathBuf, + #[serde(skip)] + pub(crate) chapter_titles: HashMap<PathBuf, String>, + #[serde(skip)] + __non_exhaustive: (), +} + +impl RenderContext { + /// Create a new `RenderContext`. + pub fn new<P, Q>(root: P, book: Book, config: Config, destination: Q) -> RenderContext + where + P: Into<PathBuf>, + Q: Into<PathBuf>, + { + RenderContext { + book, + config, + version: crate::MDBOOK_VERSION.to_string(), + root: root.into(), + destination: destination.into(), + chapter_titles: HashMap::new(), + __non_exhaustive: (), + } + } + + /// Get the source directory's (absolute) path on disk. + pub fn source_dir(&self) -> PathBuf { + self.root.join(&self.config.book.src) + } + + /// Load a `RenderContext` from its JSON representation. + pub fn from_json<R: Read>(reader: R) -> Result<RenderContext> { + serde_json::from_reader(reader).with_context(|| "Unable to deserialize the `RenderContext`") + } +} + +/// A generic renderer which will shell out to an arbitrary executable. +/// +/// # Rendering Protocol +/// +/// When the renderer's `render()` method is invoked, `CmdRenderer` will spawn +/// the `cmd` as a subprocess. The `RenderContext` is passed to the subprocess +/// as a JSON string (using `serde_json`). +/// +/// > **Note:** The command used doesn't necessarily need to be a single +/// > executable (i.e. `/path/to/renderer`). The `cmd` string lets you pass +/// > in command line arguments, so there's no reason why it couldn't be +/// > `python /path/to/renderer --from mdbook --to epub`. +/// +/// Anything the subprocess writes to `stdin` or `stdout` will be passed through +/// to the user. While this gives the renderer maximum flexibility to output +/// whatever it wants, to avoid spamming users it is recommended to avoid +/// unnecessary output. +/// +/// To help choose the appropriate output level, the `RUST_LOG` environment +/// variable will be passed through to the subprocess, if set. +/// +/// If the subprocess wishes to indicate that rendering failed, it should exit +/// with a non-zero return code. +#[derive(Debug, Clone, PartialEq)] +pub struct CmdRenderer { + name: String, + cmd: String, +} + +impl CmdRenderer { + /// Create a new `CmdRenderer` which will invoke the provided `cmd` string. + pub fn new(name: String, cmd: String) -> CmdRenderer { + CmdRenderer { name, cmd } + } + + fn compose_command(&self, root: &Path, destination: &Path) -> Result<Command> { + let mut words = Shlex::new(&self.cmd); + let exe = match words.next() { + Some(e) => PathBuf::from(e), + None => bail!("Command string was empty"), + }; + + let exe = if exe.components().count() == 1 { + // Search PATH for the executable. + exe + } else { + // Relative paths are preferred to be relative to the book root. + let abs_exe = root.join(&exe); + if abs_exe.exists() { + abs_exe + } else { + // Historically paths were relative to the destination, but + // this is not the preferred way. + let legacy_path = destination.join(&exe); + if legacy_path.exists() { + warn!( + "Renderer command `{}` uses a path relative to the \ + renderer output directory `{}`. This was previously \ + accepted, but has been deprecated. Relative executable \ + paths should be relative to the book root.", + exe.display(), + destination.display() + ); + legacy_path + } else { + // Let this bubble through to later be handled by + // handle_render_command_error. + abs_exe + } + } + }; + + let mut cmd = Command::new(exe); + + for arg in words { + cmd.arg(arg); + } + + Ok(cmd) + } +} + +impl CmdRenderer { + fn handle_render_command_error(&self, ctx: &RenderContext, error: io::Error) -> Result<()> { + if let ErrorKind::NotFound = error.kind() { + // Look for "output.{self.name}.optional". + // If it exists and is true, treat this as a warning. + // Otherwise, fail the build. + + let optional_key = format!("output.{}.optional", self.name); + + let is_optional = match ctx.config.get(&optional_key) { + Some(Value::Boolean(value)) => *value, + _ => false, + }; + + if is_optional { + warn!( + "The command `{}` for backend `{}` was not found, \ + but was marked as optional.", + self.cmd, self.name + ); + return Ok(()); + } else { + error!( + "The command `{0}` wasn't found, is the \"{1}\" backend installed? \ + If you want to ignore this error when the \"{1}\" backend is not installed, \ + set `optional = true` in the `[output.{1}]` section of the book.toml configuration file.", + self.cmd, self.name + ); + } + } + Err(error).with_context(|| "Unable to start the backend")? + } +} + +impl Renderer for CmdRenderer { + fn name(&self) -> &str { + &self.name + } + + fn render(&self, ctx: &RenderContext) -> Result<()> { + info!("Invoking the \"{}\" renderer", self.name); + + let _ = fs::create_dir_all(&ctx.destination); + + let mut child = match self + .compose_command(&ctx.root, &ctx.destination)? + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .current_dir(&ctx.destination) + .spawn() + { + Ok(c) => c, + Err(e) => return self.handle_render_command_error(ctx, e), + }; + + let mut stdin = child.stdin.take().expect("Child has stdin"); + if let Err(e) = serde_json::to_writer(&mut stdin, &ctx) { + // Looks like the backend hung up before we could finish + // sending it the render context. Log the error and keep going + warn!("Error writing the RenderContext to the backend, {}", e); + } + + // explicitly close the `stdin` file handle + drop(stdin); + + let status = child + .wait() + .with_context(|| "Error waiting for the backend to complete")?; + + trace!("{} exited with output: {:?}", self.cmd, status); + + if !status.success() { + error!("Renderer exited with non-zero return code."); + bail!("The \"{}\" renderer failed", self.name); + } else { + Ok(()) + } + } +} diff --git a/vendor/mdbook/src/theme/ayu-highlight.css b/vendor/mdbook/src/theme/ayu-highlight.css new file mode 100644 index 000000000..32c943222 --- /dev/null +++ b/vendor/mdbook/src/theme/ayu-highlight.css @@ -0,0 +1,78 @@ +/* +Based off of the Ayu theme +Original by Dempfi (https://github.com/dempfi/ayu) +*/ + +.hljs { + display: block; + overflow-x: auto; + background: #191f26; + color: #e6e1cf; +} + +.hljs-comment, +.hljs-quote { + color: #5c6773; + font-style: italic; +} + +.hljs-variable, +.hljs-template-variable, +.hljs-attribute, +.hljs-attr, +.hljs-regexp, +.hljs-link, +.hljs-selector-id, +.hljs-selector-class { + color: #ff7733; +} + +.hljs-number, +.hljs-meta, +.hljs-builtin-name, +.hljs-literal, +.hljs-type, +.hljs-params { + color: #ffee99; +} + +.hljs-string, +.hljs-bullet { + color: #b8cc52; +} + +.hljs-title, +.hljs-built_in, +.hljs-section { + color: #ffb454; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-symbol { + color: #ff7733; +} + +.hljs-name { + color: #36a3d9; +} + +.hljs-tag { + color: #00568d; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +.hljs-addition { + color: #91b362; +} + +.hljs-deletion { + color: #d96c75; +} diff --git a/vendor/mdbook/src/theme/book.js b/vendor/mdbook/src/theme/book.js new file mode 100644 index 000000000..d40440c72 --- /dev/null +++ b/vendor/mdbook/src/theme/book.js @@ -0,0 +1,679 @@ +"use strict"; + +// Fix back button cache problem +window.onunload = function () { }; + +// Global variable, shared between modules +function playground_text(playground) { + let code_block = playground.querySelector("code"); + + if (window.ace && code_block.classList.contains("editable")) { + let editor = window.ace.edit(code_block); + return editor.getValue(); + } else { + return code_block.textContent; + } +} + +(function codeSnippets() { + function fetch_with_timeout(url, options, timeout = 6000) { + return Promise.race([ + fetch(url, options), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)) + ]); + } + + var playgrounds = Array.from(document.querySelectorAll(".playground")); + if (playgrounds.length > 0) { + fetch_with_timeout("https://play.rust-lang.org/meta/crates", { + headers: { + 'Content-Type': "application/json", + }, + method: 'POST', + mode: 'cors', + }) + .then(response => response.json()) + .then(response => { + // get list of crates available in the rust playground + let playground_crates = response.crates.map(item => item["id"]); + playgrounds.forEach(block => handle_crate_list_update(block, playground_crates)); + }); + } + + function handle_crate_list_update(playground_block, playground_crates) { + // update the play buttons after receiving the response + update_play_button(playground_block, playground_crates); + + // and install on change listener to dynamically update ACE editors + if (window.ace) { + let code_block = playground_block.querySelector("code"); + if (code_block.classList.contains("editable")) { + let editor = window.ace.edit(code_block); + editor.addEventListener("change", function (e) { + update_play_button(playground_block, playground_crates); + }); + // add Ctrl-Enter command to execute rust code + editor.commands.addCommand({ + name: "run", + bindKey: { + win: "Ctrl-Enter", + mac: "Ctrl-Enter" + }, + exec: _editor => run_rust_code(playground_block) + }); + } + } + } + + // updates the visibility of play button based on `no_run` class and + // used crates vs ones available on http://play.rust-lang.org + function update_play_button(pre_block, playground_crates) { + var play_button = pre_block.querySelector(".play-button"); + + // skip if code is `no_run` + if (pre_block.querySelector('code').classList.contains("no_run")) { + play_button.classList.add("hidden"); + return; + } + + // get list of `extern crate`'s from snippet + var txt = playground_text(pre_block); + var re = /extern\s+crate\s+([a-zA-Z_0-9]+)\s*;/g; + var snippet_crates = []; + var item; + while (item = re.exec(txt)) { + snippet_crates.push(item[1]); + } + + // check if all used crates are available on play.rust-lang.org + var all_available = snippet_crates.every(function (elem) { + return playground_crates.indexOf(elem) > -1; + }); + + if (all_available) { + play_button.classList.remove("hidden"); + } else { + play_button.classList.add("hidden"); + } + } + + function run_rust_code(code_block) { + var result_block = code_block.querySelector(".result"); + if (!result_block) { + result_block = document.createElement('code'); + result_block.className = 'result hljs language-bash'; + + code_block.append(result_block); + } + + let text = playground_text(code_block); + let classes = code_block.querySelector('code').classList; + let edition = "2015"; + if(classes.contains("edition2018")) { + edition = "2018"; + } else if(classes.contains("edition2021")) { + edition = "2021"; + } + var params = { + version: "stable", + optimize: "0", + code: text, + edition: edition + }; + + if (text.indexOf("#![feature") !== -1) { + params.version = "nightly"; + } + + result_block.innerText = "Running..."; + + fetch_with_timeout("https://play.rust-lang.org/evaluate.json", { + headers: { + 'Content-Type': "application/json", + }, + method: 'POST', + mode: 'cors', + body: JSON.stringify(params) + }) + .then(response => response.json()) + .then(response => { + if (response.result.trim() === '') { + result_block.innerText = "No output"; + result_block.classList.add("result-no-output"); + } else { + result_block.innerText = response.result; + result_block.classList.remove("result-no-output"); + } + }) + .catch(error => result_block.innerText = "Playground Communication: " + error.message); + } + + // Syntax highlighting Configuration + hljs.configure({ + tabReplace: ' ', // 4 spaces + languages: [], // Languages used for auto-detection + }); + + let code_nodes = Array + .from(document.querySelectorAll('code')) + // Don't highlight `inline code` blocks in headers. + .filter(function (node) {return !node.parentElement.classList.contains("header"); }); + + if (window.ace) { + // language-rust class needs to be removed for editable + // blocks or highlightjs will capture events + code_nodes + .filter(function (node) {return node.classList.contains("editable"); }) + .forEach(function (block) { block.classList.remove('language-rust'); }); + + Array + code_nodes + .filter(function (node) {return !node.classList.contains("editable"); }) + .forEach(function (block) { hljs.highlightBlock(block); }); + } else { + code_nodes.forEach(function (block) { hljs.highlightBlock(block); }); + } + + // Adding the hljs class gives code blocks the color css + // even if highlighting doesn't apply + code_nodes.forEach(function (block) { block.classList.add('hljs'); }); + + Array.from(document.querySelectorAll("code.language-rust")).forEach(function (block) { + + var lines = Array.from(block.querySelectorAll('.boring')); + // If no lines were hidden, return + if (!lines.length) { return; } + block.classList.add("hide-boring"); + + var buttons = document.createElement('div'); + buttons.className = 'buttons'; + buttons.innerHTML = "<button class=\"fa fa-eye\" title=\"Show hidden lines\" aria-label=\"Show hidden lines\"></button>"; + + // add expand button + var pre_block = block.parentNode; + pre_block.insertBefore(buttons, pre_block.firstChild); + + pre_block.querySelector('.buttons').addEventListener('click', function (e) { + if (e.target.classList.contains('fa-eye')) { + e.target.classList.remove('fa-eye'); + e.target.classList.add('fa-eye-slash'); + e.target.title = 'Hide lines'; + e.target.setAttribute('aria-label', e.target.title); + + block.classList.remove('hide-boring'); + } else if (e.target.classList.contains('fa-eye-slash')) { + e.target.classList.remove('fa-eye-slash'); + e.target.classList.add('fa-eye'); + e.target.title = 'Show hidden lines'; + e.target.setAttribute('aria-label', e.target.title); + + block.classList.add('hide-boring'); + } + }); + }); + + if (window.playground_copyable) { + Array.from(document.querySelectorAll('pre code')).forEach(function (block) { + var pre_block = block.parentNode; + if (!pre_block.classList.contains('playground')) { + var buttons = pre_block.querySelector(".buttons"); + if (!buttons) { + buttons = document.createElement('div'); + buttons.className = 'buttons'; + pre_block.insertBefore(buttons, pre_block.firstChild); + } + + var clipButton = document.createElement('button'); + clipButton.className = 'fa fa-copy clip-button'; + clipButton.title = 'Copy to clipboard'; + clipButton.setAttribute('aria-label', clipButton.title); + clipButton.innerHTML = '<i class=\"tooltiptext\"></i>'; + + buttons.insertBefore(clipButton, buttons.firstChild); + } + }); + } + + // Process playground code blocks + Array.from(document.querySelectorAll(".playground")).forEach(function (pre_block) { + // Add play button + var buttons = pre_block.querySelector(".buttons"); + if (!buttons) { + buttons = document.createElement('div'); + buttons.className = 'buttons'; + pre_block.insertBefore(buttons, pre_block.firstChild); + } + + var runCodeButton = document.createElement('button'); + runCodeButton.className = 'fa fa-play play-button'; + runCodeButton.hidden = true; + runCodeButton.title = 'Run this code'; + runCodeButton.setAttribute('aria-label', runCodeButton.title); + + buttons.insertBefore(runCodeButton, buttons.firstChild); + runCodeButton.addEventListener('click', function (e) { + run_rust_code(pre_block); + }); + + if (window.playground_copyable) { + var copyCodeClipboardButton = document.createElement('button'); + copyCodeClipboardButton.className = 'fa fa-copy clip-button'; + copyCodeClipboardButton.innerHTML = '<i class="tooltiptext"></i>'; + copyCodeClipboardButton.title = 'Copy to clipboard'; + copyCodeClipboardButton.setAttribute('aria-label', copyCodeClipboardButton.title); + + buttons.insertBefore(copyCodeClipboardButton, buttons.firstChild); + } + + let code_block = pre_block.querySelector("code"); + if (window.ace && code_block.classList.contains("editable")) { + var undoChangesButton = document.createElement('button'); + undoChangesButton.className = 'fa fa-history reset-button'; + undoChangesButton.title = 'Undo changes'; + undoChangesButton.setAttribute('aria-label', undoChangesButton.title); + + buttons.insertBefore(undoChangesButton, buttons.firstChild); + + undoChangesButton.addEventListener('click', function () { + let editor = window.ace.edit(code_block); + editor.setValue(editor.originalCode); + editor.clearSelection(); + }); + } + }); +})(); + +(function themes() { + var html = document.querySelector('html'); + var themeToggleButton = document.getElementById('theme-toggle'); + var themePopup = document.getElementById('theme-list'); + var themeColorMetaTag = document.querySelector('meta[name="theme-color"]'); + var stylesheets = { + ayuHighlight: document.querySelector("[href$='ayu-highlight.css']"), + tomorrowNight: document.querySelector("[href$='tomorrow-night.css']"), + highlight: document.querySelector("[href$='highlight.css']"), + }; + + function showThemes() { + themePopup.style.display = 'block'; + themeToggleButton.setAttribute('aria-expanded', true); + themePopup.querySelector("button#" + get_theme()).focus(); + } + + function hideThemes() { + themePopup.style.display = 'none'; + themeToggleButton.setAttribute('aria-expanded', false); + themeToggleButton.focus(); + } + + function get_theme() { + var theme; + try { theme = localStorage.getItem('mdbook-theme'); } catch (e) { } + if (theme === null || theme === undefined) { + return default_theme; + } else { + return theme; + } + } + + function set_theme(theme, store = true) { + let ace_theme; + + if (theme == 'coal' || theme == 'navy') { + stylesheets.ayuHighlight.disabled = true; + stylesheets.tomorrowNight.disabled = false; + stylesheets.highlight.disabled = true; + + ace_theme = "ace/theme/tomorrow_night"; + } else if (theme == 'ayu') { + stylesheets.ayuHighlight.disabled = false; + stylesheets.tomorrowNight.disabled = true; + stylesheets.highlight.disabled = true; + ace_theme = "ace/theme/tomorrow_night"; + } else { + stylesheets.ayuHighlight.disabled = true; + stylesheets.tomorrowNight.disabled = true; + stylesheets.highlight.disabled = false; + ace_theme = "ace/theme/dawn"; + } + + setTimeout(function () { + themeColorMetaTag.content = getComputedStyle(document.body).backgroundColor; + }, 1); + + if (window.ace && window.editors) { + window.editors.forEach(function (editor) { + editor.setTheme(ace_theme); + }); + } + + var previousTheme = get_theme(); + + if (store) { + try { localStorage.setItem('mdbook-theme', theme); } catch (e) { } + } + + html.classList.remove(previousTheme); + html.classList.add(theme); + } + + // Set theme + var theme = get_theme(); + + set_theme(theme, false); + + themeToggleButton.addEventListener('click', function () { + if (themePopup.style.display === 'block') { + hideThemes(); + } else { + showThemes(); + } + }); + + themePopup.addEventListener('click', function (e) { + var theme; + if (e.target.className === "theme") { + theme = e.target.id; + } else if (e.target.parentElement.className === "theme") { + theme = e.target.parentElement.id; + } else { + return; + } + set_theme(theme); + }); + + themePopup.addEventListener('focusout', function(e) { + // e.relatedTarget is null in Safari and Firefox on macOS (see workaround below) + if (!!e.relatedTarget && !themeToggleButton.contains(e.relatedTarget) && !themePopup.contains(e.relatedTarget)) { + hideThemes(); + } + }); + + // Should not be needed, but it works around an issue on macOS & iOS: https://github.com/rust-lang/mdBook/issues/628 + document.addEventListener('click', function(e) { + if (themePopup.style.display === 'block' && !themeToggleButton.contains(e.target) && !themePopup.contains(e.target)) { + hideThemes(); + } + }); + + document.addEventListener('keydown', function (e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if (!themePopup.contains(e.target)) { return; } + + switch (e.key) { + case 'Escape': + e.preventDefault(); + hideThemes(); + break; + case 'ArrowUp': + e.preventDefault(); + var li = document.activeElement.parentElement; + if (li && li.previousElementSibling) { + li.previousElementSibling.querySelector('button').focus(); + } + break; + case 'ArrowDown': + e.preventDefault(); + var li = document.activeElement.parentElement; + if (li && li.nextElementSibling) { + li.nextElementSibling.querySelector('button').focus(); + } + break; + case 'Home': + e.preventDefault(); + themePopup.querySelector('li:first-child button').focus(); + break; + case 'End': + e.preventDefault(); + themePopup.querySelector('li:last-child button').focus(); + break; + } + }); +})(); + +(function sidebar() { + var html = document.querySelector("html"); + var sidebar = document.getElementById("sidebar"); + var sidebarLinks = document.querySelectorAll('#sidebar a'); + var sidebarToggleButton = document.getElementById("sidebar-toggle"); + var sidebarResizeHandle = document.getElementById("sidebar-resize-handle"); + var firstContact = null; + + function showSidebar() { + html.classList.remove('sidebar-hidden') + html.classList.add('sidebar-visible'); + Array.from(sidebarLinks).forEach(function (link) { + link.setAttribute('tabIndex', 0); + }); + sidebarToggleButton.setAttribute('aria-expanded', true); + sidebar.setAttribute('aria-hidden', false); + try { localStorage.setItem('mdbook-sidebar', 'visible'); } catch (e) { } + } + + + var sidebarAnchorToggles = document.querySelectorAll('#sidebar a.toggle'); + + function toggleSection(ev) { + ev.currentTarget.parentElement.classList.toggle('expanded'); + } + + Array.from(sidebarAnchorToggles).forEach(function (el) { + el.addEventListener('click', toggleSection); + }); + + function hideSidebar() { + html.classList.remove('sidebar-visible') + html.classList.add('sidebar-hidden'); + Array.from(sidebarLinks).forEach(function (link) { + link.setAttribute('tabIndex', -1); + }); + sidebarToggleButton.setAttribute('aria-expanded', false); + sidebar.setAttribute('aria-hidden', true); + try { localStorage.setItem('mdbook-sidebar', 'hidden'); } catch (e) { } + } + + // Toggle sidebar + sidebarToggleButton.addEventListener('click', function sidebarToggle() { + if (html.classList.contains("sidebar-hidden")) { + var current_width = parseInt( + document.documentElement.style.getPropertyValue('--sidebar-width'), 10); + if (current_width < 150) { + document.documentElement.style.setProperty('--sidebar-width', '150px'); + } + showSidebar(); + } else if (html.classList.contains("sidebar-visible")) { + hideSidebar(); + } else { + if (getComputedStyle(sidebar)['transform'] === 'none') { + hideSidebar(); + } else { + showSidebar(); + } + } + }); + + sidebarResizeHandle.addEventListener('mousedown', initResize, false); + + function initResize(e) { + window.addEventListener('mousemove', resize, false); + window.addEventListener('mouseup', stopResize, false); + html.classList.add('sidebar-resizing'); + } + function resize(e) { + var pos = (e.clientX - sidebar.offsetLeft); + if (pos < 20) { + hideSidebar(); + } else { + if (html.classList.contains("sidebar-hidden")) { + showSidebar(); + } + pos = Math.min(pos, window.innerWidth - 100); + document.documentElement.style.setProperty('--sidebar-width', pos + 'px'); + } + } + //on mouseup remove windows functions mousemove & mouseup + function stopResize(e) { + html.classList.remove('sidebar-resizing'); + window.removeEventListener('mousemove', resize, false); + window.removeEventListener('mouseup', stopResize, false); + } + + document.addEventListener('touchstart', function (e) { + firstContact = { + x: e.touches[0].clientX, + time: Date.now() + }; + }, { passive: true }); + + document.addEventListener('touchmove', function (e) { + if (!firstContact) + return; + + var curX = e.touches[0].clientX; + var xDiff = curX - firstContact.x, + tDiff = Date.now() - firstContact.time; + + if (tDiff < 250 && Math.abs(xDiff) >= 150) { + if (xDiff >= 0 && firstContact.x < Math.min(document.body.clientWidth * 0.25, 300)) + showSidebar(); + else if (xDiff < 0 && curX < 300) + hideSidebar(); + + firstContact = null; + } + }, { passive: true }); + + // Scroll sidebar to current active section + var activeSection = document.getElementById("sidebar").querySelector(".active"); + if (activeSection) { + // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView + activeSection.scrollIntoView({ block: 'center' }); + } +})(); + +(function chapterNavigation() { + document.addEventListener('keydown', function (e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if (window.search && window.search.hasFocus()) { return; } + + switch (e.key) { + case 'ArrowRight': + e.preventDefault(); + var nextButton = document.querySelector('.nav-chapters.next'); + if (nextButton) { + window.location.href = nextButton.href; + } + break; + case 'ArrowLeft': + e.preventDefault(); + var previousButton = document.querySelector('.nav-chapters.previous'); + if (previousButton) { + window.location.href = previousButton.href; + } + break; + } + }); +})(); + +(function clipboard() { + var clipButtons = document.querySelectorAll('.clip-button'); + + function hideTooltip(elem) { + elem.firstChild.innerText = ""; + elem.className = 'fa fa-copy clip-button'; + } + + function showTooltip(elem, msg) { + elem.firstChild.innerText = msg; + elem.className = 'fa fa-copy tooltipped'; + } + + var clipboardSnippets = new ClipboardJS('.clip-button', { + text: function (trigger) { + hideTooltip(trigger); + let playground = trigger.closest("pre"); + return playground_text(playground); + } + }); + + Array.from(clipButtons).forEach(function (clipButton) { + clipButton.addEventListener('mouseout', function (e) { + hideTooltip(e.currentTarget); + }); + }); + + clipboardSnippets.on('success', function (e) { + e.clearSelection(); + showTooltip(e.trigger, "Copied!"); + }); + + clipboardSnippets.on('error', function (e) { + showTooltip(e.trigger, "Clipboard error!"); + }); +})(); + +(function scrollToTop () { + var menuTitle = document.querySelector('.menu-title'); + + menuTitle.addEventListener('click', function () { + document.scrollingElement.scrollTo({ top: 0, behavior: 'smooth' }); + }); +})(); + +(function controllMenu() { + var menu = document.getElementById('menu-bar'); + + (function controllPosition() { + var scrollTop = document.scrollingElement.scrollTop; + var prevScrollTop = scrollTop; + var minMenuY = -menu.clientHeight - 50; + // When the script loads, the page can be at any scroll (e.g. if you reforesh it). + menu.style.top = scrollTop + 'px'; + // Same as parseInt(menu.style.top.slice(0, -2), but faster + var topCache = menu.style.top.slice(0, -2); + menu.classList.remove('sticky'); + var stickyCache = false; // Same as menu.classList.contains('sticky'), but faster + document.addEventListener('scroll', function () { + scrollTop = Math.max(document.scrollingElement.scrollTop, 0); + // `null` means that it doesn't need to be updated + var nextSticky = null; + var nextTop = null; + var scrollDown = scrollTop > prevScrollTop; + var menuPosAbsoluteY = topCache - scrollTop; + if (scrollDown) { + nextSticky = false; + if (menuPosAbsoluteY > 0) { + nextTop = prevScrollTop; + } + } else { + if (menuPosAbsoluteY > 0) { + nextSticky = true; + } else if (menuPosAbsoluteY < minMenuY) { + nextTop = prevScrollTop + minMenuY; + } + } + if (nextSticky === true && stickyCache === false) { + menu.classList.add('sticky'); + stickyCache = true; + } else if (nextSticky === false && stickyCache === true) { + menu.classList.remove('sticky'); + stickyCache = false; + } + if (nextTop !== null) { + menu.style.top = nextTop + 'px'; + topCache = nextTop; + } + prevScrollTop = scrollTop; + }, { passive: true }); + })(); + (function controllBorder() { + menu.classList.remove('bordered'); + document.addEventListener('scroll', function () { + if (menu.offsetTop === 0) { + menu.classList.remove('bordered'); + } else { + menu.classList.add('bordered'); + } + }, { passive: true }); + })(); +})(); diff --git a/vendor/mdbook/src/theme/css/chrome.css b/vendor/mdbook/src/theme/css/chrome.css new file mode 100644 index 000000000..10fa4b365 --- /dev/null +++ b/vendor/mdbook/src/theme/css/chrome.css @@ -0,0 +1,534 @@ +/* CSS for UI elements (a.k.a. chrome) */ + +@import 'variables.css'; + +::-webkit-scrollbar { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar); +} +html { + scrollbar-color: var(--scrollbar) var(--bg); +} +#searchresults a, +.content a:link, +a:visited, +a > .hljs { + color: var(--links); +} + +/* Menu Bar */ + +#menu-bar, +#menu-bar-hover-placeholder { + z-index: 101; + margin: auto calc(0px - var(--page-padding)); +} +#menu-bar { + position: relative; + display: flex; + flex-wrap: wrap; + background-color: var(--bg); + border-bottom-color: var(--bg); + border-bottom-width: 1px; + border-bottom-style: solid; +} +#menu-bar.sticky, +.js #menu-bar-hover-placeholder:hover + #menu-bar, +.js #menu-bar:hover, +.js.sidebar-visible #menu-bar { + position: -webkit-sticky; + position: sticky; + top: 0 !important; +} +#menu-bar-hover-placeholder { + position: sticky; + position: -webkit-sticky; + top: 0; + height: var(--menu-bar-height); +} +#menu-bar.bordered { + border-bottom-color: var(--table-border-color); +} +#menu-bar i, #menu-bar .icon-button { + position: relative; + padding: 0 8px; + z-index: 10; + line-height: var(--menu-bar-height); + cursor: pointer; + transition: color 0.5s; +} +@media only screen and (max-width: 420px) { + #menu-bar i, #menu-bar .icon-button { + padding: 0 5px; + } +} + +.icon-button { + border: none; + background: none; + padding: 0; + color: inherit; +} +.icon-button i { + margin: 0; +} + +.right-buttons { + margin: 0 15px; +} +.right-buttons a { + text-decoration: none; +} + +.left-buttons { + display: flex; + margin: 0 5px; +} +.no-js .left-buttons { + display: none; +} + +.menu-title { + display: inline-block; + font-weight: 200; + font-size: 2.4rem; + line-height: var(--menu-bar-height); + text-align: center; + margin: 0; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.js .menu-title { + cursor: pointer; +} + +.menu-bar, +.menu-bar:visited, +.nav-chapters, +.nav-chapters:visited, +.mobile-nav-chapters, +.mobile-nav-chapters:visited, +.menu-bar .icon-button, +.menu-bar a i { + color: var(--icons); +} + +.menu-bar i:hover, +.menu-bar .icon-button:hover, +.nav-chapters:hover, +.mobile-nav-chapters i:hover { + color: var(--icons-hover); +} + +/* Nav Icons */ + +.nav-chapters { + font-size: 2.5em; + text-align: center; + text-decoration: none; + + position: fixed; + top: 0; + bottom: 0; + margin: 0; + max-width: 150px; + min-width: 90px; + + display: flex; + justify-content: center; + align-content: center; + flex-direction: column; + + transition: color 0.5s, background-color 0.5s; +} + +.nav-chapters:hover { + text-decoration: none; + background-color: var(--theme-hover); + transition: background-color 0.15s, color 0.15s; +} + +.nav-wrapper { + margin-top: 50px; + display: none; +} + +.mobile-nav-chapters { + font-size: 2.5em; + text-align: center; + text-decoration: none; + width: 90px; + border-radius: 5px; + background-color: var(--sidebar-bg); +} + +.previous { + float: left; +} + +.next { + float: right; + right: var(--page-padding); +} + +@media only screen and (max-width: 1080px) { + .nav-wide-wrapper { display: none; } + .nav-wrapper { display: block; } +} + +@media only screen and (max-width: 1380px) { + .sidebar-visible .nav-wide-wrapper { display: none; } + .sidebar-visible .nav-wrapper { display: block; } +} + +/* Inline code */ + +:not(pre) > .hljs { + display: inline; + padding: 0.1em 0.3em; + border-radius: 3px; +} + +:not(pre):not(a) > .hljs { + color: var(--inline-code-color); + overflow-x: initial; +} + +a:hover > .hljs { + text-decoration: underline; +} + +pre { + position: relative; +} +pre > .buttons { + position: absolute; + z-index: 100; + right: 0px; + top: 2px; + margin: 0px; + padding: 2px 0px; + + color: var(--sidebar-fg); + cursor: pointer; + visibility: hidden; + opacity: 0; + transition: visibility 0.1s linear, opacity 0.1s linear; +} +pre:hover > .buttons { + visibility: visible; + opacity: 1 +} +pre > .buttons :hover { + color: var(--sidebar-active); + border-color: var(--icons-hover); + background-color: var(--theme-hover); +} +pre > .buttons i { + margin-left: 8px; +} +pre > .buttons button { + cursor: inherit; + margin: 0px 5px; + padding: 3px 5px; + font-size: 14px; + + border-style: solid; + border-width: 1px; + border-radius: 4px; + border-color: var(--icons); + background-color: var(--theme-popup-bg); + transition: 100ms; + transition-property: color,border-color,background-color; + color: var(--icons); +} +@media (pointer: coarse) { + pre > .buttons button { + /* On mobile, make it easier to tap buttons. */ + padding: 0.3rem 1rem; + } +} +pre > code { + padding: 1rem; +} + +/* FIXME: ACE editors overlap their buttons because ACE does absolute + positioning within the code block which breaks padding. The only solution I + can think of is to move the padding to the outer pre tag (or insert a div + wrapper), but that would require fixing a whole bunch of CSS rules. +*/ +.hljs.ace_editor { + padding: 0rem 0rem; +} + +pre > .result { + margin-top: 10px; +} + +/* Search */ + +#searchresults a { + text-decoration: none; +} + +mark { + border-radius: 2px; + padding: 0 3px 1px 3px; + margin: 0 -3px -1px -3px; + background-color: var(--search-mark-bg); + transition: background-color 300ms linear; + cursor: pointer; +} + +mark.fade-out { + background-color: rgba(0,0,0,0) !important; + cursor: auto; +} + +.searchbar-outer { + margin-left: auto; + margin-right: auto; + max-width: var(--content-max-width); +} + +#searchbar { + width: 100%; + margin: 5px auto 0px auto; + padding: 10px 16px; + transition: box-shadow 300ms ease-in-out; + border: 1px solid var(--searchbar-border-color); + border-radius: 3px; + background-color: var(--searchbar-bg); + color: var(--searchbar-fg); +} +#searchbar:focus, +#searchbar.active { + box-shadow: 0 0 3px var(--searchbar-shadow-color); +} + +.searchresults-header { + font-weight: bold; + font-size: 1em; + padding: 18px 0 0 5px; + color: var(--searchresults-header-fg); +} + +.searchresults-outer { + margin-left: auto; + margin-right: auto; + max-width: var(--content-max-width); + border-bottom: 1px dashed var(--searchresults-border-color); +} + +ul#searchresults { + list-style: none; + padding-left: 20px; +} +ul#searchresults li { + margin: 10px 0px; + padding: 2px; + border-radius: 2px; +} +ul#searchresults li.focus { + background-color: var(--searchresults-li-bg); +} +ul#searchresults span.teaser { + display: block; + clear: both; + margin: 5px 0 0 20px; + font-size: 0.8em; +} +ul#searchresults span.teaser em { + font-weight: bold; + font-style: normal; +} + +/* Sidebar */ + +.sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: var(--sidebar-width); + font-size: 0.875em; + box-sizing: border-box; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; + background-color: var(--sidebar-bg); + color: var(--sidebar-fg); +} +.sidebar-resizing { + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} +.js:not(.sidebar-resizing) .sidebar { + transition: transform 0.3s; /* Animation: slide away */ +} +.sidebar code { + line-height: 2em; +} +.sidebar .sidebar-scrollbox { + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + padding: 10px 10px; +} +.sidebar .sidebar-resize-handle { + position: absolute; + cursor: col-resize; + width: 0; + right: 0; + top: 0; + bottom: 0; +} +.js .sidebar .sidebar-resize-handle { + cursor: col-resize; + width: 5px; +} +.sidebar-hidden .sidebar { + transform: translateX(calc(0px - var(--sidebar-width))); +} +.sidebar::-webkit-scrollbar { + background: var(--sidebar-bg); +} +.sidebar::-webkit-scrollbar-thumb { + background: var(--scrollbar); +} + +.sidebar-visible .page-wrapper { + transform: translateX(var(--sidebar-width)); +} +@media only screen and (min-width: 620px) { + .sidebar-visible .page-wrapper { + transform: none; + margin-left: var(--sidebar-width); + } +} + +.chapter { + list-style: none outside none; + padding-left: 0; + line-height: 2.2em; +} + +.chapter ol { + width: 100%; +} + +.chapter li { + display: flex; + color: var(--sidebar-non-existant); +} +.chapter li a { + display: block; + padding: 0; + text-decoration: none; + color: var(--sidebar-fg); +} + +.chapter li a:hover { + color: var(--sidebar-active); +} + +.chapter li a.active { + color: var(--sidebar-active); +} + +.chapter li > a.toggle { + cursor: pointer; + display: block; + margin-left: auto; + padding: 0 10px; + user-select: none; + opacity: 0.68; +} + +.chapter li > a.toggle div { + transition: transform 0.5s; +} + +/* collapse the section */ +.chapter li:not(.expanded) + li > ol { + display: none; +} + +.chapter li.chapter-item { + line-height: 1.5em; + margin-top: 0.6em; +} + +.chapter li.expanded > a.toggle div { + transform: rotate(90deg); +} + +.spacer { + width: 100%; + height: 3px; + margin: 5px 0px; +} +.chapter .spacer { + background-color: var(--sidebar-spacer); +} + +@media (-moz-touch-enabled: 1), (pointer: coarse) { + .chapter li a { padding: 5px 0; } + .spacer { margin: 10px 0; } +} + +.section { + list-style: none outside none; + padding-left: 20px; + line-height: 1.9em; +} + +/* Theme Menu Popup */ + +.theme-popup { + position: absolute; + left: 10px; + top: var(--menu-bar-height); + z-index: 1000; + border-radius: 4px; + font-size: 0.7em; + color: var(--fg); + background: var(--theme-popup-bg); + border: 1px solid var(--theme-popup-border); + margin: 0; + padding: 0; + list-style: none; + display: none; +} +.theme-popup .default { + color: var(--icons); +} +.theme-popup .theme { + width: 100%; + border: 0; + margin: 0; + padding: 2px 10px; + line-height: 25px; + white-space: nowrap; + text-align: left; + cursor: pointer; + color: inherit; + background: inherit; + font-size: inherit; +} +.theme-popup .theme:hover { + background-color: var(--theme-hover); +} +.theme-popup .theme:hover:first-child, +.theme-popup .theme:hover:last-child { + border-top-left-radius: inherit; + border-top-right-radius: inherit; +} diff --git a/vendor/mdbook/src/theme/css/general.css b/vendor/mdbook/src/theme/css/general.css new file mode 100644 index 000000000..0e4f07a50 --- /dev/null +++ b/vendor/mdbook/src/theme/css/general.css @@ -0,0 +1,191 @@ +/* Base styles and content styles */ + +@import 'variables.css'; + +:root { + /* Browser default font-size is 16px, this way 1 rem = 10px */ + font-size: 62.5%; +} + +html { + font-family: "Open Sans", sans-serif; + color: var(--fg); + background-color: var(--bg); + text-size-adjust: none; + -webkit-text-size-adjust: none; +} + +body { + margin: 0; + font-size: 1.6rem; + overflow-x: hidden; +} + +code { + font-family: "Source Code Pro", Consolas, "Ubuntu Mono", Menlo, "DejaVu Sans Mono", monospace, monospace !important; + font-size: 0.875em; /* please adjust the ace font size accordingly in editor.js */ +} + +/* make long words/inline code not x overflow */ +main { + overflow-wrap: break-word; +} + +/* make wide tables scroll if they overflow */ +.table-wrapper { + overflow-x: auto; +} + +/* Don't change font size in headers. */ +h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { + font-size: unset; +} + +.left { float: left; } +.right { float: right; } +.boring { opacity: 0.6; } +.hide-boring .boring { display: none; } +.hidden { display: none !important; } + +h2, h3 { margin-top: 2.5em; } +h4, h5 { margin-top: 2em; } + +.header + .header h3, +.header + .header h4, +.header + .header h5 { + margin-top: 1em; +} + +h1:target::before, +h2:target::before, +h3:target::before, +h4:target::before, +h5:target::before, +h6:target::before { + display: inline-block; + content: "»"; + margin-left: -30px; + width: 30px; +} + +/* This is broken on Safari as of version 14, but is fixed + in Safari Technology Preview 117 which I think will be Safari 14.2. + https://bugs.webkit.org/show_bug.cgi?id=218076 +*/ +:target { + scroll-margin-top: calc(var(--menu-bar-height) + 0.5em); +} + +.page { + outline: 0; + padding: 0 var(--page-padding); + margin-top: calc(0px - var(--menu-bar-height)); /* Compensate for the #menu-bar-hover-placeholder */ +} +.page-wrapper { + box-sizing: border-box; +} +.js:not(.sidebar-resizing) .page-wrapper { + transition: margin-left 0.3s ease, transform 0.3s ease; /* Animation: slide away */ +} + +.content { + overflow-y: auto; + padding: 0 5px 50px 5px; +} +.content main { + margin-left: auto; + margin-right: auto; + max-width: var(--content-max-width); +} +.content p { line-height: 1.45em; } +.content ol { line-height: 1.45em; } +.content ul { line-height: 1.45em; } +.content a { text-decoration: none; } +.content a:hover { text-decoration: underline; } +.content img, .content video { max-width: 100%; } +.content .header:link, +.content .header:visited { + color: var(--fg); +} +.content .header:link, +.content .header:visited:hover { + text-decoration: none; +} + +table { + margin: 0 auto; + border-collapse: collapse; +} +table td { + padding: 3px 20px; + border: 1px var(--table-border-color) solid; +} +table thead { + background: var(--table-header-bg); +} +table thead td { + font-weight: 700; + border: none; +} +table thead th { + padding: 3px 20px; +} +table thead tr { + border: 1px var(--table-header-bg) solid; +} +/* Alternate background colors for rows */ +table tbody tr:nth-child(2n) { + background: var(--table-alternate-bg); +} + + +blockquote { + margin: 20px 0; + padding: 0 20px; + color: var(--fg); + background-color: var(--quote-bg); + border-top: .1em solid var(--quote-border); + border-bottom: .1em solid var(--quote-border); +} + + +:not(.footnote-definition) + .footnote-definition, +.footnote-definition + :not(.footnote-definition) { + margin-top: 2em; +} +.footnote-definition { + font-size: 0.9em; + margin: 0.5em 0; +} +.footnote-definition p { + display: inline; +} + +.tooltiptext { + position: absolute; + visibility: hidden; + color: #fff; + background-color: #333; + transform: translateX(-50%); /* Center by moving tooltip 50% of its width left */ + left: -8px; /* Half of the width of the icon */ + top: -35px; + font-size: 0.8em; + text-align: center; + border-radius: 6px; + padding: 5px 8px; + margin: 5px; + z-index: 1000; +} +.tooltipped .tooltiptext { + visibility: visible; +} + +.chapter li.part-title { + color: var(--sidebar-fg); + margin: 5px 0px; + font-weight: bold; +} + +.result-no-output { + font-style: italic; +} diff --git a/vendor/mdbook/src/theme/css/print.css b/vendor/mdbook/src/theme/css/print.css new file mode 100644 index 000000000..5e690f755 --- /dev/null +++ b/vendor/mdbook/src/theme/css/print.css @@ -0,0 +1,54 @@ + +#sidebar, +#menu-bar, +.nav-chapters, +.mobile-nav-chapters { + display: none; +} + +#page-wrapper.page-wrapper { + transform: none; + margin-left: 0px; + overflow-y: initial; +} + +#content { + max-width: none; + margin: 0; + padding: 0; +} + +.page { + overflow-y: initial; +} + +code { + background-color: #666666; + border-radius: 5px; + + /* Force background to be printed in Chrome */ + -webkit-print-color-adjust: exact; +} + +pre > .buttons { + z-index: 2; +} + +a, a:visited, a:active, a:hover { + color: #4183c4; + text-decoration: none; +} + +h1, h2, h3, h4, h5, h6 { + page-break-inside: avoid; + page-break-after: avoid; +} + +pre, code { + page-break-inside: avoid; + white-space: pre-wrap; +} + +.fa { + display: none !important; +} diff --git a/vendor/mdbook/src/theme/css/variables.css b/vendor/mdbook/src/theme/css/variables.css new file mode 100644 index 000000000..56b634bc3 --- /dev/null +++ b/vendor/mdbook/src/theme/css/variables.css @@ -0,0 +1,253 @@ + +/* Globals */ + +:root { + --sidebar-width: 300px; + --page-padding: 15px; + --content-max-width: 750px; + --menu-bar-height: 50px; +} + +/* Themes */ + +.ayu { + --bg: hsl(210, 25%, 8%); + --fg: #c5c5c5; + + --sidebar-bg: #14191f; + --sidebar-fg: #c8c9db; + --sidebar-non-existant: #5c6773; + --sidebar-active: #ffb454; + --sidebar-spacer: #2d334f; + + --scrollbar: var(--sidebar-fg); + + --icons: #737480; + --icons-hover: #b7b9cc; + + --links: #0096cf; + + --inline-code-color: #ffb454; + + --theme-popup-bg: #14191f; + --theme-popup-border: #5c6773; + --theme-hover: #191f26; + + --quote-bg: hsl(226, 15%, 17%); + --quote-border: hsl(226, 15%, 22%); + + --table-border-color: hsl(210, 25%, 13%); + --table-header-bg: hsl(210, 25%, 28%); + --table-alternate-bg: hsl(210, 25%, 11%); + + --searchbar-border-color: #848484; + --searchbar-bg: #424242; + --searchbar-fg: #fff; + --searchbar-shadow-color: #d4c89f; + --searchresults-header-fg: #666; + --searchresults-border-color: #888; + --searchresults-li-bg: #252932; + --search-mark-bg: #e3b171; +} + +.coal { + --bg: hsl(200, 7%, 8%); + --fg: #98a3ad; + + --sidebar-bg: #292c2f; + --sidebar-fg: #a1adb8; + --sidebar-non-existant: #505254; + --sidebar-active: #3473ad; + --sidebar-spacer: #393939; + + --scrollbar: var(--sidebar-fg); + + --icons: #43484d; + --icons-hover: #b3c0cc; + + --links: #2b79a2; + + --inline-code-color: #c5c8c6; + + --theme-popup-bg: #141617; + --theme-popup-border: #43484d; + --theme-hover: #1f2124; + + --quote-bg: hsl(234, 21%, 18%); + --quote-border: hsl(234, 21%, 23%); + + --table-border-color: hsl(200, 7%, 13%); + --table-header-bg: hsl(200, 7%, 28%); + --table-alternate-bg: hsl(200, 7%, 11%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #b7b7b7; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #98a3ad; + --searchresults-li-bg: #2b2b2f; + --search-mark-bg: #355c7d; +} + +.light { + --bg: hsl(0, 0%, 100%); + --fg: hsl(0, 0%, 0%); + + --sidebar-bg: #fafafa; + --sidebar-fg: hsl(0, 0%, 0%); + --sidebar-non-existant: #aaaaaa; + --sidebar-active: #1f1fff; + --sidebar-spacer: #f4f4f4; + + --scrollbar: #8F8F8F; + + --icons: #747474; + --icons-hover: #000000; + + --links: #20609f; + + --inline-code-color: #301900; + + --theme-popup-bg: #fafafa; + --theme-popup-border: #cccccc; + --theme-hover: #e6e6e6; + + --quote-bg: hsl(197, 37%, 96%); + --quote-border: hsl(197, 37%, 91%); + + --table-border-color: hsl(0, 0%, 95%); + --table-header-bg: hsl(0, 0%, 80%); + --table-alternate-bg: hsl(0, 0%, 97%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #fafafa; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #888; + --searchresults-li-bg: #e4f2fe; + --search-mark-bg: #a2cff5; +} + +.navy { + --bg: hsl(226, 23%, 11%); + --fg: #bcbdd0; + + --sidebar-bg: #282d3f; + --sidebar-fg: #c8c9db; + --sidebar-non-existant: #505274; + --sidebar-active: #2b79a2; + --sidebar-spacer: #2d334f; + + --scrollbar: var(--sidebar-fg); + + --icons: #737480; + --icons-hover: #b7b9cc; + + --links: #2b79a2; + + --inline-code-color: #c5c8c6; + + --theme-popup-bg: #161923; + --theme-popup-border: #737480; + --theme-hover: #282e40; + + --quote-bg: hsl(226, 15%, 17%); + --quote-border: hsl(226, 15%, 22%); + + --table-border-color: hsl(226, 23%, 16%); + --table-header-bg: hsl(226, 23%, 31%); + --table-alternate-bg: hsl(226, 23%, 14%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #aeaec6; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #5f5f71; + --searchresults-border-color: #5c5c68; + --searchresults-li-bg: #242430; + --search-mark-bg: #a2cff5; +} + +.rust { + --bg: hsl(60, 9%, 87%); + --fg: #262625; + + --sidebar-bg: #3b2e2a; + --sidebar-fg: #c8c9db; + --sidebar-non-existant: #505254; + --sidebar-active: #e69f67; + --sidebar-spacer: #45373a; + + --scrollbar: var(--sidebar-fg); + + --icons: #737480; + --icons-hover: #262625; + + --links: #2b79a2; + + --inline-code-color: #6e6b5e; + + --theme-popup-bg: #e1e1db; + --theme-popup-border: #b38f6b; + --theme-hover: #99908a; + + --quote-bg: hsl(60, 5%, 75%); + --quote-border: hsl(60, 5%, 70%); + + --table-border-color: hsl(60, 9%, 82%); + --table-header-bg: #b3a497; + --table-alternate-bg: hsl(60, 9%, 84%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #fafafa; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #888; + --searchresults-li-bg: #dec2a2; + --search-mark-bg: #e69f67; +} + +@media (prefers-color-scheme: dark) { + .light.no-js { + --bg: hsl(200, 7%, 8%); + --fg: #98a3ad; + + --sidebar-bg: #292c2f; + --sidebar-fg: #a1adb8; + --sidebar-non-existant: #505254; + --sidebar-active: #3473ad; + --sidebar-spacer: #393939; + + --scrollbar: var(--sidebar-fg); + + --icons: #43484d; + --icons-hover: #b3c0cc; + + --links: #2b79a2; + + --inline-code-color: #c5c8c6; + + --theme-popup-bg: #141617; + --theme-popup-border: #43484d; + --theme-hover: #1f2124; + + --quote-bg: hsl(234, 21%, 18%); + --quote-border: hsl(234, 21%, 23%); + + --table-border-color: hsl(200, 7%, 13%); + --table-header-bg: hsl(200, 7%, 28%); + --table-alternate-bg: hsl(200, 7%, 11%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #b7b7b7; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #98a3ad; + --searchresults-li-bg: #2b2b2f; + --search-mark-bg: #355c7d; + } +} diff --git a/vendor/mdbook/src/theme/favicon.png b/vendor/mdbook/src/theme/favicon.png Binary files differnew file mode 100644 index 000000000..a5b1aa16c --- /dev/null +++ b/vendor/mdbook/src/theme/favicon.png diff --git a/vendor/mdbook/src/theme/favicon.svg b/vendor/mdbook/src/theme/favicon.svg new file mode 100755 index 000000000..90e0ea58b --- /dev/null +++ b/vendor/mdbook/src/theme/favicon.svg @@ -0,0 +1,22 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 199.7 184.2"> + <style> + @media (prefers-color-scheme: dark) { + svg { fill: white; } + } + </style> +<path d="M189.5,36.8c0.2,2.8,0,5.1-0.6,6.8L153,162c-0.6,2.1-2,3.7-4.2,5c-2.2,1.2-4.4,1.9-6.7,1.9H31.4c-9.6,0-15.3-2.8-17.3-8.4 + c-0.8-2.2-0.8-3.9,0.1-5.2c0.9-1.2,2.4-1.8,4.6-1.8H123c7.4,0,12.6-1.4,15.4-4.1s5.7-8.9,8.6-18.4l32.9-108.6 + c1.8-5.9,1-11.1-2.2-15.6S169.9,0,164,0H72.7c-1,0-3.1,0.4-6.1,1.1l0.1-0.4C64.5,0.2,62.6,0,61,0.1s-3,0.5-4.3,1.4 + c-1.3,0.9-2.4,1.8-3.2,2.8S52,6.5,51.2,8.1c-0.8,1.6-1.4,3-1.9,4.3s-1.1,2.7-1.8,4.2c-0.7,1.5-1.3,2.7-2,3.7c-0.5,0.6-1.2,1.5-2,2.5 + s-1.6,2-2.2,2.8s-0.9,1.5-1.1,2.2c-0.2,0.7-0.1,1.8,0.2,3.2c0.3,1.4,0.4,2.4,0.4,3.1c-0.3,3-1.4,6.9-3.3,11.6 + c-1.9,4.7-3.6,8.1-5.1,10.1c-0.3,0.4-1.2,1.3-2.6,2.7c-1.4,1.4-2.3,2.6-2.6,3.7c-0.3,0.4-0.3,1.5-0.1,3.4c0.3,1.8,0.4,3.1,0.3,3.8 + c-0.3,2.7-1.3,6.3-3,10.8c-1.7,4.5-3.4,8.2-5,11c-0.2,0.5-0.9,1.4-2,2.8c-1.1,1.4-1.8,2.5-2,3.4c-0.2,0.6-0.1,1.8,0.1,3.4 + c0.2,1.6,0.2,2.8-0.1,3.6c-0.6,3-1.8,6.7-3.6,11c-1.8,4.3-3.6,7.9-5.4,11c-0.5,0.8-1.1,1.7-2,2.8c-0.8,1.1-1.5,2-2,2.8 + s-0.8,1.6-1,2.5c-0.1,0.5,0,1.3,0.4,2.3c0.3,1.1,0.4,1.9,0.4,2.6c-0.1,1.1-0.2,2.6-0.5,4.4c-0.2,1.8-0.4,2.9-0.4,3.2 + c-1.8,4.8-1.7,9.9,0.2,15.2c2.2,6.2,6.2,11.5,11.9,15.8c5.7,4.3,11.7,6.4,17.8,6.4h110.7c5.2,0,10.1-1.7,14.7-5.2s7.7-7.8,9.2-12.9 + l33-108.6c1.8-5.8,1-10.9-2.2-15.5C194.9,39.7,192.6,38,189.5,36.8z M59.6,122.8L73.8,80c0,0,7,0,10.8,0s28.8-1.7,25.4,17.5 + c-3.4,19.2-18.8,25.2-36.8,25.4S59.6,122.8,59.6,122.8z M78.6,116.8c4.7-0.1,18.9-2.9,22.1-17.1S89.2,86.3,89.2,86.3l-8.9,0 + l-10.2,30.5C70.2,116.9,74,116.9,78.6,116.8z M75.3,68.7L89,26.2h9.8l0.8,34l23.6-34h9.9l-13.6,42.5h-7.1l12.5-35.4l-24.5,35.4h-6.8 + l-0.8-35L82,68.7H75.3z"/> +</svg> +<!-- Original image Copyright Dave Gandy — CC BY 4.0 License --> diff --git a/vendor/mdbook/src/theme/head.hbs b/vendor/mdbook/src/theme/head.hbs new file mode 100644 index 000000000..cb1be1876 --- /dev/null +++ b/vendor/mdbook/src/theme/head.hbs @@ -0,0 +1 @@ +{{!-- Put your head HTML text here --}} diff --git a/vendor/mdbook/src/theme/header.hbs b/vendor/mdbook/src/theme/header.hbs new file mode 100644 index 000000000..26fa2d2ef --- /dev/null +++ b/vendor/mdbook/src/theme/header.hbs @@ -0,0 +1 @@ +{{!-- Put your header HTML text here --}}
\ No newline at end of file diff --git a/vendor/mdbook/src/theme/index.hbs b/vendor/mdbook/src/theme/index.hbs new file mode 100644 index 000000000..18d984a2b --- /dev/null +++ b/vendor/mdbook/src/theme/index.hbs @@ -0,0 +1,314 @@ +<!DOCTYPE HTML> +<html lang="{{ language }}" class="sidebar-visible no-js {{ default_theme }}"> + <head> + <!-- Book generated using mdBook --> + <meta charset="UTF-8"> + <title>{{ title }}</title> + {{#if is_print }} + <meta name="robots" content="noindex" /> + {{/if}} + {{#if base_url}} + <base href="{{ base_url }}"> + {{/if}} + + + <!-- Custom HTML head --> + {{> head}} + + <meta content="text/html; charset=utf-8" http-equiv="Content-Type"> + <meta name="description" content="{{ description }}"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="theme-color" content="#ffffff" /> + + {{#if favicon_svg}} + <link rel="icon" href="{{ path_to_root }}favicon.svg"> + {{/if}} + {{#if favicon_png}} + <link rel="shortcut icon" href="{{ path_to_root }}favicon.png"> + {{/if}} + <link rel="stylesheet" href="{{ path_to_root }}css/variables.css"> + <link rel="stylesheet" href="{{ path_to_root }}css/general.css"> + <link rel="stylesheet" href="{{ path_to_root }}css/chrome.css"> + {{#if print_enable}} + <link rel="stylesheet" href="{{ path_to_root }}css/print.css" media="print"> + {{/if}} + + <!-- Fonts --> + <link rel="stylesheet" href="{{ path_to_root }}FontAwesome/css/font-awesome.css"> + {{#if copy_fonts}} + <link rel="stylesheet" href="{{ path_to_root }}fonts/fonts.css"> + {{/if}} + + <!-- Highlight.js Stylesheets --> + <link rel="stylesheet" href="{{ path_to_root }}highlight.css"> + <link rel="stylesheet" href="{{ path_to_root }}tomorrow-night.css"> + <link rel="stylesheet" href="{{ path_to_root }}ayu-highlight.css"> + + <!-- Custom theme stylesheets --> + {{#each additional_css}} + <link rel="stylesheet" href="{{ ../path_to_root }}{{ this }}"> + {{/each}} + + {{#if mathjax_support}} + <!-- MathJax --> + <script async type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script> + {{/if}} + </head> + <body> + <!-- Provide site root to javascript --> + <script type="text/javascript"> + var path_to_root = "{{ path_to_root }}"; + var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}"; + </script> + + <!-- Work around some values being stored in localStorage wrapped in quotes --> + <script type="text/javascript"> + try { + var theme = localStorage.getItem('mdbook-theme'); + var sidebar = localStorage.getItem('mdbook-sidebar'); + + if (theme.startsWith('"') && theme.endsWith('"')) { + localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1)); + } + + if (sidebar.startsWith('"') && sidebar.endsWith('"')) { + localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1)); + } + } catch (e) { } + </script> + + <!-- Set the theme before any content is loaded, prevents flash --> + <script type="text/javascript"> + var theme; + try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { } + if (theme === null || theme === undefined) { theme = default_theme; } + var html = document.querySelector('html'); + html.classList.remove('no-js') + html.classList.remove('{{ default_theme }}') + html.classList.add(theme); + html.classList.add('js'); + </script> + + <!-- Hide / unhide sidebar before it is displayed --> + <script type="text/javascript"> + var html = document.querySelector('html'); + var sidebar = 'hidden'; + if (document.body.clientWidth >= 1080) { + try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { } + sidebar = sidebar || 'visible'; + } + html.classList.remove('sidebar-visible'); + html.classList.add("sidebar-" + sidebar); + </script> + + <nav id="sidebar" class="sidebar" aria-label="Table of contents"> + <div class="sidebar-scrollbox"> + {{#toc}}{{/toc}} + </div> + <div id="sidebar-resize-handle" class="sidebar-resize-handle"></div> + </nav> + + <div id="page-wrapper" class="page-wrapper"> + + <div class="page"> + {{> header}} + <div id="menu-bar-hover-placeholder"></div> + <div id="menu-bar" class="menu-bar sticky bordered"> + <div class="left-buttons"> + <button id="sidebar-toggle" class="icon-button" type="button" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar"> + <i class="fa fa-bars"></i> + </button> + <button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list"> + <i class="fa fa-paint-brush"></i> + </button> + <ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu"> + <li role="none"><button role="menuitem" class="theme" id="light">{{ theme_option "Light" }}</button></li> + <li role="none"><button role="menuitem" class="theme" id="rust">{{ theme_option "Rust" }}</button></li> + <li role="none"><button role="menuitem" class="theme" id="coal">{{ theme_option "Coal" }}</button></li> + <li role="none"><button role="menuitem" class="theme" id="navy">{{ theme_option "Navy" }}</button></li> + <li role="none"><button role="menuitem" class="theme" id="ayu">{{ theme_option "Ayu" }}</button></li> + </ul> + {{#if search_enabled}} + <button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar"> + <i class="fa fa-search"></i> + </button> + {{/if}} + </div> + + <h1 class="menu-title">{{ book_title }}</h1> + + <div class="right-buttons"> + {{#if print_enable}} + <a href="{{ path_to_root }}print.html" title="Print this book" aria-label="Print this book"> + <i id="print-button" class="fa fa-print"></i> + </a> + {{/if}} + {{#if git_repository_url}} + <a href="{{git_repository_url}}" title="Git repository" aria-label="Git repository"> + <i id="git-repository-button" class="fa {{git_repository_icon}}"></i> + </a> + {{/if}} + {{#if git_repository_edit_url}} + <a href="{{git_repository_edit_url}}" title="Suggest an edit" aria-label="Suggest an edit"> + <i id="git-edit-button" class="fa fa-edit"></i> + </a> + {{/if}} + + </div> + </div> + + {{#if search_enabled}} + <div id="search-wrapper" class="hidden"> + <form id="searchbar-outer" class="searchbar-outer"> + <input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header"> + </form> + <div id="searchresults-outer" class="searchresults-outer hidden"> + <div id="searchresults-header" class="searchresults-header"></div> + <ul id="searchresults"> + </ul> + </div> + </div> + {{/if}} + + <!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM --> + <script type="text/javascript"> + document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible'); + document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible'); + Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) { + link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1); + }); + </script> + + <div id="content" class="content"> + <main> + {{{ content }}} + </main> + + <nav class="nav-wrapper" aria-label="Page navigation"> + <!-- Mobile navigation buttons --> + {{#previous}} + <a rel="prev" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left"> + <i class="fa fa-angle-left"></i> + </a> + {{/previous}} + + {{#next}} + <a rel="next" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right"> + <i class="fa fa-angle-right"></i> + </a> + {{/next}} + + <div style="clear: both"></div> + </nav> + </div> + </div> + + <nav class="nav-wide-wrapper" aria-label="Page navigation"> + {{#previous}} + <a rel="prev" href="{{ path_to_root }}{{link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left"> + <i class="fa fa-angle-left"></i> + </a> + {{/previous}} + + {{#next}} + <a rel="next" href="{{ path_to_root }}{{link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right"> + <i class="fa fa-angle-right"></i> + </a> + {{/next}} + </nav> + + </div> + + {{#if live_reload_endpoint}} + <!-- Livereload script (if served using the cli tool) --> + <script type="text/javascript"> + const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsAddress = wsProtocol + "//" + location.host + "/" + "{{{live_reload_endpoint}}}"; + const socket = new WebSocket(wsAddress); + socket.onmessage = function (event) { + if (event.data === "reload") { + socket.close(); + location.reload(); + } + }; + + window.onbeforeunload = function() { + socket.close(); + } + </script> + {{/if}} + + {{#if google_analytics}} + <!-- Google Analytics Tag --> + <script type="text/javascript"> + var localAddrs = ["localhost", "127.0.0.1", ""]; + + // make sure we don't activate google analytics if the developer is + // inspecting the book locally... + if (localAddrs.indexOf(document.location.hostname) === -1) { + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); + + ga('create', '{{google_analytics}}', 'auto'); + ga('send', 'pageview'); + } + </script> + {{/if}} + + {{#if playground_line_numbers}} + <script type="text/javascript"> + window.playground_line_numbers = true; + </script> + {{/if}} + + {{#if playground_copyable}} + <script type="text/javascript"> + window.playground_copyable = true; + </script> + {{/if}} + + {{#if playground_js}} + <script src="{{ path_to_root }}ace.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}editor.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}mode-rust.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}theme-dawn.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}theme-tomorrow_night.js" type="text/javascript" charset="utf-8"></script> + {{/if}} + + {{#if search_js}} + <script src="{{ path_to_root }}elasticlunr.min.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}mark.min.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}searcher.js" type="text/javascript" charset="utf-8"></script> + {{/if}} + + <script src="{{ path_to_root }}clipboard.min.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}highlight.js" type="text/javascript" charset="utf-8"></script> + <script src="{{ path_to_root }}book.js" type="text/javascript" charset="utf-8"></script> + + <!-- Custom JS scripts --> + {{#each additional_js}} + <script type="text/javascript" src="{{ ../path_to_root }}{{this}}"></script> + {{/each}} + + {{#if is_print}} + {{#if mathjax_support}} + <script type="text/javascript"> + window.addEventListener('load', function() { + MathJax.Hub.Register.StartupHook('End', function() { + window.setTimeout(window.print, 100); + }); + }); + </script> + {{else}} + <script type="text/javascript"> + window.addEventListener('load', function() { + window.setTimeout(window.print, 100); + }); + </script> + {{/if}} + {{/if}} + + </body> +</html> diff --git a/vendor/mdbook/src/theme/mod.rs b/vendor/mdbook/src/theme/mod.rs new file mode 100644 index 000000000..a1ee18aff --- /dev/null +++ b/vendor/mdbook/src/theme/mod.rs @@ -0,0 +1,270 @@ +#![allow(missing_docs)] + +pub mod playground_editor; + +pub mod fonts; + +#[cfg(feature = "search")] +pub mod searcher; + +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use crate::errors::*; + +pub static INDEX: &[u8] = include_bytes!("index.hbs"); +pub static HEAD: &[u8] = include_bytes!("head.hbs"); +pub static REDIRECT: &[u8] = include_bytes!("redirect.hbs"); +pub static HEADER: &[u8] = include_bytes!("header.hbs"); +pub static CHROME_CSS: &[u8] = include_bytes!("css/chrome.css"); +pub static GENERAL_CSS: &[u8] = include_bytes!("css/general.css"); +pub static PRINT_CSS: &[u8] = include_bytes!("css/print.css"); +pub static VARIABLES_CSS: &[u8] = include_bytes!("css/variables.css"); +pub static FAVICON_PNG: &[u8] = include_bytes!("favicon.png"); +pub static FAVICON_SVG: &[u8] = include_bytes!("favicon.svg"); +pub static JS: &[u8] = include_bytes!("book.js"); +pub static HIGHLIGHT_JS: &[u8] = include_bytes!("highlight.js"); +pub static TOMORROW_NIGHT_CSS: &[u8] = include_bytes!("tomorrow-night.css"); +pub static HIGHLIGHT_CSS: &[u8] = include_bytes!("highlight.css"); +pub static AYU_HIGHLIGHT_CSS: &[u8] = include_bytes!("ayu-highlight.css"); +pub static CLIPBOARD_JS: &[u8] = include_bytes!("clipboard.min.js"); +pub static FONT_AWESOME: &[u8] = include_bytes!("FontAwesome/css/font-awesome.min.css"); +pub static FONT_AWESOME_EOT: &[u8] = include_bytes!("FontAwesome/fonts/fontawesome-webfont.eot"); +pub static FONT_AWESOME_SVG: &[u8] = include_bytes!("FontAwesome/fonts/fontawesome-webfont.svg"); +pub static FONT_AWESOME_TTF: &[u8] = include_bytes!("FontAwesome/fonts/fontawesome-webfont.ttf"); +pub static FONT_AWESOME_WOFF: &[u8] = include_bytes!("FontAwesome/fonts/fontawesome-webfont.woff"); +pub static FONT_AWESOME_WOFF2: &[u8] = + include_bytes!("FontAwesome/fonts/fontawesome-webfont.woff2"); +pub static FONT_AWESOME_OTF: &[u8] = include_bytes!("FontAwesome/fonts/FontAwesome.otf"); + +/// The `Theme` struct should be used instead of the static variables because +/// the `new()` method will look if the user has a theme directory in their +/// source folder and use the users theme instead of the default. +/// +/// You should only ever use the static variables directly if you want to +/// override the user's theme with the defaults. +#[derive(Debug, PartialEq)] +pub struct Theme { + pub index: Vec<u8>, + pub head: Vec<u8>, + pub redirect: Vec<u8>, + pub header: Vec<u8>, + pub chrome_css: Vec<u8>, + pub general_css: Vec<u8>, + pub print_css: Vec<u8>, + pub variables_css: Vec<u8>, + pub favicon_png: Option<Vec<u8>>, + pub favicon_svg: Option<Vec<u8>>, + pub js: Vec<u8>, + pub highlight_css: Vec<u8>, + pub tomorrow_night_css: Vec<u8>, + pub ayu_highlight_css: Vec<u8>, + pub highlight_js: Vec<u8>, + pub clipboard_js: Vec<u8>, +} + +impl Theme { + /// Creates a `Theme` from the given `theme_dir`. + /// If a file is found in the theme dir, it will override the default version. + pub fn new<P: AsRef<Path>>(theme_dir: P) -> Self { + let theme_dir = theme_dir.as_ref(); + let mut theme = Theme::default(); + + // If the theme directory doesn't exist there's no point continuing... + if !theme_dir.exists() || !theme_dir.is_dir() { + return theme; + } + + // Check for individual files, if they exist copy them across + { + let files = vec![ + (theme_dir.join("index.hbs"), &mut theme.index), + (theme_dir.join("head.hbs"), &mut theme.head), + (theme_dir.join("redirect.hbs"), &mut theme.redirect), + (theme_dir.join("header.hbs"), &mut theme.header), + (theme_dir.join("book.js"), &mut theme.js), + (theme_dir.join("css/chrome.css"), &mut theme.chrome_css), + (theme_dir.join("css/general.css"), &mut theme.general_css), + (theme_dir.join("css/print.css"), &mut theme.print_css), + ( + theme_dir.join("css/variables.css"), + &mut theme.variables_css, + ), + (theme_dir.join("highlight.js"), &mut theme.highlight_js), + (theme_dir.join("clipboard.min.js"), &mut theme.clipboard_js), + (theme_dir.join("highlight.css"), &mut theme.highlight_css), + ( + theme_dir.join("tomorrow-night.css"), + &mut theme.tomorrow_night_css, + ), + ( + theme_dir.join("ayu-highlight.css"), + &mut theme.ayu_highlight_css, + ), + ]; + + let load_with_warn = |filename: &Path, dest| { + if !filename.exists() { + // Don't warn if the file doesn't exist. + return false; + } + if let Err(e) = load_file_contents(filename, dest) { + warn!("Couldn't load custom file, {}: {}", filename.display(), e); + false + } else { + true + } + }; + + for (filename, dest) in files { + load_with_warn(&filename, dest); + } + + // If the user overrides one favicon, but not the other, do not + // copy the default for the other. + let favicon_png = &mut theme.favicon_png.as_mut().unwrap(); + let png = load_with_warn(&theme_dir.join("favicon.png"), favicon_png); + let favicon_svg = &mut theme.favicon_svg.as_mut().unwrap(); + let svg = load_with_warn(&theme_dir.join("favicon.svg"), favicon_svg); + match (png, svg) { + (true, true) | (false, false) => {} + (true, false) => { + theme.favicon_svg = None; + } + (false, true) => { + theme.favicon_png = None; + } + } + } + + theme + } +} + +impl Default for Theme { + fn default() -> Theme { + Theme { + index: INDEX.to_owned(), + head: HEAD.to_owned(), + redirect: REDIRECT.to_owned(), + header: HEADER.to_owned(), + chrome_css: CHROME_CSS.to_owned(), + general_css: GENERAL_CSS.to_owned(), + print_css: PRINT_CSS.to_owned(), + variables_css: VARIABLES_CSS.to_owned(), + favicon_png: Some(FAVICON_PNG.to_owned()), + favicon_svg: Some(FAVICON_SVG.to_owned()), + js: JS.to_owned(), + highlight_css: HIGHLIGHT_CSS.to_owned(), + tomorrow_night_css: TOMORROW_NIGHT_CSS.to_owned(), + ayu_highlight_css: AYU_HIGHLIGHT_CSS.to_owned(), + highlight_js: HIGHLIGHT_JS.to_owned(), + clipboard_js: CLIPBOARD_JS.to_owned(), + } + } +} + +/// Checks if a file exists, if so, the destination buffer will be filled with +/// its contents. +fn load_file_contents<P: AsRef<Path>>(filename: P, dest: &mut Vec<u8>) -> Result<()> { + let filename = filename.as_ref(); + + let mut buffer = Vec::new(); + File::open(filename)?.read_to_end(&mut buffer)?; + + // We needed the buffer so we'd only overwrite the existing content if we + // could successfully load the file into memory. + dest.clear(); + dest.append(&mut buffer); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + use tempfile::Builder as TempFileBuilder; + + #[test] + fn theme_uses_defaults_with_nonexistent_src_dir() { + let non_existent = PathBuf::from("/non/existent/directory/"); + assert!(!non_existent.exists()); + + let should_be = Theme::default(); + let got = Theme::new(&non_existent); + + assert_eq!(got, should_be); + } + + #[test] + fn theme_dir_overrides_defaults() { + let files = [ + "index.hbs", + "head.hbs", + "redirect.hbs", + "header.hbs", + "favicon.png", + "favicon.svg", + "css/chrome.css", + "css/fonts.css", + "css/general.css", + "css/print.css", + "css/variables.css", + "book.js", + "highlight.js", + "tomorrow-night.css", + "highlight.css", + "ayu-highlight.css", + "clipboard.min.js", + ]; + + let temp = TempFileBuilder::new().prefix("mdbook-").tempdir().unwrap(); + fs::create_dir(temp.path().join("css")).unwrap(); + + // "touch" all of the special files so we have empty copies + for file in &files { + File::create(&temp.path().join(file)).unwrap(); + } + + let got = Theme::new(temp.path()); + + let empty = Theme { + index: Vec::new(), + head: Vec::new(), + redirect: Vec::new(), + header: Vec::new(), + chrome_css: Vec::new(), + general_css: Vec::new(), + print_css: Vec::new(), + variables_css: Vec::new(), + favicon_png: Some(Vec::new()), + favicon_svg: Some(Vec::new()), + js: Vec::new(), + highlight_css: Vec::new(), + tomorrow_night_css: Vec::new(), + ayu_highlight_css: Vec::new(), + highlight_js: Vec::new(), + clipboard_js: Vec::new(), + }; + + assert_eq!(got, empty); + } + + #[test] + fn favicon_override() { + let temp = TempFileBuilder::new().prefix("mdbook-").tempdir().unwrap(); + fs::write(temp.path().join("favicon.png"), "1234").unwrap(); + let got = Theme::new(temp.path()); + assert_eq!(got.favicon_png.as_ref().unwrap(), b"1234"); + assert_eq!(got.favicon_svg, None); + + let temp = TempFileBuilder::new().prefix("mdbook-").tempdir().unwrap(); + fs::write(temp.path().join("favicon.svg"), "4567").unwrap(); + let got = Theme::new(temp.path()); + assert_eq!(got.favicon_png, None); + assert_eq!(got.favicon_svg.as_ref().unwrap(), b"4567"); + } +} diff --git a/vendor/mdbook/src/theme/redirect.hbs b/vendor/mdbook/src/theme/redirect.hbs new file mode 100644 index 000000000..9f49e6d09 --- /dev/null +++ b/vendor/mdbook/src/theme/redirect.hbs @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Redirecting...</title> + <meta http-equiv="refresh" content="0;URL='{{url}}'"> + <meta rel="canonical" href="{{url}}"> + </head> + <body> + <p>Redirecting to... <a href="{{url}}">{{url}}</a>.</p> + </body> +</html> diff --git a/vendor/mdbook/src/theme/searcher/mod.rs b/vendor/mdbook/src/theme/searcher/mod.rs new file mode 100644 index 000000000..d5029db16 --- /dev/null +++ b/vendor/mdbook/src/theme/searcher/mod.rs @@ -0,0 +1,6 @@ +//! Theme dependencies for in-browser search. Not included in mdbook when +//! the "search" cargo feature is disabled. + +pub static JS: &[u8] = include_bytes!("searcher.js"); +pub static MARK_JS: &[u8] = include_bytes!("mark.min.js"); +pub static ELASTICLUNR_JS: &[u8] = include_bytes!("elasticlunr.min.js"); diff --git a/vendor/mdbook/src/theme/searcher/searcher.js b/vendor/mdbook/src/theme/searcher/searcher.js new file mode 100644 index 000000000..d2b0aeed3 --- /dev/null +++ b/vendor/mdbook/src/theme/searcher/searcher.js @@ -0,0 +1,483 @@ +"use strict"; +window.search = window.search || {}; +(function search(search) { + // Search functionality + // + // You can use !hasFocus() to prevent keyhandling in your key + // event handlers while the user is typing their search. + + if (!Mark || !elasticlunr) { + return; + } + + //IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith + if (!String.prototype.startsWith) { + String.prototype.startsWith = function(search, pos) { + return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search; + }; + } + + var search_wrap = document.getElementById('search-wrapper'), + searchbar = document.getElementById('searchbar'), + searchbar_outer = document.getElementById('searchbar-outer'), + searchresults = document.getElementById('searchresults'), + searchresults_outer = document.getElementById('searchresults-outer'), + searchresults_header = document.getElementById('searchresults-header'), + searchicon = document.getElementById('search-toggle'), + content = document.getElementById('content'), + + searchindex = null, + doc_urls = [], + results_options = { + teaser_word_count: 30, + limit_results: 30, + }, + search_options = { + bool: "AND", + expand: true, + fields: { + title: {boost: 1}, + body: {boost: 1}, + breadcrumbs: {boost: 0} + } + }, + mark_exclude = [], + marker = new Mark(content), + current_searchterm = "", + URL_SEARCH_PARAM = 'search', + URL_MARK_PARAM = 'highlight', + teaser_count = 0, + + SEARCH_HOTKEY_KEYCODE = 83, + ESCAPE_KEYCODE = 27, + DOWN_KEYCODE = 40, + UP_KEYCODE = 38, + SELECT_KEYCODE = 13; + + function hasFocus() { + return searchbar === document.activeElement; + } + + function removeChildren(elem) { + while (elem.firstChild) { + elem.removeChild(elem.firstChild); + } + } + + // Helper to parse a url into its building blocks. + function parseURL(url) { + var a = document.createElement('a'); + a.href = url; + return { + source: url, + protocol: a.protocol.replace(':',''), + host: a.hostname, + port: a.port, + params: (function(){ + var ret = {}; + var seg = a.search.replace(/^\?/,'').split('&'); + var len = seg.length, i = 0, s; + for (;i<len;i++) { + if (!seg[i]) { continue; } + s = seg[i].split('='); + ret[s[0]] = s[1]; + } + return ret; + })(), + file: (a.pathname.match(/\/([^/?#]+)$/i) || [,''])[1], + hash: a.hash.replace('#',''), + path: a.pathname.replace(/^([^/])/,'/$1') + }; + } + + // Helper to recreate a url string from its building blocks. + function renderURL(urlobject) { + var url = urlobject.protocol + "://" + urlobject.host; + if (urlobject.port != "") { + url += ":" + urlobject.port; + } + url += urlobject.path; + var joiner = "?"; + for(var prop in urlobject.params) { + if(urlobject.params.hasOwnProperty(prop)) { + url += joiner + prop + "=" + urlobject.params[prop]; + joiner = "&"; + } + } + if (urlobject.hash != "") { + url += "#" + urlobject.hash; + } + return url; + } + + // Helper to escape html special chars for displaying the teasers + var escapeHTML = (function() { + var MAP = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + var repl = function(c) { return MAP[c]; }; + return function(s) { + return s.replace(/[&<>'"]/g, repl); + }; + })(); + + function formatSearchMetric(count, searchterm) { + if (count == 1) { + return count + " search result for '" + searchterm + "':"; + } else if (count == 0) { + return "No search results for '" + searchterm + "'."; + } else { + return count + " search results for '" + searchterm + "':"; + } + } + + function formatSearchResult(result, searchterms) { + var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms); + teaser_count++; + + // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor + var url = doc_urls[result.ref].split("#"); + if (url.length == 1) { // no anchor found + url.push(""); + } + + // encodeURIComponent escapes all chars that could allow an XSS except + // for '. Due to that we also manually replace ' with its url-encoded + // representation (%27). + var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27"); + + return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + url[1] + + '" aria-details="teaser_' + teaser_count + '">' + result.doc.breadcrumbs + '</a>' + + '<span class="teaser" id="teaser_' + teaser_count + '" aria-label="Search Result Teaser">' + + teaser + '</span>'; + } + + function makeTeaser(body, searchterms) { + // The strategy is as follows: + // First, assign a value to each word in the document: + // Words that correspond to search terms (stemmer aware): 40 + // Normal words: 2 + // First word in a sentence: 8 + // Then use a sliding window with a constant number of words and count the + // sum of the values of the words within the window. Then use the window that got the + // maximum sum. If there are multiple maximas, then get the last one. + // Enclose the terms in <em>. + var stemmed_searchterms = searchterms.map(function(w) { + return elasticlunr.stemmer(w.toLowerCase()); + }); + var searchterm_weight = 40; + var weighted = []; // contains elements of ["word", weight, index_in_document] + // split in sentences, then words + var sentences = body.toLowerCase().split('. '); + var index = 0; + var value = 0; + var searchterm_found = false; + for (var sentenceindex in sentences) { + var words = sentences[sentenceindex].split(' '); + value = 8; + for (var wordindex in words) { + var word = words[wordindex]; + if (word.length > 0) { + for (var searchtermindex in stemmed_searchterms) { + if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) { + value = searchterm_weight; + searchterm_found = true; + } + }; + weighted.push([word, value, index]); + value = 2; + } + index += word.length; + index += 1; // ' ' or '.' if last word in sentence + }; + index += 1; // because we split at a two-char boundary '. ' + }; + + if (weighted.length == 0) { + return body; + } + + var window_weight = []; + var window_size = Math.min(weighted.length, results_options.teaser_word_count); + + var cur_sum = 0; + for (var wordindex = 0; wordindex < window_size; wordindex++) { + cur_sum += weighted[wordindex][1]; + }; + window_weight.push(cur_sum); + for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) { + cur_sum -= weighted[wordindex][1]; + cur_sum += weighted[wordindex + window_size][1]; + window_weight.push(cur_sum); + }; + + if (searchterm_found) { + var max_sum = 0; + var max_sum_window_index = 0; + // backwards + for (var i = window_weight.length - 1; i >= 0; i--) { + if (window_weight[i] > max_sum) { + max_sum = window_weight[i]; + max_sum_window_index = i; + } + }; + } else { + max_sum_window_index = 0; + } + + // add <em/> around searchterms + var teaser_split = []; + var index = weighted[max_sum_window_index][2]; + for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) { + var word = weighted[i]; + if (index < word[2]) { + // missing text from index to start of `word` + teaser_split.push(body.substring(index, word[2])); + index = word[2]; + } + if (word[1] == searchterm_weight) { + teaser_split.push("<em>") + } + index = word[2] + word[0].length; + teaser_split.push(body.substring(word[2], index)); + if (word[1] == searchterm_weight) { + teaser_split.push("</em>") + } + }; + + return teaser_split.join(''); + } + + function init(config) { + results_options = config.results_options; + search_options = config.search_options; + searchbar_outer = config.searchbar_outer; + doc_urls = config.doc_urls; + searchindex = elasticlunr.Index.load(config.index); + + // Set up events + searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false); + searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false); + document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false); + // If the user uses the browser buttons, do the same as if a reload happened + window.onpopstate = function(e) { doSearchOrMarkFromUrl(); }; + // Suppress "submit" events so the page doesn't reload when the user presses Enter + document.addEventListener('submit', function(e) { e.preventDefault(); }, false); + + // If reloaded, do the search or mark again, depending on the current url parameters + doSearchOrMarkFromUrl(); + } + + function unfocusSearchbar() { + // hacky, but just focusing a div only works once + var tmp = document.createElement('input'); + tmp.setAttribute('style', 'position: absolute; opacity: 0;'); + searchicon.appendChild(tmp); + tmp.focus(); + tmp.remove(); + } + + // On reload or browser history backwards/forwards events, parse the url and do search or mark + function doSearchOrMarkFromUrl() { + // Check current URL for search request + var url = parseURL(window.location.href); + if (url.params.hasOwnProperty(URL_SEARCH_PARAM) + && url.params[URL_SEARCH_PARAM] != "") { + showSearch(true); + searchbar.value = decodeURIComponent( + (url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20')); + searchbarKeyUpHandler(); // -> doSearch() + } else { + showSearch(false); + } + + if (url.params.hasOwnProperty(URL_MARK_PARAM)) { + var words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' '); + marker.mark(words, { + exclude: mark_exclude + }); + + var markers = document.querySelectorAll("mark"); + function hide() { + for (var i = 0; i < markers.length; i++) { + markers[i].classList.add("fade-out"); + window.setTimeout(function(e) { marker.unmark(); }, 300); + } + } + for (var i = 0; i < markers.length; i++) { + markers[i].addEventListener('click', hide); + } + } + } + + // Eventhandler for keyevents on `document` + function globalKeyHandler(e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.target.type === 'textarea' || e.target.type === 'text') { return; } + + if (e.keyCode === ESCAPE_KEYCODE) { + e.preventDefault(); + searchbar.classList.remove("active"); + setSearchUrlParameters("", + (searchbar.value.trim() !== "") ? "push" : "replace"); + if (hasFocus()) { + unfocusSearchbar(); + } + showSearch(false); + marker.unmark(); + } else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) { + e.preventDefault(); + showSearch(true); + window.scrollTo(0, 0); + searchbar.select(); + } else if (hasFocus() && e.keyCode === DOWN_KEYCODE) { + e.preventDefault(); + unfocusSearchbar(); + searchresults.firstElementChild.classList.add("focus"); + } else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE + || e.keyCode === UP_KEYCODE + || e.keyCode === SELECT_KEYCODE)) { + // not `:focus` because browser does annoying scrolling + var focused = searchresults.querySelector("li.focus"); + if (!focused) return; + e.preventDefault(); + if (e.keyCode === DOWN_KEYCODE) { + var next = focused.nextElementSibling; + if (next) { + focused.classList.remove("focus"); + next.classList.add("focus"); + } + } else if (e.keyCode === UP_KEYCODE) { + focused.classList.remove("focus"); + var prev = focused.previousElementSibling; + if (prev) { + prev.classList.add("focus"); + } else { + searchbar.select(); + } + } else { // SELECT_KEYCODE + window.location.assign(focused.querySelector('a')); + } + } + } + + function showSearch(yes) { + if (yes) { + search_wrap.classList.remove('hidden'); + searchicon.setAttribute('aria-expanded', 'true'); + } else { + search_wrap.classList.add('hidden'); + searchicon.setAttribute('aria-expanded', 'false'); + var results = searchresults.children; + for (var i = 0; i < results.length; i++) { + results[i].classList.remove("focus"); + } + } + } + + function showResults(yes) { + if (yes) { + searchresults_outer.classList.remove('hidden'); + } else { + searchresults_outer.classList.add('hidden'); + } + } + + // Eventhandler for search icon + function searchIconClickHandler() { + if (search_wrap.classList.contains('hidden')) { + showSearch(true); + window.scrollTo(0, 0); + searchbar.select(); + } else { + showSearch(false); + } + } + + // Eventhandler for keyevents while the searchbar is focused + function searchbarKeyUpHandler() { + var searchterm = searchbar.value.trim(); + if (searchterm != "") { + searchbar.classList.add("active"); + doSearch(searchterm); + } else { + searchbar.classList.remove("active"); + showResults(false); + removeChildren(searchresults); + } + + setSearchUrlParameters(searchterm, "push_if_new_search_else_replace"); + + // Remove marks + marker.unmark(); + } + + // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor . + // `action` can be one of "push", "replace", "push_if_new_search_else_replace" + // and replaces or pushes a new browser history item. + // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet. + function setSearchUrlParameters(searchterm, action) { + var url = parseURL(window.location.href); + var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM); + if (searchterm != "" || action == "push_if_new_search_else_replace") { + url.params[URL_SEARCH_PARAM] = searchterm; + delete url.params[URL_MARK_PARAM]; + url.hash = ""; + } else { + delete url.params[URL_MARK_PARAM]; + delete url.params[URL_SEARCH_PARAM]; + } + // A new search will also add a new history item, so the user can go back + // to the page prior to searching. A updated search term will only replace + // the url. + if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) { + history.pushState({}, document.title, renderURL(url)); + } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) { + history.replaceState({}, document.title, renderURL(url)); + } + } + + function doSearch(searchterm) { + + // Don't search the same twice + if (current_searchterm == searchterm) { return; } + else { current_searchterm = searchterm; } + + if (searchindex == null) { return; } + + // Do the actual search + var results = searchindex.search(searchterm, search_options); + var resultcount = Math.min(results.length, results_options.limit_results); + + // Display search metrics + searchresults_header.innerText = formatSearchMetric(resultcount, searchterm); + + // Clear and insert results + var searchterms = searchterm.split(' '); + removeChildren(searchresults); + for(var i = 0; i < resultcount ; i++){ + var resultElem = document.createElement('li'); + resultElem.innerHTML = formatSearchResult(results[i], searchterms); + searchresults.appendChild(resultElem); + } + + // Display results + showResults(true); + } + + fetch(path_to_root + 'searchindex.json') + .then(response => response.json()) + .then(json => init(json)) + .catch(error => { // Try to load searchindex.js if fetch failed + var script = document.createElement('script'); + script.src = path_to_root + 'searchindex.js'; + script.onload = () => init(window.search); + document.head.appendChild(script); + }); + + // Exported functions + search.hasFocus = hasFocus; +})(window.search); diff --git a/vendor/mdbook/src/theme/tomorrow-night.css b/vendor/mdbook/src/theme/tomorrow-night.css new file mode 100644 index 000000000..5b4aca77c --- /dev/null +++ b/vendor/mdbook/src/theme/tomorrow-night.css @@ -0,0 +1,102 @@ +/* Tomorrow Night Theme */ +/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ +/* Original theme - https://github.com/chriskempson/tomorrow-theme */ +/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */ + +/* Tomorrow Comment */ +.hljs-comment { + color: #969896; +} + +/* Tomorrow Red */ +.hljs-variable, +.hljs-attribute, +.hljs-tag, +.hljs-regexp, +.ruby .hljs-constant, +.xml .hljs-tag .hljs-title, +.xml .hljs-pi, +.xml .hljs-doctype, +.html .hljs-doctype, +.css .hljs-id, +.css .hljs-class, +.css .hljs-pseudo { + color: #cc6666; +} + +/* Tomorrow Orange */ +.hljs-number, +.hljs-preprocessor, +.hljs-pragma, +.hljs-built_in, +.hljs-literal, +.hljs-params, +.hljs-constant { + color: #de935f; +} + +/* Tomorrow Yellow */ +.ruby .hljs-class .hljs-title, +.css .hljs-rule .hljs-attribute { + color: #f0c674; +} + +/* Tomorrow Green */ +.hljs-string, +.hljs-value, +.hljs-inheritance, +.hljs-header, +.hljs-name, +.ruby .hljs-symbol, +.xml .hljs-cdata { + color: #b5bd68; +} + +/* Tomorrow Aqua */ +.hljs-title, +.css .hljs-hexcolor { + color: #8abeb7; +} + +/* Tomorrow Blue */ +.hljs-function, +.python .hljs-decorator, +.python .hljs-title, +.ruby .hljs-function .hljs-title, +.ruby .hljs-title .hljs-keyword, +.perl .hljs-sub, +.javascript .hljs-title, +.coffeescript .hljs-title { + color: #81a2be; +} + +/* Tomorrow Purple */ +.hljs-keyword, +.javascript .hljs-function { + color: #b294bb; +} + +.hljs { + display: block; + overflow-x: auto; + background: #1d1f21; + color: #c5c8c6; +} + +.coffeescript .javascript, +.javascript .xml, +.tex .hljs-formula, +.xml .javascript, +.xml .vbscript, +.xml .css, +.xml .hljs-cdata { + opacity: 0.5; +} + +.hljs-addition { + color: #718c00; +} + +.hljs-deletion { + color: #c82829; +} diff --git a/vendor/mdbook/src/utils/fs.rs b/vendor/mdbook/src/utils/fs.rs new file mode 100644 index 000000000..a933d548a --- /dev/null +++ b/vendor/mdbook/src/utils/fs.rs @@ -0,0 +1,275 @@ +use crate::errors::*; +use std::convert::Into; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +/// Naively replaces any path separator with a forward-slash '/' +pub fn normalize_path(path: &str) -> String { + use std::path::is_separator; + path.chars() + .map(|ch| if is_separator(ch) { '/' } else { ch }) + .collect::<String>() +} + +/// Write the given data to a file, creating it first if necessary +pub fn write_file<P: AsRef<Path>>(build_dir: &Path, filename: P, content: &[u8]) -> Result<()> { + let path = build_dir.join(filename); + + create_file(&path)?.write_all(content).map_err(Into::into) +} + +/// Takes a path and returns a path containing just enough `../` to point to +/// the root of the given path. +/// +/// This is mostly interesting for a relative path to point back to the +/// directory from where the path starts. +/// +/// ```rust +/// # use std::path::Path; +/// # use mdbook::utils::fs::path_to_root; +/// let path = Path::new("some/relative/path"); +/// assert_eq!(path_to_root(path), "../../"); +/// ``` +/// +/// **note:** it's not very fool-proof, if you find a situation where +/// it doesn't return the correct path. +/// Consider [submitting a new issue](https://github.com/rust-lang/mdBook/issues) +/// or a [pull-request](https://github.com/rust-lang/mdBook/pulls) to improve it. +pub fn path_to_root<P: Into<PathBuf>>(path: P) -> String { + debug!("path_to_root"); + // Remove filename and add "../" for every directory + + path.into() + .parent() + .expect("") + .components() + .fold(String::new(), |mut s, c| { + match c { + Component::Normal(_) => s.push_str("../"), + _ => { + debug!("Other path component... {:?}", c); + } + } + s + }) +} + +/// This function creates a file and returns it. But before creating the file +/// it checks every directory in the path to see if it exists, +/// and if it does not it will be created. +pub fn create_file(path: &Path) -> Result<File> { + debug!("Creating {}", path.display()); + + // Construct path + if let Some(p) = path.parent() { + trace!("Parent directory is: {:?}", p); + + fs::create_dir_all(p)?; + } + + File::create(path).map_err(Into::into) +} + +/// Removes all the content of a directory but not the directory itself +pub fn remove_dir_content(dir: &Path) -> Result<()> { + for item in fs::read_dir(dir)? { + if let Ok(item) = item { + let item = item.path(); + if item.is_dir() { + fs::remove_dir_all(item)?; + } else { + fs::remove_file(item)?; + } + } + } + Ok(()) +} + +/// Copies all files of a directory to another one except the files +/// with the extensions given in the `ext_blacklist` array +pub fn copy_files_except_ext( + from: &Path, + to: &Path, + recursive: bool, + avoid_dir: Option<&PathBuf>, + ext_blacklist: &[&str], +) -> Result<()> { + debug!( + "Copying all files from {} to {} (blacklist: {:?}), avoiding {:?}", + from.display(), + to.display(), + ext_blacklist, + avoid_dir + ); + + // Check that from and to are different + if from == to { + return Ok(()); + } + + for entry in fs::read_dir(from)? { + let entry = entry?; + let metadata = entry + .path() + .metadata() + .with_context(|| format!("Failed to read {:?}", entry.path()))?; + + // If the entry is a dir and the recursive option is enabled, call itself + if metadata.is_dir() && recursive { + if entry.path() == to.to_path_buf() { + continue; + } + + if let Some(avoid) = avoid_dir { + if entry.path() == *avoid { + continue; + } + } + + // check if output dir already exists + if !to.join(entry.file_name()).exists() { + fs::create_dir(&to.join(entry.file_name()))?; + } + + copy_files_except_ext( + &from.join(entry.file_name()), + &to.join(entry.file_name()), + true, + avoid_dir, + ext_blacklist, + )?; + } else if metadata.is_file() { + // Check if it is in the blacklist + if let Some(ext) = entry.path().extension() { + if ext_blacklist.contains(&ext.to_str().unwrap()) { + continue; + } + } + debug!( + "creating path for file: {:?}", + &to.join( + entry + .path() + .file_name() + .expect("a file should have a file name...") + ) + ); + + debug!( + "Copying {:?} to {:?}", + entry.path(), + &to.join( + entry + .path() + .file_name() + .expect("a file should have a file name...") + ) + ); + fs::copy( + entry.path(), + &to.join( + entry + .path() + .file_name() + .expect("a file should have a file name..."), + ), + )?; + } + } + Ok(()) +} + +pub fn get_404_output_file(input_404: &Option<String>) -> String { + input_404 + .as_ref() + .unwrap_or(&"404.md".to_string()) + .replace(".md", ".html") +} + +#[cfg(test)] +mod tests { + use super::copy_files_except_ext; + use std::{fs, io::Result, path::Path}; + + #[cfg(target_os = "windows")] + fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(src: P, dst: Q) -> Result<()> { + std::os::windows::fs::symlink_file(src, dst) + } + + #[cfg(not(target_os = "windows"))] + fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(src: P, dst: Q) -> Result<()> { + std::os::unix::fs::symlink(src, dst) + } + + #[test] + fn copy_files_except_ext_test() { + let tmp = match tempfile::TempDir::new() { + Ok(t) => t, + Err(e) => panic!("Could not create a temp dir: {}", e), + }; + + // Create a couple of files + if let Err(err) = fs::File::create(&tmp.path().join("file.txt")) { + panic!("Could not create file.txt: {}", err); + } + if let Err(err) = fs::File::create(&tmp.path().join("file.md")) { + panic!("Could not create file.md: {}", err); + } + if let Err(err) = fs::File::create(&tmp.path().join("file.png")) { + panic!("Could not create file.png: {}", err); + } + if let Err(err) = fs::create_dir(&tmp.path().join("sub_dir")) { + panic!("Could not create sub_dir: {}", err); + } + if let Err(err) = fs::File::create(&tmp.path().join("sub_dir/file.png")) { + panic!("Could not create sub_dir/file.png: {}", err); + } + if let Err(err) = fs::create_dir(&tmp.path().join("sub_dir_exists")) { + panic!("Could not create sub_dir_exists: {}", err); + } + if let Err(err) = fs::File::create(&tmp.path().join("sub_dir_exists/file.txt")) { + panic!("Could not create sub_dir_exists/file.txt: {}", err); + } + if let Err(err) = symlink( + &tmp.path().join("file.png"), + &tmp.path().join("symlink.png"), + ) { + panic!("Could not symlink file.png: {}", err); + } + + // Create output dir + if let Err(err) = fs::create_dir(&tmp.path().join("output")) { + panic!("Could not create output: {}", err); + } + if let Err(err) = fs::create_dir(&tmp.path().join("output/sub_dir_exists")) { + panic!("Could not create output/sub_dir_exists: {}", err); + } + + if let Err(e) = + copy_files_except_ext(tmp.path(), &tmp.path().join("output"), true, None, &["md"]) + { + panic!("Error while executing the function:\n{:?}", e); + } + + // Check if the correct files where created + if !(&tmp.path().join("output/file.txt")).exists() { + panic!("output/file.txt should exist") + } + if (&tmp.path().join("output/file.md")).exists() { + panic!("output/file.md should not exist") + } + if !(&tmp.path().join("output/file.png")).exists() { + panic!("output/file.png should exist") + } + if !(&tmp.path().join("output/sub_dir/file.png")).exists() { + panic!("output/sub_dir/file.png should exist") + } + if !(&tmp.path().join("output/sub_dir_exists/file.txt")).exists() { + panic!("output/sub_dir/file.png should exist") + } + if !(&tmp.path().join("output/symlink.png")).exists() { + panic!("output/symlink.png should exist") + } + } +} diff --git a/vendor/mdbook/src/utils/mod.rs b/vendor/mdbook/src/utils/mod.rs new file mode 100644 index 000000000..a205633f9 --- /dev/null +++ b/vendor/mdbook/src/utils/mod.rs @@ -0,0 +1,494 @@ +#![allow(missing_docs)] // FIXME: Document this + +pub mod fs; +mod string; +pub(crate) mod toml_ext; +use crate::errors::Error; +use regex::Regex; + +use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, Options, Parser, Tag}; + +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt::Write; +use std::path::Path; + +pub use self::string::{ + take_anchored_lines, take_lines, take_rustdoc_include_anchored_lines, + take_rustdoc_include_lines, +}; + +/// Replaces multiple consecutive whitespace characters with a single space character. +pub fn collapse_whitespace(text: &str) -> Cow<'_, str> { + lazy_static! { + static ref RE: Regex = Regex::new(r"\s\s+").unwrap(); + } + RE.replace_all(text, " ") +} + +/// Convert the given string to a valid HTML element ID. +/// The only restriction is that the ID must not contain any ASCII whitespace. +pub fn normalize_id(content: &str) -> String { + content + .chars() + .filter_map(|ch| { + if ch.is_alphanumeric() || ch == '_' || ch == '-' { + Some(ch.to_ascii_lowercase()) + } else if ch.is_whitespace() { + Some('-') + } else { + None + } + }) + .collect::<String>() +} + +/// Generate an ID for use with anchors which is derived from a "normalised" +/// string. +// This function should be made private when the deprecation expires. +#[deprecated(since = "0.4.16", note = "use unique_id_from_content instead")] +pub fn id_from_content(content: &str) -> String { + let mut content = content.to_string(); + + // Skip any tags or html-encoded stuff + lazy_static! { + static ref HTML: Regex = Regex::new(r"(<.*?>)").unwrap(); + } + content = HTML.replace_all(&content, "").into(); + const REPL_SUB: &[&str] = &["<", ">", "&", "'", """]; + for sub in REPL_SUB { + content = content.replace(sub, ""); + } + + // Remove spaces and hashes indicating a header + let trimmed = content.trim().trim_start_matches('#').trim(); + normalize_id(trimmed) +} + +/// Generate an ID for use with anchors which is derived from a "normalised" +/// string. +/// +/// Each ID returned will be unique, if the same `id_counter` is provided on +/// each call. +pub fn unique_id_from_content(content: &str, id_counter: &mut HashMap<String, usize>) -> String { + let id = { + #[allow(deprecated)] + id_from_content(content) + }; + + // If we have headers with the same normalized id, append an incrementing counter + let id_count = id_counter.entry(id.clone()).or_insert(0); + let unique_id = match *id_count { + 0 => id, + id_count => format!("{}-{}", id, id_count), + }; + *id_count += 1; + unique_id +} + +/// Fix links to the correct location. +/// +/// This adjusts links, such as turning `.md` extensions to `.html`. +/// +/// `path` is the path to the page being rendered relative to the root of the +/// book. This is used for the `print.html` page so that links on the print +/// page go to the original location. Normal page rendering sets `path` to +/// None. Ideally, print page links would link to anchors on the print page, +/// but that is very difficult. +fn adjust_links<'a>(event: Event<'a>, path: Option<&Path>) -> Event<'a> { + lazy_static! { + static ref SCHEME_LINK: Regex = Regex::new(r"^[a-z][a-z0-9+.-]*:").unwrap(); + static ref MD_LINK: Regex = Regex::new(r"(?P<link>.*)\.md(?P<anchor>#.*)?").unwrap(); + } + + fn fix<'a>(dest: CowStr<'a>, path: Option<&Path>) -> CowStr<'a> { + if dest.starts_with('#') { + // Fragment-only link. + if let Some(path) = path { + let mut base = path.display().to_string(); + if base.ends_with(".md") { + base.replace_range(base.len() - 3.., ".html"); + } + return format!("{}{}", base, dest).into(); + } else { + return dest; + } + } + // Don't modify links with schemes like `https`. + if !SCHEME_LINK.is_match(&dest) { + // This is a relative link, adjust it as necessary. + let mut fixed_link = String::new(); + if let Some(path) = path { + let base = path + .parent() + .expect("path can't be empty") + .to_str() + .expect("utf-8 paths only"); + if !base.is_empty() { + write!(fixed_link, "{}/", base).unwrap(); + } + } + + if let Some(caps) = MD_LINK.captures(&dest) { + fixed_link.push_str(&caps["link"]); + fixed_link.push_str(".html"); + if let Some(anchor) = caps.name("anchor") { + fixed_link.push_str(anchor.as_str()); + } + } else { + fixed_link.push_str(&dest); + }; + return CowStr::from(fixed_link); + } + dest + } + + fn fix_html<'a>(html: CowStr<'a>, path: Option<&Path>) -> CowStr<'a> { + // This is a terrible hack, but should be reasonably reliable. Nobody + // should ever parse a tag with a regex. However, there isn't anything + // in Rust that I know of that is suitable for handling partial html + // fragments like those generated by pulldown_cmark. + // + // There are dozens of HTML tags/attributes that contain paths, so + // feel free to add more tags if desired; these are the only ones I + // care about right now. + lazy_static! { + static ref HTML_LINK: Regex = + Regex::new(r#"(<(?:a|img) [^>]*?(?:src|href)=")([^"]+?)""#).unwrap(); + } + + HTML_LINK + .replace_all(&html, |caps: ®ex::Captures<'_>| { + let fixed = fix(caps[2].into(), path); + format!("{}{}\"", &caps[1], fixed) + }) + .into_owned() + .into() + } + + match event { + Event::Start(Tag::Link(link_type, dest, title)) => { + Event::Start(Tag::Link(link_type, fix(dest, path), title)) + } + Event::Start(Tag::Image(link_type, dest, title)) => { + Event::Start(Tag::Image(link_type, fix(dest, path), title)) + } + Event::Html(html) => Event::Html(fix_html(html, path)), + _ => event, + } +} + +/// Wrapper around the pulldown-cmark parser for rendering markdown to HTML. +pub fn render_markdown(text: &str, curly_quotes: bool) -> String { + render_markdown_with_path(text, curly_quotes, None) +} + +pub fn new_cmark_parser(text: &str, curly_quotes: bool) -> Parser<'_, '_> { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_TABLES); + opts.insert(Options::ENABLE_FOOTNOTES); + opts.insert(Options::ENABLE_STRIKETHROUGH); + opts.insert(Options::ENABLE_TASKLISTS); + if curly_quotes { + opts.insert(Options::ENABLE_SMART_PUNCTUATION); + } + Parser::new_ext(text, opts) +} + +pub fn render_markdown_with_path(text: &str, curly_quotes: bool, path: Option<&Path>) -> String { + let mut s = String::with_capacity(text.len() * 3 / 2); + let p = new_cmark_parser(text, curly_quotes); + let events = p + .map(clean_codeblock_headers) + .map(|event| adjust_links(event, path)) + .flat_map(|event| { + let (a, b) = wrap_tables(event); + a.into_iter().chain(b) + }); + + html::push_html(&mut s, events); + s +} + +/// Wraps tables in a `.table-wrapper` class to apply overflow-x rules to. +fn wrap_tables(event: Event<'_>) -> (Option<Event<'_>>, Option<Event<'_>>) { + match event { + Event::Start(Tag::Table(_)) => ( + Some(Event::Html(r#"<div class="table-wrapper">"#.into())), + Some(event), + ), + Event::End(Tag::Table(_)) => (Some(event), Some(Event::Html(r#"</div>"#.into()))), + _ => (Some(event), None), + } +} + +fn clean_codeblock_headers(event: Event<'_>) -> Event<'_> { + match event { + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(ref info))) => { + let info: String = info + .chars() + .map(|x| match x { + ' ' | '\t' => ',', + _ => x, + }) + .filter(|ch| !ch.is_whitespace()) + .collect(); + + Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(CowStr::from(info)))) + } + _ => event, + } +} + +/// Prints a "backtrace" of some `Error`. +pub fn log_backtrace(e: &Error) { + error!("Error: {}", e); + + for cause in e.chain().skip(1) { + error!("\tCaused By: {}", cause); + } +} + +pub(crate) fn bracket_escape(mut s: &str) -> String { + let mut escaped = String::with_capacity(s.len()); + let needs_escape: &[char] = &['<', '>']; + while let Some(next) = s.find(needs_escape) { + escaped.push_str(&s[..next]); + match s.as_bytes()[next] { + b'<' => escaped.push_str("<"), + b'>' => escaped.push_str(">"), + _ => unreachable!(), + } + s = &s[next + 1..]; + } + escaped.push_str(s); + escaped +} + +#[cfg(test)] +mod tests { + use super::bracket_escape; + + mod render_markdown { + use super::super::render_markdown; + + #[test] + fn preserves_external_links() { + assert_eq!( + render_markdown("[example](https://www.rust-lang.org/)", false), + "<p><a href=\"https://www.rust-lang.org/\">example</a></p>\n" + ); + } + + #[test] + fn it_can_adjust_markdown_links() { + assert_eq!( + render_markdown("[example](example.md)", false), + "<p><a href=\"example.html\">example</a></p>\n" + ); + assert_eq!( + render_markdown("[example_anchor](example.md#anchor)", false), + "<p><a href=\"example.html#anchor\">example_anchor</a></p>\n" + ); + + // this anchor contains 'md' inside of it + assert_eq!( + render_markdown("[phantom data](foo.html#phantomdata)", false), + "<p><a href=\"foo.html#phantomdata\">phantom data</a></p>\n" + ); + } + + #[test] + fn it_can_wrap_tables() { + let src = r#" +| Original | Punycode | Punycode + Encoding | +|-----------------|-----------------|---------------------| +| føø | f-5gaa | f_5gaa | +"#; + let out = r#" +<div class="table-wrapper"><table><thead><tr><th>Original</th><th>Punycode</th><th>Punycode + Encoding</th></tr></thead><tbody> +<tr><td>føø</td><td>f-5gaa</td><td>f_5gaa</td></tr> +</tbody></table> +</div> +"#.trim(); + assert_eq!(render_markdown(src, false), out); + } + + #[test] + fn it_can_keep_quotes_straight() { + assert_eq!(render_markdown("'one'", false), "<p>'one'</p>\n"); + } + + #[test] + fn it_can_make_quotes_curly_except_when_they_are_in_code() { + let input = r#" +'one' +``` +'two' +``` +`'three'` 'four'"#; + let expected = r#"<p>‘one’</p> +<pre><code>'two' +</code></pre> +<p><code>'three'</code> ‘four’</p> +"#; + assert_eq!(render_markdown(input, true), expected); + } + + #[test] + fn whitespace_outside_of_codeblock_header_is_preserved() { + let input = r#" +some text with spaces +```rust +fn main() { +// code inside is unchanged +} +``` +more text with spaces +"#; + + let expected = r#"<p>some text with spaces</p> +<pre><code class="language-rust">fn main() { +// code inside is unchanged +} +</code></pre> +<p>more text with spaces</p> +"#; + assert_eq!(render_markdown(input, false), expected); + assert_eq!(render_markdown(input, true), expected); + } + + #[test] + fn rust_code_block_properties_are_passed_as_space_delimited_class() { + let input = r#" +```rust,no_run,should_panic,property_3 +``` +"#; + + let expected = r#"<pre><code class="language-rust,no_run,should_panic,property_3"></code></pre> +"#; + assert_eq!(render_markdown(input, false), expected); + assert_eq!(render_markdown(input, true), expected); + } + + #[test] + fn rust_code_block_properties_with_whitespace_are_passed_as_space_delimited_class() { + let input = r#" +```rust, no_run,,,should_panic , ,property_3 +``` +"#; + + let expected = r#"<pre><code class="language-rust,,,,,no_run,,,should_panic,,,,property_3"></code></pre> +"#; + assert_eq!(render_markdown(input, false), expected); + assert_eq!(render_markdown(input, true), expected); + } + + #[test] + fn rust_code_block_without_properties_has_proper_html_class() { + let input = r#" +```rust +``` +"#; + + let expected = r#"<pre><code class="language-rust"></code></pre> +"#; + assert_eq!(render_markdown(input, false), expected); + assert_eq!(render_markdown(input, true), expected); + + let input = r#" +```rust +``` +"#; + assert_eq!(render_markdown(input, false), expected); + assert_eq!(render_markdown(input, true), expected); + } + } + + #[allow(deprecated)] + mod id_from_content { + use super::super::id_from_content; + + #[test] + fn it_generates_anchors() { + assert_eq!( + id_from_content("## Method-call expressions"), + "method-call-expressions" + ); + assert_eq!(id_from_content("## **Bold** title"), "bold-title"); + assert_eq!(id_from_content("## `Code` title"), "code-title"); + assert_eq!( + id_from_content("## title <span dir=rtl>foo</span>"), + "title-foo" + ); + } + + #[test] + fn it_generates_anchors_from_non_ascii_initial() { + assert_eq!( + id_from_content("## `--passes`: add more rustdoc passes"), + "--passes-add-more-rustdoc-passes" + ); + assert_eq!( + id_from_content("## 中文標題 CJK title"), + "中文標題-cjk-title" + ); + assert_eq!(id_from_content("## Über"), "Über"); + } + } + + mod html_munging { + use super::super::{normalize_id, unique_id_from_content}; + + #[test] + fn it_normalizes_ids() { + assert_eq!( + normalize_id("`--passes`: add more rustdoc passes"), + "--passes-add-more-rustdoc-passes" + ); + assert_eq!( + normalize_id("Method-call 🐙 expressions \u{1f47c}"), + "method-call--expressions-" + ); + assert_eq!(normalize_id("_-_12345"), "_-_12345"); + assert_eq!(normalize_id("12345"), "12345"); + assert_eq!(normalize_id("中文"), "中文"); + assert_eq!(normalize_id("にほんご"), "にほんご"); + assert_eq!(normalize_id("한국어"), "한국어"); + assert_eq!(normalize_id(""), ""); + } + + #[test] + fn it_generates_unique_ids_from_content() { + // Same id if not given shared state + assert_eq!( + unique_id_from_content("## 中文標題 CJK title", &mut Default::default()), + "中文標題-cjk-title" + ); + assert_eq!( + unique_id_from_content("## 中文標題 CJK title", &mut Default::default()), + "中文標題-cjk-title" + ); + + // Different id if given shared state + let mut id_counter = Default::default(); + assert_eq!(unique_id_from_content("## Über", &mut id_counter), "Über"); + assert_eq!( + unique_id_from_content("## 中文標題 CJK title", &mut id_counter), + "中文標題-cjk-title" + ); + assert_eq!(unique_id_from_content("## Über", &mut id_counter), "Über-1"); + assert_eq!(unique_id_from_content("## Über", &mut id_counter), "Über-2"); + } + } + + #[test] + fn escaped_brackets() { + assert_eq!(bracket_escape(""), ""); + assert_eq!(bracket_escape("<"), "<"); + assert_eq!(bracket_escape(">"), ">"); + assert_eq!(bracket_escape("<>"), "<>"); + assert_eq!(bracket_escape("<test>"), "<test>"); + assert_eq!(bracket_escape("a<test>b"), "a<test>b"); + } +} diff --git a/vendor/mdbook/src/utils/string.rs b/vendor/mdbook/src/utils/string.rs new file mode 100644 index 000000000..97485d7b6 --- /dev/null +++ b/vendor/mdbook/src/utils/string.rs @@ -0,0 +1,255 @@ +use regex::Regex; +use std::ops::Bound::{Excluded, Included, Unbounded}; +use std::ops::RangeBounds; + +/// Take a range of lines from a string. +pub fn take_lines<R: RangeBounds<usize>>(s: &str, range: R) -> String { + let start = match range.start_bound() { + Excluded(&n) => n + 1, + Included(&n) => n, + Unbounded => 0, + }; + let lines = s.lines().skip(start); + match range.end_bound() { + Excluded(end) => lines + .take(end.saturating_sub(start)) + .collect::<Vec<_>>() + .join("\n"), + Included(end) => lines + .take((end + 1).saturating_sub(start)) + .collect::<Vec<_>>() + .join("\n"), + Unbounded => lines.collect::<Vec<_>>().join("\n"), + } +} + +lazy_static! { + static ref ANCHOR_START: Regex = Regex::new(r"ANCHOR:\s*(?P<anchor_name>[\w_-]+)").unwrap(); + static ref ANCHOR_END: Regex = Regex::new(r"ANCHOR_END:\s*(?P<anchor_name>[\w_-]+)").unwrap(); +} + +/// Take anchored lines from a string. +/// Lines containing anchor are ignored. +pub fn take_anchored_lines(s: &str, anchor: &str) -> String { + let mut retained = Vec::<&str>::new(); + let mut anchor_found = false; + + for l in s.lines() { + if anchor_found { + match ANCHOR_END.captures(l) { + Some(cap) => { + if &cap["anchor_name"] == anchor { + break; + } + } + None => { + if !ANCHOR_START.is_match(l) { + retained.push(l); + } + } + } + } else if let Some(cap) = ANCHOR_START.captures(l) { + if &cap["anchor_name"] == anchor { + anchor_found = true; + } + } + } + + retained.join("\n") +} + +/// Keep lines contained within the range specified as-is. +/// For any lines not in the range, include them but use `#` at the beginning. This will hide the +/// lines from initial display but include them when expanding the code snippet or testing with +/// rustdoc. +pub fn take_rustdoc_include_lines<R: RangeBounds<usize>>(s: &str, range: R) -> String { + let mut output = String::with_capacity(s.len()); + + for (index, line) in s.lines().enumerate() { + if !range.contains(&index) { + output.push_str("# "); + } + output.push_str(line); + output.push('\n'); + } + output.pop(); + output +} + +/// Keep lines between the anchor comments specified as-is. +/// For any lines not between the anchors, include them but use `#` at the beginning. This will +/// hide the lines from initial display but include them when expanding the code snippet or testing +/// with rustdoc. +pub fn take_rustdoc_include_anchored_lines(s: &str, anchor: &str) -> String { + let mut output = String::with_capacity(s.len()); + let mut within_anchored_section = false; + + for l in s.lines() { + if within_anchored_section { + match ANCHOR_END.captures(l) { + Some(cap) => { + if &cap["anchor_name"] == anchor { + within_anchored_section = false; + } + } + None => { + if !ANCHOR_START.is_match(l) { + output.push_str(l); + output.push('\n'); + } + } + } + } else if let Some(cap) = ANCHOR_START.captures(l) { + if &cap["anchor_name"] == anchor { + within_anchored_section = true; + } + } else if !ANCHOR_END.is_match(l) { + output.push_str("# "); + output.push_str(l); + output.push('\n'); + } + } + + output.pop(); + output +} + +#[cfg(test)] +mod tests { + use super::{ + take_anchored_lines, take_lines, take_rustdoc_include_anchored_lines, + take_rustdoc_include_lines, + }; + + #[test] + #[allow(clippy::reversed_empty_ranges)] // Intentionally checking that those are correctly handled + fn take_lines_test() { + let s = "Lorem\nipsum\ndolor\nsit\namet"; + assert_eq!(take_lines(s, 1..3), "ipsum\ndolor"); + assert_eq!(take_lines(s, 3..), "sit\namet"); + assert_eq!(take_lines(s, ..3), "Lorem\nipsum\ndolor"); + assert_eq!(take_lines(s, ..), s); + // corner cases + assert_eq!(take_lines(s, 4..3), ""); + assert_eq!(take_lines(s, ..100), s); + } + + #[test] + fn take_anchored_lines_test() { + let s = "Lorem\nipsum\ndolor\nsit\namet"; + assert_eq!(take_anchored_lines(s, "test"), ""); + + let s = "Lorem\nipsum\ndolor\nANCHOR_END: test\nsit\namet"; + assert_eq!(take_anchored_lines(s, "test"), ""); + + let s = "Lorem\nipsum\nANCHOR: test\ndolor\nsit\namet"; + assert_eq!(take_anchored_lines(s, "test"), "dolor\nsit\namet"); + assert_eq!(take_anchored_lines(s, "something"), ""); + + let s = "Lorem\nipsum\nANCHOR: test\ndolor\nsit\namet\nANCHOR_END: test\nlorem\nipsum"; + assert_eq!(take_anchored_lines(s, "test"), "dolor\nsit\namet"); + assert_eq!(take_anchored_lines(s, "something"), ""); + + let s = "Lorem\nANCHOR: test\nipsum\nANCHOR: test\ndolor\nsit\namet\nANCHOR_END: test\nlorem\nipsum"; + assert_eq!(take_anchored_lines(s, "test"), "ipsum\ndolor\nsit\namet"); + assert_eq!(take_anchored_lines(s, "something"), ""); + + let s = "Lorem\nANCHOR: test2\nipsum\nANCHOR: test\ndolor\nsit\namet\nANCHOR_END: test\nlorem\nANCHOR_END:test2\nipsum"; + assert_eq!( + take_anchored_lines(s, "test2"), + "ipsum\ndolor\nsit\namet\nlorem" + ); + assert_eq!(take_anchored_lines(s, "test"), "dolor\nsit\namet"); + assert_eq!(take_anchored_lines(s, "something"), ""); + } + + #[test] + #[allow(clippy::reversed_empty_ranges)] // Intentionally checking that those are correctly handled + fn take_rustdoc_include_lines_test() { + let s = "Lorem\nipsum\ndolor\nsit\namet"; + assert_eq!( + take_rustdoc_include_lines(s, 1..3), + "# Lorem\nipsum\ndolor\n# sit\n# amet" + ); + assert_eq!( + take_rustdoc_include_lines(s, 3..), + "# Lorem\n# ipsum\n# dolor\nsit\namet" + ); + assert_eq!( + take_rustdoc_include_lines(s, ..3), + "Lorem\nipsum\ndolor\n# sit\n# amet" + ); + assert_eq!(take_rustdoc_include_lines(s, ..), s); + // corner cases + assert_eq!( + take_rustdoc_include_lines(s, 4..3), + "# Lorem\n# ipsum\n# dolor\n# sit\n# amet" + ); + assert_eq!(take_rustdoc_include_lines(s, ..100), s); + } + + #[test] + fn take_rustdoc_include_anchored_lines_test() { + let s = "Lorem\nipsum\ndolor\nsit\namet"; + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test"), + "# Lorem\n# ipsum\n# dolor\n# sit\n# amet" + ); + + let s = "Lorem\nipsum\ndolor\nANCHOR_END: test\nsit\namet"; + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test"), + "# Lorem\n# ipsum\n# dolor\n# sit\n# amet" + ); + + let s = "Lorem\nipsum\nANCHOR: test\ndolor\nsit\namet"; + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test"), + "# Lorem\n# ipsum\ndolor\nsit\namet" + ); + assert_eq!( + take_rustdoc_include_anchored_lines(s, "something"), + "# Lorem\n# ipsum\n# dolor\n# sit\n# amet" + ); + + let s = "Lorem\nipsum\nANCHOR: test\ndolor\nsit\namet\nANCHOR_END: test\nlorem\nipsum"; + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test"), + "# Lorem\n# ipsum\ndolor\nsit\namet\n# lorem\n# ipsum" + ); + assert_eq!( + take_rustdoc_include_anchored_lines(s, "something"), + "# Lorem\n# ipsum\n# dolor\n# sit\n# amet\n# lorem\n# ipsum" + ); + + let s = "Lorem\nANCHOR: test\nipsum\nANCHOR: test\ndolor\nsit\namet\nANCHOR_END: test\nlorem\nipsum"; + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test"), + "# Lorem\nipsum\ndolor\nsit\namet\n# lorem\n# ipsum" + ); + assert_eq!( + take_rustdoc_include_anchored_lines(s, "something"), + "# Lorem\n# ipsum\n# dolor\n# sit\n# amet\n# lorem\n# ipsum" + ); + + let s = "Lorem\nANCHOR: test2\nipsum\nANCHOR: test\ndolor\nsit\namet\nANCHOR_END: test\nlorem\nANCHOR_END:test2\nipsum"; + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test2"), + "# Lorem\nipsum\ndolor\nsit\namet\nlorem\n# ipsum" + ); + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test"), + "# Lorem\n# ipsum\ndolor\nsit\namet\n# lorem\n# ipsum" + ); + assert_eq!( + take_rustdoc_include_anchored_lines(s, "something"), + "# Lorem\n# ipsum\n# dolor\n# sit\n# amet\n# lorem\n# ipsum" + ); + + let s = "Lorem\nANCHOR: test\nipsum\nANCHOR_END: test\ndolor\nANCHOR: test\nsit\nANCHOR_END: test\namet"; + assert_eq!( + take_rustdoc_include_anchored_lines(s, "test"), + "# Lorem\nipsum\n# dolor\nsit\n# amet" + ); + } +} diff --git a/vendor/mdbook/src/utils/toml_ext.rs b/vendor/mdbook/src/utils/toml_ext.rs new file mode 100644 index 000000000..bf25ad11b --- /dev/null +++ b/vendor/mdbook/src/utils/toml_ext.rs @@ -0,0 +1,130 @@ +use toml::value::{Table, Value}; + +pub(crate) trait TomlExt { + fn read(&self, key: &str) -> Option<&Value>; + fn read_mut(&mut self, key: &str) -> Option<&mut Value>; + fn insert(&mut self, key: &str, value: Value); + fn delete(&mut self, key: &str) -> Option<Value>; +} + +impl TomlExt for Value { + fn read(&self, key: &str) -> Option<&Value> { + if let Some((head, tail)) = split(key) { + self.get(head)?.read(tail) + } else { + self.get(key) + } + } + + fn read_mut(&mut self, key: &str) -> Option<&mut Value> { + if let Some((head, tail)) = split(key) { + self.get_mut(head)?.read_mut(tail) + } else { + self.get_mut(key) + } + } + + fn insert(&mut self, key: &str, value: Value) { + if !self.is_table() { + *self = Value::Table(Table::new()); + } + + let table = self.as_table_mut().expect("unreachable"); + + if let Some((head, tail)) = split(key) { + table + .entry(head) + .or_insert_with(|| Value::Table(Table::new())) + .insert(tail, value); + } else { + table.insert(key.to_string(), value); + } + } + + fn delete(&mut self, key: &str) -> Option<Value> { + if let Some((head, tail)) = split(key) { + self.get_mut(head)?.delete(tail) + } else if let Some(table) = self.as_table_mut() { + table.remove(key) + } else { + None + } + } +} + +fn split(key: &str) -> Option<(&str, &str)> { + let ix = key.find('.')?; + + let (head, tail) = key.split_at(ix); + // splitting will leave the "." + let tail = &tail[1..]; + + Some((head, tail)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn read_simple_table() { + let src = "[table]"; + let value = Value::from_str(src).unwrap(); + + let got = value.read("table").unwrap(); + + assert!(got.is_table()); + } + + #[test] + fn read_nested_item() { + let src = "[table]\nnested=true"; + let value = Value::from_str(src).unwrap(); + + let got = value.read("table.nested").unwrap(); + + assert_eq!(got, &Value::Boolean(true)); + } + + #[test] + fn insert_item_at_top_level() { + let mut value = Value::Table(Table::default()); + let item = Value::Boolean(true); + + value.insert("first", item.clone()); + + assert_eq!(value.get("first").unwrap(), &item); + } + + #[test] + fn insert_nested_item() { + let mut value = Value::Table(Table::default()); + let item = Value::Boolean(true); + + value.insert("first.second", item.clone()); + + let inserted = value.read("first.second").unwrap(); + assert_eq!(inserted, &item); + } + + #[test] + fn delete_a_top_level_item() { + let src = "top = true"; + let mut value = Value::from_str(src).unwrap(); + + let got = value.delete("top").unwrap(); + + assert_eq!(got, Value::Boolean(true)); + } + + #[test] + fn delete_a_nested_item() { + let src = "[table]\n nested = true"; + let mut value = Value::from_str(src).unwrap(); + + let got = value.delete("table.nested").unwrap(); + + assert_eq!(got, Value::Boolean(true)); + } +} |