diff --git a/Cargo.toml b/Cargo.toml index 4c49432e..061fd674 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ open = "1.1" regex = "0.2.1" tempdir = "0.3.4" itertools = "0.7.4" +tempfile = "2.2.0" +shlex = "0.1.1" # Watch feature notify = { version = "4.0", optional = true } diff --git a/book-example/src/SUMMARY.md b/book-example/src/SUMMARY.md index ff3911c7..dd703380 100644 --- a/book-example/src/SUMMARY.md +++ b/book-example/src/SUMMARY.md @@ -15,6 +15,6 @@ - [Syntax highlighting](format/theme/syntax-highlighting.md) - [MathJax Support](format/mathjax.md) - [Rust code specific features](format/rust.md) -- [Rust Library](lib/lib.md) +- [For Developers](lib/index.md) ----------- [Contributors](misc/contributors.md) diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index d1980a21..786dce3c 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -69,8 +69,6 @@ renderer need to be specified under the TOML table `[output.html]`. The following configuration options are available: - pub playpen: Playpen, - - **theme:** mdBook comes with a default theme and all the resource files needed for it. But if this option is set, mdBook will selectively overwrite the theme files with the ones found in the specified folder. @@ -105,51 +103,3 @@ additional-js = ["custom.js"] editor = "./path/to/editor" editable = false ``` - - -## For Developers - -If you are developing a plugin or alternate backend then whenever your code is -called you will almost certainly be passed a reference to the book's `Config`. -This can be treated roughly as a nested hashmap which lets you call methods like -`get()` and `get_mut()` to get access to the config's contents. - -By convention, plugin developers will have their settings as a subtable inside -`plugins` (e.g. a link checker would put its settings in `plugins.link_check`) -and backends should put their configuration under `output`, like the HTML -renderer does in the previous examples. - -As an example, some hypothetical `random` renderer would typically want to load -its settings from the `Config` at the very start of its rendering process. The -author can take advantage of serde to deserialize the generic `toml::Value` -object retrieved from `Config` into a struct specific to its use case. - -```rust -#[derive(Debug, Deserialize, PartialEq)] -struct RandomOutput { - foo: u32, - bar: String, - baz: Vec, -} - -let src = r#" -[output.random] -foo = 5 -bar = "Hello World" -baz = [true, true, false] -"#; - -let book_config = Config::from_str(src)?; // usually passed in by mdbook -let random: Value = book_config.get("output.random").unwrap_or_default(); -let got: RandomOutput = random.try_into()?; - -assert_eq!(got, should_be); - -if let Some(baz) = book_config.get_deserialized::>("output.random.baz") { - println!("{:?}", baz); // prints [true, true, false] - - // do something interesting with baz -} - -// start the rendering process -``` diff --git a/book-example/src/lib/index.md b/book-example/src/lib/index.md new file mode 100644 index 00000000..23b96ec7 --- /dev/null +++ b/book-example/src/lib/index.md @@ -0,0 +1,176 @@ +# For Developers + +While `mdbook` is mainly used as a command line tool, you can also import the +underlying library directly and use that to manage a book. + +- Creating custom backends +- Automatically generating and reloading a book on the fly +- Integration with existing projects + +The best source for examples on using the `mdbook` crate from your own Rust +programs is the [API Docs]. + + +## Configuration + +The mechanism for using alternative backends is very simple, you add an extra +table to your `book.toml` and the `MDBook::load()` function will automatically +detect the backends being used. + +For example, if you wanted to use a hypothetical `latex` backend you would add +an empty `output.latex` table to `book.toml`. + +```toml +# book.toml + +[book] +... + +[output.latex] +``` + +And then during the rendering stage `mdbook` will run the `mdbook-latex` +program, piping it a JSON serialized [RenderContext] via stdin. + +You can set the command used via the `command` key. + +```toml +# book.toml + +[book] +... + +[output.latex] +command = "python3 my_plugin.py" +``` + +If no backend is supplied (i.e. there are no `output.*` tables), `mdbook` will +fall back to the `html` backend. + +### The `Config` Struct + +If you are developing a plugin or alternate backend then whenever your code is +called you will almost certainly be passed a reference to the book's `Config`. +This can be treated roughly as a nested hashmap which lets you call methods like +`get()` and `get_mut()` to get access to the config's contents. + +By convention, plugin developers will have their settings as a subtable inside +`plugins` (e.g. a link checker would put its settings in `plugins.link_check`) +and backends should put their configuration under `output`, like the HTML +renderer does in the previous examples. + +As an example, some hypothetical `random` renderer would typically want to load +its settings from the `Config` at the very start of its rendering process. The +author can take advantage of serde to deserialize the generic `toml::Value` +object retrieved from `Config` into a struct specific to its use case. + +```rust +extern crate serde; +#[macro_use] +extern crate serde_derive; +extern crate toml; +extern crate mdbook; + +use toml::Value; +use mdbook::config::Config; + +#[derive(Debug, Deserialize, PartialEq)] +struct RandomOutput { + foo: u32, + bar: String, + baz: Vec, +} + +# fn run() -> Result<(), Box<::std::error::Error>> { +let src = r#" +[output.random] +foo = 5 +bar = "Hello World" +baz = [true, true, false] +"#; + +let book_config = Config::from_str(src)?; // usually passed in via the RenderContext +let random = book_config.get("output.random") + .cloned() + .ok_or("output.random not found")?; +let got: RandomOutput = random.try_into()?; + +let should_be = RandomOutput { + foo: 5, + bar: "Hello World".to_string(), + baz: vec![true, true, false] +}; + +assert_eq!(got, should_be); + +let baz: Vec = book_config.get_deserialized("output.random.baz")?; +println!("{:?}", baz); // prints [true, true, false] + +// do something interesting with baz +# Ok(()) +# } +# fn main() { run().unwrap() } +``` + + +## Render Context + +The `RenderContext` encapsulates all the information a backend needs to know +in order to generate output. Its Rust definition looks something like this: + +```rust +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RenderContext { + pub version: String, + pub root: PathBuf, + pub book: Book, + pub config: Config, + pub destination: PathBuf, +} +``` + +A backend will receive the `RenderContext` via `stdin` as one big JSON blob. If +possible, it is recommended to import the `mdbook` crate and use the +`RenderContext::from_json()` method. This way you should always be able to +deserialize the `RenderContext`, and as a bonus will also have access to the +methods already defined on the underlying types. + +Although backends are told the book's root directory on disk, it is *strongly +discouraged* to load chapter content from the filesystem. The `root` key is +provided as an escape hatch for certain plugins which may load additional, +non-markdown, files. + + +## Output Directory + +To make things more deterministic, a backend will be told where it should place +its generated artefacts. + +The general algorithm for deciding the output directory goes something like +this: + +- If there is only one backend: + - `destination` is `config.build.build_dir` (usually `book/`) +- Otherwise: + - `destination` is `config.build.build_dir` joined with the backend's name + (e.g. `build/latex/` for the "latex" backend) + + +## Output and Signalling Failure + +To signal that the plugin failed it just needs to exit with a non-zero return +code. + +All output from the plugin's subprocess is immediately passed through to the +user, so it is encouraged for plugins to follow the ["rule of silence"] and +by default only tell the user about things they directly need to respond to +(e.g. an error in generation or a warning). + +This "silent by default" behaviour can be overridden via the `RUST_LOG` +environment variable (which `mdbook` will pass through to the backend if set) +as is typical with Rust applications. + + +[API Docs]: https://docs.rs/mdbook +[RenderContext]: https://docs.rs/mdbook/*/mdbook/renderer/struct.RenderContext.html +["rule of silence"]: http://www.linfo.org/rule_of_silence.html \ No newline at end of file diff --git a/book-example/src/lib/lib.md b/book-example/src/lib/lib.md deleted file mode 100644 index 269e8c31..00000000 --- a/book-example/src/lib/lib.md +++ /dev/null @@ -1,24 +0,0 @@ -# Rust Library - -mdBook is not only a command line tool, it can be used as a crate. You can extend it, -integrate it in current projects. Here is a short example: - -```rust,ignore -extern crate mdbook; - -use mdbook::MDBook; -use std::path::Path; - -# #[allow(unused_variables)] -fn main() { - let mut book = MDBook::new("my-book") // Path to root - .with_source("src") // Path from root to source directory - .with_destination("book") // Path from root to output directory - .read_config() // Parse book.toml or book.json configuration file - .expect("I don't handle configuration file error, but you should!"); - - book.build().unwrap(); // Render the book -} -``` - -Check here for the [API docs](mdbook/index.html) generated by rustdoc. diff --git a/src/bin/build.rs b/src/bin/build.rs index bc784ea9..24d9a7fd 100644 --- a/src/bin/build.rs +++ b/src/bin/build.rs @@ -30,7 +30,8 @@ pub fn execute(args: &ArgMatches) -> Result<()> { book.build()?; if args.is_present("open") { - open(book.get_destination().join("index.html")); + // FIXME: What's the right behaviour if we don't use the HTML renderer? + open(book.build_dir_for("html").join("index.html")); } Ok(()) diff --git a/src/bin/init.rs b/src/bin/init.rs index 3a14d9c0..b0299059 100644 --- a/src/bin/init.rs +++ b/src/bin/init.rs @@ -26,7 +26,11 @@ pub fn execute(args: &ArgMatches) -> Result<()> { // Skip this if `--force` is present if !args.is_present("force") { // Print warning - print!("\nCopying the default theme to {}", builder.config().book.src.display()); + println!(); + println!( + "Copying the default theme to {}", + builder.config().book.src.display() + ); println!("This could potentially overwrite files already present in that directory."); print!("\nAre you sure you want to continue? (y/n) "); diff --git a/src/bin/mdbook.rs b/src/bin/mdbook.rs index 93794541..d8bd487a 100644 --- a/src/bin/mdbook.rs +++ b/src/bin/mdbook.rs @@ -16,7 +16,7 @@ use clap::{App, AppSettings, ArgMatches}; use chrono::Local; use log::LevelFilter; use env_logger::Builder; -use error_chain::ChainedError; +use mdbook::utils; pub mod build; pub mod init; @@ -64,7 +64,7 @@ fn main() { }; if let Err(e) = res { - eprintln!("{}", e.display_chain()); + utils::log_backtrace(&e); ::std::process::exit(101); } @@ -101,12 +101,12 @@ fn get_book_dir(args: &ArgMatches) -> PathBuf { p.to_path_buf() } } else { - env::current_dir().unwrap() + env::current_dir().expect("Unable to determine the current directory") } } fn open>(path: P) { if let Err(e) = open::that(path) { - println!("Error opening web browser: {}", e); + error!("Error opening web browser: {}", e); } } diff --git a/src/bin/serve.rs b/src/bin/serve.rs index ac6a51e5..78328234 100644 --- a/src/bin/serve.rs +++ b/src/bin/serve.rs @@ -7,6 +7,7 @@ use self::iron::{status, AfterMiddleware, Chain, Iron, IronError, IronResult, Re Set}; use clap::{App, ArgMatches, SubCommand}; use mdbook::MDBook; +use mdbook::utils; use mdbook::errors::*; use {get_book_dir, open}; #[cfg(feature = "watch")] @@ -38,8 +39,6 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { // Watch command implementation pub fn execute(args: &ArgMatches) -> Result<()> { - const RELOAD_COMMAND: &'static str = "reload"; - let book_dir = get_book_dir(args); let mut book = MDBook::load(&book_dir)?; @@ -52,29 +51,13 @@ pub fn execute(args: &ArgMatches) -> Result<()> { let address = format!("{}:{}", interface, port); let ws_address = format!("{}:{}", interface, ws_port); - let livereload = Some(format!( - r#" - -"#, - public_address, ws_port, RELOAD_COMMAND - )); - book.livereload = livereload.clone(); + let livereload_url = format!("ws://{}:{}", public_address, ws_port); + book.config + .set("output.html.livereload-url", &livereload_url)?; book.build()?; - let mut chain = Chain::new(staticfile::Static::new(book.get_destination())); + let mut chain = Chain::new(staticfile::Static::new(book.build_dir_for("html"))); chain.link_after(ErrorRecover); let _iron = Iron::new(chain) .http(&*address) @@ -90,7 +73,7 @@ pub fn execute(args: &ArgMatches) -> Result<()> { }); let serving_url = format!("http://{}", address); - println!("\nServing on: {}", serving_url); + info!("Serving on: {}", serving_url); if open_browser { open(serving_url); @@ -98,26 +81,25 @@ pub fn execute(args: &ArgMatches) -> Result<()> { #[cfg(feature = "watch")] watch::trigger_on_change(&mut book, move |path, book_dir| { - println!("File changed: {:?}\nBuilding book...\n", path); + info!("File changed: {:?}", path); + info!("Building book..."); + // FIXME: This area is really ugly because we need to re-set livereload :( - - let livereload = livereload.clone(); + + let livereload_url = livereload_url.clone(); let result = MDBook::load(&book_dir) - .map(move |mut b| { - b.livereload = livereload; - b + .and_then(move |mut b| { + b.config.set("output.html.livereload-url", &livereload_url)?; + Ok(b) }) - .and_then(|mut b| b.build()); + .and_then(|b| b.build()); if let Err(e) = result { error!("Unable to load the book"); - error!("Error: {}", e); - for cause in e.iter().skip(1) { - error!("\tCaused By: {}", cause); - } + utils::log_backtrace(&e); } else { - let _ = broadcaster.send(RELOAD_COMMAND); + let _ = broadcaster.send("reload"); } }); diff --git a/src/bin/watch.rs b/src/bin/watch.rs index 536e6150..5d39e715 100644 --- a/src/bin/watch.rs +++ b/src/bin/watch.rs @@ -6,6 +6,7 @@ use std::time::Duration; use std::sync::mpsc::channel; use clap::{App, ArgMatches, SubCommand}; use mdbook::MDBook; +use mdbook::utils; use mdbook::errors::Result; use {get_book_dir, open}; @@ -22,21 +23,21 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> { // Watch command implementation pub fn execute(args: &ArgMatches) -> Result<()> { let book_dir = get_book_dir(args); - let mut book = MDBook::load(&book_dir)?; + let book = MDBook::load(&book_dir)?; if args.is_present("open") { book.build()?; - open(book.get_destination().join("index.html")); + open(book.build_dir_for("html").join("index.html")); } trigger_on_change(&book, |path, book_dir| { - println!("File changed: {:?}\nBuilding book...\n", path); - let result = MDBook::load(&book_dir).and_then(|mut b| b.build()); + info!("File changed: {:?}\nBuilding book...\n", path); + let result = MDBook::load(&book_dir).and_then(|b| b.build()); if let Err(e) = result { - println!("Error while building: {}", e); + error!("Unable to build the book"); + utils::log_backtrace(&e); } - println!(); }); Ok(()) @@ -56,14 +57,14 @@ where let mut watcher = match notify::watcher(tx, Duration::from_secs(1)) { Ok(w) => w, Err(e) => { - println!("Error while trying to watch the files:\n\n\t{:?}", 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) { - println!("Error while watching {:?}:\n {:?}", book.source_dir(), e); + error!("Error while watching {:?}:\n {:?}", book.source_dir(), e); ::std::process::exit(1); }; @@ -72,9 +73,10 @@ where // Add the book.toml file to the watcher if it exists let _ = watcher.watch(book.root.join("book.toml"), NonRecursive); - println!("\nListening for changes...\n"); + info!("Listening for changes..."); for event in rx.iter() { + debug!("Received filesystem event: {:?}", event); match event { Create(path) | Write(path) | Remove(path) | Rename(_, path) => { closure(&path, &book.root); diff --git a/src/book/mod.rs b/src/book/mod.rs index bc8550e2..1d985caa 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -15,13 +15,14 @@ pub use self::book::{load_book, Book, BookItem, BookItems, Chapter}; pub use self::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem}; pub use self::init::BookBuilder; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::io::Write; use std::process::Command; use tempdir::TempDir; +use toml::Value; use utils; -use renderer::{HtmlHandlebars, Renderer}; +use renderer::{CmdRenderer, HtmlHandlebars, RenderContext, Renderer}; use preprocess; use errors::*; @@ -33,9 +34,9 @@ pub struct MDBook { pub root: PathBuf, /// The configuration used to tweak now a book is built. pub config: Config, - - book: Book, - renderer: Box, + /// A representation of the book's contents in memory. + pub book: Book, + renderers: Vec>, /// The URL used for live reloading when serving up the book. pub livereload: Option, @@ -75,17 +76,20 @@ impl MDBook { /// Load a book from its root directory using a custom config. pub fn load_with_config>(book_root: P, config: Config) -> Result { - let book_root = book_root.into(); + let root = book_root.into(); - let src_dir = book_root.join(&config.book.src); + let src_dir = root.join(&config.book.src); let book = book::load_book(&src_dir, &config.build)?; + let livereload = None; + + let renderers = determine_renderers(&config); Ok(MDBook { - root: book_root, - config: config, - book: book, - renderer: Box::new(HtmlHandlebars::new()), - livereload: None, + root, + config, + book, + renderers, + livereload, }) } @@ -142,32 +146,47 @@ impl MDBook { } /// Tells the renderer to build our book and put it in the build directory. - pub fn build(&mut self) -> Result<()> { + pub fn build(&self) -> Result<()> { debug!("[fn]: build"); - let dest = self.get_destination(); - if dest.exists() { - utils::fs::remove_dir_content(&dest).chain_err(|| "Unable to clear output directory")?; + for renderer in &self.renderers { + self.run_renderer(renderer.as_ref())?; } - self.renderer.render(self) + Ok(()) } - // FIXME: This doesn't belong as part of `MDBook`. It is only used by the HTML renderer - #[doc(hidden)] - pub fn write_file>(&self, filename: P, content: &[u8]) -> Result<()> { - let path = self.get_destination().join(filename); + fn run_renderer(&self, renderer: &Renderer) -> Result<()> { + let name = renderer.name(); + let build_dir = self.build_dir_for(name); + if build_dir.exists() { + debug!( + "Cleaning build dir for the \"{}\" renderer ({})", + name, + build_dir.display() + ); - utils::fs::create_file(&path)? - .write_all(content) - .map_err(|e| e.into()) + utils::fs::remove_dir_content(&build_dir) + .chain_err(|| "Unable to clear output directory")?; + } + + let render_context = RenderContext::new( + self.root.clone(), + self.book.clone(), + self.config.clone(), + build_dir, + ); + + renderer + .render(&render_context) + .chain_err(|| "Rendering failed") } /// You can change the default renderer to another one by using this method. /// The only requirement is for your renderer to implement the [Renderer /// trait](../../renderer/renderer/trait.Renderer.html) - pub fn set_renderer(mut self, renderer: R) -> Self { - self.renderer = Box::new(renderer); + pub fn with_renderer(&mut self, renderer: R) -> &mut Self { + self.renderers.push(Box::new(renderer)); self } @@ -215,10 +234,38 @@ impl MDBook { Ok(()) } - // FIXME: This doesn't belong under `MDBook`, it should really be passed to the renderer directly. - #[doc(hidden)] - pub fn get_destination(&self) -> PathBuf { - self.root.join(&self.config.build.build_dir) + /// 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. @@ -226,7 +273,7 @@ impl MDBook { self.root.join(&self.config.book.src) } - // FIXME: This belongs as part of the `HtmlConfig`. + // FIXME: This really belongs as part of the `HtmlConfig`. #[doc(hidden)] pub fn theme_dir(&self) -> PathBuf { match self.config.html_config().and_then(|h| h.theme) { @@ -235,3 +282,84 @@ impl MDBook { } } } + +/// Look at the `Config` and try to figure out what renderers to use. +fn determine_renderers(config: &Config) -> Vec> { + let mut renderers: Vec> = Vec::new(); + + if let Some(output_table) = config.get("output").and_then(|o| o.as_table()) { + for (key, table) in output_table.iter() { + // the "html" backend has its own Renderer + if key == "html" { + renderers.push(Box::new(HtmlHandlebars::new())); + } else { + let renderer = interpret_custom_renderer(key, table); + renderers.push(renderer); + } + } + } + + // if we couldn't find anything, add the HTML renderer as a default + if renderers.is_empty() { + renderers.push(Box::new(HtmlHandlebars::new())); + } + + renderers +} + +fn interpret_custom_renderer(key: &str, table: &Value) -> Box { + // look for the `command` field, falling back to using the key + // prepended by "mdbook-" + let table_dot_command = table + .get("command") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()); + + let command = table_dot_command.unwrap_or_else(|| format!("mdbook-{}", key)); + + Box::new(CmdRenderer::new(key.to_string(), command.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + 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"); + } +} diff --git a/src/config.rs b/src/config.rs index 5b597477..b83293e2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,9 +25,10 @@ impl Config { /// Load the configuration file from disk. pub fn from_disk>(config_file: P) -> Result { let mut buffer = String::new(); - File::open(config_file).chain_err(|| "Unable to open the configuration file")? - .read_to_string(&mut buffer) - .chain_err(|| "Couldn't read the file")?; + File::open(config_file) + .chain_err(|| "Unable to open the configuration file")? + .read_to_string(&mut buffer) + .chain_err(|| "Couldn't read the file")?; Config::from_str(&buffer) } @@ -53,7 +54,8 @@ impl Config { /// # Note /// /// This is for compatibility only. It will be removed completely once the - /// rendering and plugin system is established. + /// HTML renderer is refactored to be less coupled to `mdbook` internals. + #[doc(hidden)] pub fn html_config(&self) -> Option { self.get_deserialized("output.html").ok() } @@ -64,14 +66,28 @@ impl Config { let name = name.as_ref(); if let Some(value) = self.get(name) { - value.clone() - .try_into() - .chain_err(|| "Couldn't deserialize the value") + value + .clone() + .try_into() + .chain_err(|| "Couldn't deserialize the value") } else { bail!("Key not found, {:?}", name) } } + /// 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>(&mut self, index: I, value: S) -> Result<()> { + let pieces: Vec<_> = index.as_ref().split(".").collect(); + let value = + Value::try_from(value).chain_err(|| "Unable to represent the item as a JSON Value")?; + recursive_set(&pieces, &mut self.rest, value); + + Ok(()) + } + fn from_legacy(mut table: Table) -> Config { let mut cfg = Config::default(); @@ -91,10 +107,11 @@ impl Config { get_and_insert!(table, "source" => cfg.book.src); get_and_insert!(table, "description" => cfg.book.description); - // This complicated chain of and_then's is so we can move - // "output.html.destination" to "build.build_dir" and parse it into a + // This complicated chain of and_then's is so we can move + // "output.html.destination" to "build.build_dir" and parse it into a // PathBuf. - let destination: Option = table.get_mut("output") + let destination: Option = table + .get_mut("output") .and_then(|output| output.as_table_mut()) .and_then(|output| output.get_mut("html")) .and_then(|html| html.as_table_mut()) @@ -110,6 +127,32 @@ impl Config { } } +/// Recursively walk down a table and try to set some `foo.bar.baz` value. +/// +/// If at any table along the way doesn't exist (or isn't itself a `Table`!) an +/// empty `Table` will be inserted. e.g. if the `foo` table didn't contain a +/// nested table called `bar`, we'd insert one and then keep recursing. +fn recursive_set(key: &[&str], table: &mut Table, value: Value) { + if key.is_empty() { + unreachable!(); + } else if key.len() == 1 { + table.insert(key[0].to_string(), value); + } else { + let first = key[0]; + let rest = &key[1..]; + + // if `table[first]` isn't a table, replace whatever is there with a + // new table. + if table.get(first).and_then(|t| t.as_table()).is_none() { + table.insert(first.to_string(), Value::Table(Table::new())); + } + + let nested = table.get_mut(first).and_then(|t| t.as_table_mut()).unwrap(); + recursive_set(rest, nested, value); + } +} + +/// The "getter" version of `recursive_set()`. fn recursive_get<'a>(key: &[&str], table: &'a Table) -> Option<&'a Value> { if key.is_empty() { return None; @@ -127,6 +170,7 @@ fn recursive_get<'a>(key: &[&str], table: &'a Table) -> Option<&'a Value> { } } +/// The mutable version of `recursive_get()`. fn recursive_get_mut<'a>(key: &[&str], table: &'a mut Table) -> Option<&'a mut Value> { // TODO: Figure out how to abstract over mutability to reduce copy-pasta if key.is_empty() { @@ -171,13 +215,15 @@ impl<'de> Deserialize<'de> for Config { return Ok(Config::from_legacy(table)); } - let book: BookConfig = table.remove("book") - .and_then(|value| value.try_into().ok()) - .unwrap_or_default(); + let book: BookConfig = table + .remove("book") + .and_then(|value| value.try_into().ok()) + .unwrap_or_default(); - let build: BuildConfig = table.remove("build") - .and_then(|value| value.try_into().ok()) - .unwrap_or_default(); + let build: BuildConfig = table + .remove("build") + .and_then(|value| value.try_into().ok()) + .unwrap_or_default(); Ok(Config { book: book, @@ -200,7 +246,7 @@ impl Serialize for Config { }; table.insert("book".to_string(), book_config); - + Value::Table(table).serialize(s) } } @@ -208,10 +254,11 @@ impl Serialize for Config { fn is_legacy_format(table: &Table) -> bool { let top_level_items = ["title", "author", "authors"]; - top_level_items.iter().any(|key| table.contains_key(&key.to_string())) + top_level_items + .iter() + .any(|key| table.contains_key(&key.to_string())) } - /// Configuration options which are specific to the book and required for /// loading it from disk. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -271,6 +318,14 @@ pub struct HtmlConfig { pub additional_css: Vec, pub additional_js: Vec, pub playpen: Playpen, + /// This is used as a bit of a workaround for the `mdbook serve` command. + /// Basically, because you set the websocket port from the command line, the + /// `mdbook serve` command needs a way to let the HTML renderer know where + /// to point livereloading at, if it has been enabled. + /// + /// This config item *should not be edited* by the end user. + #[doc(hidden)] + pub livereload_url: Option, } /// Configuration for tweaking how the the HTML renderer handles the playpen. @@ -290,7 +345,6 @@ impl Default for Playpen { } } - #[cfg(test)] mod tests { use super::*; @@ -450,4 +504,17 @@ mod tests { 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(key).unwrap(); + assert_eq!(got, value); + } } diff --git a/src/lib.rs b/src/lib.rs index 3efef1d8..cb4938fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,7 +69,7 @@ //! # let your_renderer = HtmlHandlebars::new(); //! # //! let mut book = MDBook::load("my-book").unwrap(); -//! book.set_renderer(your_renderer); +//! book.with_renderer(your_renderer); //! # } //! ``` //! @@ -109,7 +109,9 @@ extern crate serde; extern crate serde_derive; #[macro_use] extern crate serde_json; +extern crate shlex; extern crate tempdir; +extern crate tempfile; extern crate toml; #[cfg(test)] diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 28b49376..ac256eaf 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -1,18 +1,17 @@ use renderer::html_handlebars::helpers; use preprocess; -use renderer::Renderer; -use book::MDBook; -use book::{BookItem, Chapter}; -use config::{Config, Playpen, HtmlConfig}; -use {utils, theme}; -use theme::{Theme, playpen_editor}; +use renderer::{RenderContext, Renderer}; +use book::{Book, BookItem, Chapter}; +use config::{Config, HtmlConfig, Playpen}; +use {theme, utils}; +use theme::{playpen_editor, Theme}; use errors::*; use regex::{Captures, Regex}; #[allow(unused_imports)] use std::ascii::AsciiExt; use std::path::{Path, PathBuf}; use std::fs::{self, File}; -use std::io::{self, Read}; +use std::io::{self, Read, Write}; use std::collections::BTreeMap; use std::collections::HashMap; @@ -28,15 +27,28 @@ impl HtmlHandlebars { HtmlHandlebars } - fn render_item(&self, + fn write_file>( + &self, + build_dir: &Path, + filename: P, + content: &[u8], + ) -> Result<()> { + let path = build_dir.join(filename); + + utils::fs::create_file(&path)? + .write_all(content) + .map_err(|e| e.into()) + } + + fn render_item( + &self, item: &BookItem, mut ctx: RenderItemContext, - print_content: &mut String) - -> Result<()> { + print_content: &mut String, + ) -> Result<()> { // FIXME: This should be made DRY-er and rely less on mutable state match *item { - BookItem::Chapter(ref ch) => - { + BookItem::Chapter(ref ch) => { let content = ch.content.clone(); let base = ch.path.parent() .map(|dir| ctx.src_dir.join(dir)) @@ -83,18 +95,18 @@ impl HtmlHandlebars { let filepath = Path::new(&ch.path).with_extension("html"); let rendered = self.post_process( rendered, - &normalize_path(filepath.to_str().ok_or_else(|| Error::from( - format!("Bad file name: {}", filepath.display()), - ))?), - &ctx.book.config.html_config().unwrap_or_default().playpen, + &normalize_path(filepath.to_str().ok_or_else(|| { + Error::from(format!("Bad file name: {}", filepath.display())) + })?), + &ctx.html_config.playpen, ); // Write to file info!("[*] Creating {:?} ✓", filepath.display()); - ctx.book.write_file(filepath, &rendered.into_bytes())?; + self.write_file(&ctx.destination, filepath, &rendered.into_bytes())?; if ctx.is_index { - self.render_index(ctx.book, ch, &ctx.destination)?; + self.render_index(ch, &ctx.destination)?; } } _ => {} @@ -104,7 +116,7 @@ impl HtmlHandlebars { } /// Create an index.html from the first element in SUMMARY.md - fn render_index(&self, book: &MDBook, ch: &Chapter, destination: &Path) -> Result<()> { + fn render_index(&self, ch: &Chapter, destination: &Path) -> Result<()> { debug!("[*]: index.html"); let mut content = String::new(); @@ -120,10 +132,10 @@ impl HtmlHandlebars { .collect::>() .join("\n"); - book.write_file("index.html", content.as_bytes())?; + self.write_file(destination, "index.html", content.as_bytes())?; info!("[*] Creating index.html from {:?} ✓", - book.get_destination().join(&ch.path.with_extension("html"))); + destination.join(&ch.path.with_extension("html"))); Ok(()) } @@ -142,30 +154,57 @@ impl HtmlHandlebars { rendered } - fn copy_static_files(&self, book: &MDBook, theme: &Theme, html_config: &HtmlConfig) -> Result<()> { - book.write_file("book.js", &theme.js)?; - book.write_file("book.css", &theme.css)?; - book.write_file("favicon.png", &theme.favicon)?; - book.write_file("jquery.js", &theme.jquery)?; - book.write_file("highlight.css", &theme.highlight_css)?; - book.write_file("tomorrow-night.css", &theme.tomorrow_night_css)?; - book.write_file("ayu-highlight.css", &theme.ayu_highlight_css)?; - book.write_file("highlight.js", &theme.highlight_js)?; - book.write_file("clipboard.min.js", &theme.clipboard_js)?; - book.write_file("store.js", &theme.store_js)?; - book.write_file("_FontAwesome/css/font-awesome.css", theme::FONT_AWESOME)?; - book.write_file("_FontAwesome/fonts/fontawesome-webfont.eot", - theme::FONT_AWESOME_EOT)?; - book.write_file("_FontAwesome/fonts/fontawesome-webfont.svg", - theme::FONT_AWESOME_SVG)?; - book.write_file("_FontAwesome/fonts/fontawesome-webfont.ttf", - theme::FONT_AWESOME_TTF)?; - book.write_file("_FontAwesome/fonts/fontawesome-webfont.woff", - theme::FONT_AWESOME_WOFF)?; - book.write_file("_FontAwesome/fonts/fontawesome-webfont.woff2", - theme::FONT_AWESOME_WOFF2)?; - book.write_file("_FontAwesome/fonts/FontAwesome.ttf", - theme::FONT_AWESOME_TTF)?; + fn copy_static_files( + &self, + destination: &Path, + theme: &Theme, + html_config: &HtmlConfig, + ) -> Result<()> { + self.write_file(destination, "book.js", &theme.js)?; + self.write_file(destination, "book.css", &theme.css)?; + self.write_file(destination, "favicon.png", &theme.favicon)?; + self.write_file(destination, "jquery.js", &theme.jquery)?; + self.write_file(destination, "highlight.css", &theme.highlight_css)?; + self.write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?; + self.write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?; + self.write_file(destination, "highlight.js", &theme.highlight_js)?; + self.write_file(destination, "clipboard.min.js", &theme.clipboard_js)?; + self.write_file(destination, "store.js", &theme.store_js)?; + self.write_file( + destination, + "_FontAwesome/css/font-awesome.css", + theme::FONT_AWESOME, + )?; + self.write_file( + destination, + "_FontAwesome/fonts/fontawesome-webfont.eot", + theme::FONT_AWESOME_EOT, + )?; + self.write_file( + destination, + "_FontAwesome/fonts/fontawesome-webfont.svg", + theme::FONT_AWESOME_SVG, + )?; + self.write_file( + destination, + "_FontAwesome/fonts/fontawesome-webfont.ttf", + theme::FONT_AWESOME_TTF, + )?; + self.write_file( + destination, + "_FontAwesome/fonts/fontawesome-webfont.woff", + theme::FONT_AWESOME_WOFF, + )?; + self.write_file( + destination, + "_FontAwesome/fonts/fontawesome-webfont.woff2", + theme::FONT_AWESOME_WOFF2, + )?; + self.write_file( + destination, + "_FontAwesome/fonts/FontAwesome.ttf", + theme::FONT_AWESOME_TTF, + )?; let playpen_config = &html_config.playpen; @@ -173,38 +212,19 @@ impl HtmlHandlebars { if playpen_config.editable { // Load the editor let editor = playpen_editor::PlaypenEditor::new(&playpen_config.editor); - book.write_file("editor.js", &editor.js)?; - book.write_file("ace.js", &editor.ace_js)?; - book.write_file("mode-rust.js", &editor.mode_rust_js)?; - book.write_file("theme-dawn.js", &editor.theme_dawn_js)?; - book.write_file("theme-tomorrow_night.js", &editor.theme_tomorrow_night_js)?; + self.write_file(destination, "editor.js", &editor.js)?; + self.write_file(destination, "ace.js", &editor.ace_js)?; + self.write_file(destination, "mode-rust.js", &editor.mode_rust_js)?; + self.write_file(destination, "theme-dawn.js", &editor.theme_dawn_js)?; + self.write_file(destination, + "theme-tomorrow_night.js", + &editor.theme_tomorrow_night_js, + )?; } Ok(()) } - /// Helper function to write a file to the build directory, normalizing - /// the path to be relative to the book root. - fn write_custom_file(&self, custom_file: &Path, book: &MDBook) -> Result<()> { - let mut data = Vec::new(); - let mut f = File::open(custom_file)?; - f.read_to_end(&mut data)?; - - let name = match custom_file.strip_prefix(&book.root) { - Ok(p) => p.to_str().expect("Could not convert to str"), - Err(_) => { - custom_file.file_name() - .expect("File has a file name") - .to_str() - .expect("Could not convert to str") - } - }; - - book.write_file(name, &data)?; - - Ok(()) - } - /// Update the context with data for this file fn configure_print_version(&self, data: &mut serde_json::Map, @@ -227,27 +247,42 @@ impl HtmlHandlebars { /// Copy across any additional CSS and JavaScript files which the book /// has been configured to use. - fn copy_additional_css_and_js(&self, book: &MDBook) -> Result<()> { - let html = book.config.html_config().unwrap_or_default(); + fn copy_additional_css_and_js(&self, html: &HtmlConfig, destination: &Path) -> Result<()> { + let custom_files = html.additional_css.iter().chain(html.additional_js.iter()); - let custom_files = html.additional_css - .iter() - .chain(html.additional_js.iter()); + debug!("Copying additional CSS and JS"); for custom_file in custom_files { - self.write_custom_file(&custom_file, book) - .chain_err(|| format!("Copying {} failed", custom_file.display()))?; + let output_location = destination.join(custom_file); + debug!( + "Copying {} -> {}", + custom_file.display(), + output_location.display() + ); + + fs::copy(custom_file, &output_location).chain_err(|| { + format!( + "Unable to copy {} to {}", + custom_file.display(), + output_location.display() + ) + })?; } Ok(()) } } - impl Renderer for HtmlHandlebars { - fn render(&self, book: &MDBook) -> Result<()> { - let html_config = book.config.html_config().unwrap_or_default(); - let src_dir = book.root.join(&book.config.book.src); + fn name(&self) -> &str { + "html" + } + + fn render(&self, ctx: &RenderContext) -> Result<()> { + 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; debug!("[fn]: render"); let mut handlebars = Handlebars::new(); @@ -274,21 +309,17 @@ impl Renderer for HtmlHandlebars { debug!("[*]: Register handlebars helpers"); self.register_hbs_helpers(&mut handlebars); - let mut data = make_data(book, &book.config)?; + let mut data = make_data(&ctx.root, &book, &ctx.config, &html_config)?; // Print version let mut print_content = String::new(); - // TODO: The Renderer trait should really pass in where it wants us to build to... - let destination = book.get_destination(); - debug!("[*]: Check if destination directory exists"); fs::create_dir_all(&destination) .chain_err(|| "Unexpected error when constructing destination path")?; for (i, item) in book.iter().enumerate() { let ctx = RenderItemContext { - book: book, handlebars: &handlebars, destination: destination.to_path_buf(), src_dir: src_dir.clone(), @@ -301,7 +332,7 @@ impl Renderer for HtmlHandlebars { // Print version self.configure_print_version(&mut data, &print_content); - if let Some(ref title) = book.config.book.title { + if let Some(ref title) = ctx.config.book.title { data.insert("title".to_owned(), json!(title)); } @@ -314,25 +345,23 @@ impl Renderer for HtmlHandlebars { "print.html", &html_config.playpen); - book.write_file(Path::new("print").with_extension("html"), - &rendered.into_bytes())?; + self.write_file(&destination, "print.html", &rendered.into_bytes())?; info!("[*] Creating print.html ✓"); debug!("[*] Copy static files"); - self.copy_static_files(book, &theme, &html_config) + self.copy_static_files(&destination, &theme, &html_config) .chain_err(|| "Unable to copy across static files")?; - self.copy_additional_css_and_js(book) + self.copy_additional_css_and_js(&html_config, &destination) .chain_err(|| "Unable to copy across additional CSS and JS")?; // Copy all remaining files - let src = book.source_dir(); - utils::fs::copy_files_except_ext(&src, &destination, true, &["md"])?; + utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?; Ok(()) } } -fn make_data(book: &MDBook, config: &Config) -> Result> { +fn make_data(root: &Path, book: &Book, config: &Config, html_config: &HtmlConfig) -> Result> { debug!("[fn]: make_data"); let html = config.html_config().unwrap_or_default(); @@ -341,7 +370,7 @@ fn make_data(book: &MDBook, config: &Config) -> Result Result css.push(p.to_str().expect("Could not convert to str")), Err(_) => { css.push(style.file_name() @@ -375,7 +404,7 @@ fn make_data(book: &MDBook, config: &Config) -> Result js.push(p.to_str().expect("Could not convert to str")), Err(_) => { js.push(script.file_name() @@ -604,7 +633,6 @@ fn partition_source(s: &str) -> (String, String) { struct RenderItemContext<'a> { handlebars: &'a Handlebars, - book: &'a MDBook, destination: PathBuf, src_dir: PathBuf, data: serde_json::Map, diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index fe32b387..0e145cce 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,9 +1,180 @@ +//! `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-nursery.github.io/mdBook/lib/index.html +//! [RenderContext]: struct.RenderContext.html + pub use self::html_handlebars::HtmlHandlebars; mod html_handlebars; -use errors::*; +use std::io::Read; +use std::path::PathBuf; +use std::process::Command; +use serde_json; +use tempfile; +use shlex::Shlex; +use errors::*; +use config::Config; +use book::Book; + +/// 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 +/// +/// [HtmlHandlebars]: struct.HtmlHandlebars.html +/// [CmdRenderer]: struct.CmdRenderer.html pub trait Renderer { - fn render(&self, book: &::book::MDBook) -> Result<()>; + /// 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, +} + +impl RenderContext { + /// Create a new `RenderContext`. + pub(crate) fn new(root: P, book: Book, config: Config, destination: Q) -> RenderContext + where + P: Into, + Q: Into, + { + RenderContext { + book: book, + config: config, + version: env!("CARGO_PKG_VERSION").to_string(), + root: root.into(), + destination: destination.into(), + } + } + + /// 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(reader: R) -> Result { + serde_json::from_reader(reader).chain_err(|| "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) -> Result { + 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 Renderer for CmdRenderer { + fn name(&self) -> &str { + &self.name + } + + fn render(&self, ctx: &RenderContext) -> Result<()> { + info!("Invoking the \"{}\" renderer", self.cmd); + + // We need to write the RenderContext to a temporary file here instead + // of passing it in via a pipe. This prevents a race condition where + // some quickly executing command (e.g. `/bin/true`) may exit before we + // finish writing the render context (closing the stdin pipe and + // throwing a write error). + let mut temp = tempfile::tempfile().chain_err(|| "Unable to create a temporary file")?; + serde_json::to_writer(&mut temp, &ctx) + .chain_err(|| "Unable to serialize the RenderContext")?; + + let status = self.compose_command()? + .stdin(temp) + .current_dir(&ctx.destination) + .status() + .chain_err(|| "Unable to start the renderer")?; + + trace!("{} exited with output: {:?}", self.cmd, status); + + if !status.success() { + error!("Renderer exited with non-zero return code."); + bail!("The \"{}\" renderer failed", self.cmd); + } else { + Ok(()) + } + } } diff --git a/src/theme/index.hbs b/src/theme/index.hbs index a64095a4..a44bd10c 100644 --- a/src/theme/index.hbs +++ b/src/theme/index.hbs @@ -139,8 +139,22 @@ } + {{#if livereload}} - {{{livereload}}} + + {{/if}} {{#if google_analytics}} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index b541f84a..255a7f77 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod fs; mod string; +use errors::Error; use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES, OPTION_ENABLE_TABLES}; @@ -7,10 +8,7 @@ use std::borrow::Cow; pub use self::string::{RangeArgument, take_lines}; -/// -/// -/// Wrapper around the pulldown-cmark parser and renderer to render markdown - +/// Wrapper around the pulldown-cmark parser for rendering markdown to HTML. pub fn render_markdown(text: &str, curly_quotes: bool) -> String { let mut s = String::with_capacity(text.len() * 3 / 2); @@ -105,6 +103,15 @@ fn convert_quotes_to_curly(original_text: &str) -> String { .collect() } +/// Prints a "backtrace" of some `Error`. +pub fn log_backtrace(e: &Error) { + error!("Error: {}", e); + + for cause in e.iter().skip(1) { + error!("\tCaused By: {}", cause); + } +} + #[cfg(test)] mod tests { mod render_markdown { diff --git a/tests/alternate_backends.rs b/tests/alternate_backends.rs new file mode 100644 index 00000000..6f69f0ea --- /dev/null +++ b/tests/alternate_backends.rs @@ -0,0 +1,45 @@ +//! Integration tests to make sure alternate backends work. + +extern crate mdbook; +extern crate tempdir; + +use tempdir::TempDir; +use mdbook::config::Config; +use mdbook::MDBook; + +#[test] +fn passing_alternate_backend() { + let (md, _temp) = dummy_book_with_backend("passing", "true"); + + md.build().unwrap(); +} + +#[test] +fn failing_alternate_backend() { + let (md, _temp) = dummy_book_with_backend("failing", "false"); + + md.build().unwrap_err(); +} + +#[test] +fn alternate_backend_with_arguments() { + let (md, _temp) = dummy_book_with_backend("arguments", "echo Hello World!"); + + md.build().unwrap(); +} + +fn dummy_book_with_backend(name: &str, command: &str) -> (MDBook, TempDir) { + let temp = TempDir::new("mdbook").unwrap(); + + let mut config = Config::default(); + config + .set(format!("output.{}.command", name), command) + .unwrap(); + + let md = MDBook::init(temp.path()) + .with_config(config) + .build() + .unwrap(); + + (md, temp) +} diff --git a/tests/init.rs b/tests/init.rs index 8659936a..8bea5792 100644 --- a/tests/init.rs +++ b/tests/init.rs @@ -62,7 +62,7 @@ fn run_mdbook_init_with_custom_book_and_src_locations() { #[test] fn book_toml_isnt_required() { let temp = TempDir::new("mdbook").unwrap(); - let mut md = MDBook::init(temp.path()).build().unwrap(); + let md = MDBook::init(temp.path()).build().unwrap(); let _ = fs::remove_file(temp.path().join("book.toml")); diff --git a/tests/rendered_output.rs b/tests/rendered_output.rs index dd630847..38b804b2 100644 --- a/tests/rendered_output.rs +++ b/tests/rendered_output.rs @@ -22,7 +22,6 @@ use mdbook::utils::fs::file_to_string; use mdbook::config::Config; use mdbook::MDBook; - const BOOK_ROOT: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/dummy_book"); const TOC_TOP_LEVEL: &[&'static str] = &[ "1. First Chapter", @@ -36,7 +35,7 @@ const TOC_SECOND_LEVEL: &[&'static str] = &["1.1. Nested Chapter"]; #[test] fn build_the_dummy_book() { let temp = DummyBook::new().build().unwrap(); - let mut md = MDBook::load(temp.path()).unwrap(); + let md = MDBook::load(temp.path()).unwrap(); md.build().unwrap(); } @@ -44,7 +43,7 @@ fn build_the_dummy_book() { #[test] fn by_default_mdbook_generates_rendered_content_in_the_book_directory() { let temp = DummyBook::new().build().unwrap(); - let mut md = MDBook::load(temp.path()).unwrap(); + let md = MDBook::load(temp.path()).unwrap(); assert!(!temp.path().join("book").exists()); md.build().unwrap(); @@ -56,7 +55,7 @@ fn by_default_mdbook_generates_rendered_content_in_the_book_directory() { #[test] fn make_sure_bottom_level_files_contain_links_to_chapters() { let temp = DummyBook::new().build().unwrap(); - let mut md = MDBook::load(temp.path()).unwrap(); + let md = MDBook::load(temp.path()).unwrap(); md.build().unwrap(); let dest = temp.path().join("book"); @@ -78,7 +77,7 @@ fn make_sure_bottom_level_files_contain_links_to_chapters() { #[test] fn check_correct_cross_links_in_nested_dir() { let temp = DummyBook::new().build().unwrap(); - let mut md = MDBook::load(temp.path()).unwrap(); + let md = MDBook::load(temp.path()).unwrap(); md.build().unwrap(); let first = temp.path().join("book").join("first"); @@ -115,7 +114,7 @@ fn check_correct_cross_links_in_nested_dir() { #[test] fn rendered_code_has_playpen_stuff() { let temp = DummyBook::new().build().unwrap(); - let mut md = MDBook::load(temp.path()).unwrap(); + let md = MDBook::load(temp.path()).unwrap(); md.build().unwrap(); let nested = temp.path().join("book/first/nested.html"); @@ -138,7 +137,7 @@ fn chapter_content_appears_in_rendered_document() { ]; let temp = DummyBook::new().build().unwrap(); - let mut md = MDBook::load(temp.path()).unwrap(); + let md = MDBook::load(temp.path()).unwrap(); md.build().unwrap(); let destination = temp.path().join("book"); @@ -149,7 +148,6 @@ fn chapter_content_appears_in_rendered_document() { } } - /// Apply a series of predicates to some root predicate, where each /// successive predicate is the descendant of the last one. Similar to how you /// might do `ul.foo li a` in CSS to access all anchor tags in the `foo` list. @@ -162,7 +160,6 @@ macro_rules! descendants { }; } - /// Make sure that all `*.md` files (excluding `SUMMARY.md`) were rendered /// and placed in the `book` directory with their extensions set to `*.html`. #[test] @@ -286,7 +283,7 @@ fn create_missing_file_with_config() { #[test] fn able_to_include_rust_files_in_chapters() { let temp = DummyBook::new().build().unwrap(); - let mut md = MDBook::load(temp.path()).unwrap(); + let md = MDBook::load(temp.path()).unwrap(); md.build().unwrap(); let second = temp.path().join("book/second.html"); @@ -302,10 +299,9 @@ fn able_to_include_rust_files_in_chapters() { fn example_book_can_build() { let example_book_dir = dummy_book::new_copy_of_example_book().unwrap(); - let mut md = MDBook::load(example_book_dir.path()).unwrap(); + let md = MDBook::load(example_book_dir.path()).unwrap(); - let got = md.build(); - assert!(got.is_ok()); + md.build().unwrap(); } #[test]