Add support for alternative backends (#507)

* Added a mechanism for creating alternate backends

* Added a CmdRenderer and the ability to have multiple renderers

* Made MDBook::load() autodetect renderers

* Added a couple methods to RenderContext

* Converted RenderContext.version to a String

* Made sure all alternate renderers are invoked as `mdbook-*`

* Factored out the logic for determining which renderer to use

* Added tests for renderer detection

* Made it so `mdbook test` works on the book-example again

* Updated the "For Developers" docs

* Removed `[output.epub]` from the example book's book.toml

* Added a bit more info on how backends should work

* Added a `destination` key to the RenderContext

* Altered how we wait for an alternate backend to finish

* Refactored the Renderer trait to not use MDBook and moved livereload to the template

* Moved info for developers out of the book.toml format chapter

* MOAR docs

* MDBook::build() no longer takes &mut self

* Replaced a bunch of println!()'s with proper log macros

* Cleaned up the build() method and backend discovery

* Added a couple notes and doc-comments

* Found a race condition when backends exit really quickly

* Added support for backends with arguments

* Fixed a funny doc-comment
This commit is contained in:
Michael Bryan 2018-01-07 22:10:48 +08:00 committed by GitHub
parent dedc208a6a
commit fd7e8d1b7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 849 additions and 298 deletions

View File

@ -32,6 +32,8 @@ open = "1.1"
regex = "0.2.1" regex = "0.2.1"
tempdir = "0.3.4" tempdir = "0.3.4"
itertools = "0.7.4" itertools = "0.7.4"
tempfile = "2.2.0"
shlex = "0.1.1"
# Watch feature # Watch feature
notify = { version = "4.0", optional = true } notify = { version = "4.0", optional = true }

View File

@ -15,6 +15,6 @@
- [Syntax highlighting](format/theme/syntax-highlighting.md) - [Syntax highlighting](format/theme/syntax-highlighting.md)
- [MathJax Support](format/mathjax.md) - [MathJax Support](format/mathjax.md)
- [Rust code specific features](format/rust.md) - [Rust code specific features](format/rust.md)
- [Rust Library](lib/lib.md) - [For Developers](lib/index.md)
----------- -----------
[Contributors](misc/contributors.md) [Contributors](misc/contributors.md)

View File

@ -69,8 +69,6 @@ renderer need to be specified under the TOML table `[output.html]`.
The following configuration options are available: The following configuration options are available:
pub playpen: Playpen,
- **theme:** mdBook comes with a default theme and all the resource files - **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 needed for it. But if this option is set, mdBook will selectively overwrite
the theme files with the ones found in the specified folder. the theme files with the ones found in the specified folder.
@ -105,51 +103,3 @@ additional-js = ["custom.js"]
editor = "./path/to/editor" editor = "./path/to/editor"
editable = false 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<bool>,
}
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::<Vec<bool>>("output.random.baz") {
println!("{:?}", baz); // prints [true, true, false]
// do something interesting with baz
}
// start the rendering process
```

View File

@ -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<bool>,
}
# 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<bool> = 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

View File

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

View File

@ -30,7 +30,8 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
book.build()?; book.build()?;
if args.is_present("open") { 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(()) Ok(())

View File

@ -26,7 +26,11 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
// Skip this if `--force` is present // Skip this if `--force` is present
if !args.is_present("force") { if !args.is_present("force") {
// Print warning // 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."); println!("This could potentially overwrite files already present in that directory.");
print!("\nAre you sure you want to continue? (y/n) "); print!("\nAre you sure you want to continue? (y/n) ");

View File

@ -16,7 +16,7 @@ use clap::{App, AppSettings, ArgMatches};
use chrono::Local; use chrono::Local;
use log::LevelFilter; use log::LevelFilter;
use env_logger::Builder; use env_logger::Builder;
use error_chain::ChainedError; use mdbook::utils;
pub mod build; pub mod build;
pub mod init; pub mod init;
@ -64,7 +64,7 @@ fn main() {
}; };
if let Err(e) = res { if let Err(e) = res {
eprintln!("{}", e.display_chain()); utils::log_backtrace(&e);
::std::process::exit(101); ::std::process::exit(101);
} }
@ -101,12 +101,12 @@ fn get_book_dir(args: &ArgMatches) -> PathBuf {
p.to_path_buf() p.to_path_buf()
} }
} else { } else {
env::current_dir().unwrap() env::current_dir().expect("Unable to determine the current directory")
} }
} }
fn open<P: AsRef<OsStr>>(path: P) { fn open<P: AsRef<OsStr>>(path: P) {
if let Err(e) = open::that(path) { if let Err(e) = open::that(path) {
println!("Error opening web browser: {}", e); error!("Error opening web browser: {}", e);
} }
} }

View File

@ -7,6 +7,7 @@ use self::iron::{status, AfterMiddleware, Chain, Iron, IronError, IronResult, Re
Set}; Set};
use clap::{App, ArgMatches, SubCommand}; use clap::{App, ArgMatches, SubCommand};
use mdbook::MDBook; use mdbook::MDBook;
use mdbook::utils;
use mdbook::errors::*; use mdbook::errors::*;
use {get_book_dir, open}; use {get_book_dir, open};
#[cfg(feature = "watch")] #[cfg(feature = "watch")]
@ -38,8 +39,6 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> {
// Watch command implementation // Watch command implementation
pub fn execute(args: &ArgMatches) -> Result<()> { pub fn execute(args: &ArgMatches) -> Result<()> {
const RELOAD_COMMAND: &'static str = "reload";
let book_dir = get_book_dir(args); let book_dir = get_book_dir(args);
let mut book = MDBook::load(&book_dir)?; let mut book = MDBook::load(&book_dir)?;
@ -52,29 +51,13 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
let address = format!("{}:{}", interface, port); let address = format!("{}:{}", interface, port);
let ws_address = format!("{}:{}", interface, ws_port); let ws_address = format!("{}:{}", interface, ws_port);
let livereload = Some(format!( let livereload_url = format!("ws://{}:{}", public_address, ws_port);
r#" book.config
<script type="text/javascript"> .set("output.html.livereload-url", &livereload_url)?;
var socket = new WebSocket("ws://{}:{}");
socket.onmessage = function (event) {{
if (event.data === "{}") {{
socket.close();
location.reload(true); // force reload from server (not from cache)
}}
}};
window.onbeforeunload = function() {{
socket.close();
}}
</script>
"#,
public_address, ws_port, RELOAD_COMMAND
));
book.livereload = livereload.clone();
book.build()?; 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); chain.link_after(ErrorRecover);
let _iron = Iron::new(chain) let _iron = Iron::new(chain)
.http(&*address) .http(&*address)
@ -90,7 +73,7 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
}); });
let serving_url = format!("http://{}", address); let serving_url = format!("http://{}", address);
println!("\nServing on: {}", serving_url); info!("Serving on: {}", serving_url);
if open_browser { if open_browser {
open(serving_url); open(serving_url);
@ -98,26 +81,25 @@ pub fn execute(args: &ArgMatches) -> Result<()> {
#[cfg(feature = "watch")] #[cfg(feature = "watch")]
watch::trigger_on_change(&mut book, move |path, book_dir| { 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 :( // 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) let result = MDBook::load(&book_dir)
.map(move |mut b| { .and_then(move |mut b| {
b.livereload = livereload; b.config.set("output.html.livereload-url", &livereload_url)?;
b Ok(b)
}) })
.and_then(|mut b| b.build()); .and_then(|b| b.build());
if let Err(e) = result { if let Err(e) = result {
error!("Unable to load the book"); error!("Unable to load the book");
error!("Error: {}", e); utils::log_backtrace(&e);
for cause in e.iter().skip(1) {
error!("\tCaused By: {}", cause);
}
} else { } else {
let _ = broadcaster.send(RELOAD_COMMAND); let _ = broadcaster.send("reload");
} }
}); });

View File

@ -6,6 +6,7 @@ use std::time::Duration;
use std::sync::mpsc::channel; use std::sync::mpsc::channel;
use clap::{App, ArgMatches, SubCommand}; use clap::{App, ArgMatches, SubCommand};
use mdbook::MDBook; use mdbook::MDBook;
use mdbook::utils;
use mdbook::errors::Result; use mdbook::errors::Result;
use {get_book_dir, open}; use {get_book_dir, open};
@ -22,21 +23,21 @@ pub fn make_subcommand<'a, 'b>() -> App<'a, 'b> {
// Watch command implementation // Watch command implementation
pub fn execute(args: &ArgMatches) -> Result<()> { pub fn execute(args: &ArgMatches) -> Result<()> {
let book_dir = get_book_dir(args); 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") { if args.is_present("open") {
book.build()?; 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| { trigger_on_change(&book, |path, book_dir| {
println!("File changed: {:?}\nBuilding book...\n", path); info!("File changed: {:?}\nBuilding book...\n", path);
let result = MDBook::load(&book_dir).and_then(|mut b| b.build()); let result = MDBook::load(&book_dir).and_then(|b| b.build());
if let Err(e) = result { if let Err(e) = result {
println!("Error while building: {}", e); error!("Unable to build the book");
utils::log_backtrace(&e);
} }
println!();
}); });
Ok(()) Ok(())
@ -56,14 +57,14 @@ where
let mut watcher = match notify::watcher(tx, Duration::from_secs(1)) { let mut watcher = match notify::watcher(tx, Duration::from_secs(1)) {
Ok(w) => w, Ok(w) => w,
Err(e) => { 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) ::std::process::exit(1)
} }
}; };
// Add the source directory to the watcher // Add the source directory to the watcher
if let Err(e) = watcher.watch(book.source_dir(), Recursive) { 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); ::std::process::exit(1);
}; };
@ -72,9 +73,10 @@ where
// Add the book.toml file to the watcher if it exists // Add the book.toml file to the watcher if it exists
let _ = watcher.watch(book.root.join("book.toml"), NonRecursive); let _ = watcher.watch(book.root.join("book.toml"), NonRecursive);
println!("\nListening for changes...\n"); info!("Listening for changes...");
for event in rx.iter() { for event in rx.iter() {
debug!("Received filesystem event: {:?}", event);
match event { match event {
Create(path) | Write(path) | Remove(path) | Rename(_, path) => { Create(path) | Write(path) | Remove(path) | Rename(_, path) => {
closure(&path, &book.root); closure(&path, &book.root);

View File

@ -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::summary::{parse_summary, Link, SectionNumber, Summary, SummaryItem};
pub use self::init::BookBuilder; pub use self::init::BookBuilder;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::io::Write; use std::io::Write;
use std::process::Command; use std::process::Command;
use tempdir::TempDir; use tempdir::TempDir;
use toml::Value;
use utils; use utils;
use renderer::{HtmlHandlebars, Renderer}; use renderer::{CmdRenderer, HtmlHandlebars, RenderContext, Renderer};
use preprocess; use preprocess;
use errors::*; use errors::*;
@ -33,9 +34,9 @@ pub struct MDBook {
pub root: PathBuf, pub root: PathBuf,
/// The configuration used to tweak now a book is built. /// The configuration used to tweak now a book is built.
pub config: Config, pub config: Config,
/// A representation of the book's contents in memory.
book: Book, pub book: Book,
renderer: Box<Renderer>, renderers: Vec<Box<Renderer>>,
/// The URL used for live reloading when serving up the book. /// The URL used for live reloading when serving up the book.
pub livereload: Option<String>, pub livereload: Option<String>,
@ -75,17 +76,20 @@ impl MDBook {
/// Load a book from its root directory using a custom 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> { pub fn load_with_config<P: Into<PathBuf>>(book_root: P, config: Config) -> Result<MDBook> {
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 book = book::load_book(&src_dir, &config.build)?;
let livereload = None;
let renderers = determine_renderers(&config);
Ok(MDBook { Ok(MDBook {
root: book_root, root,
config: config, config,
book: book, book,
renderer: Box::new(HtmlHandlebars::new()), renderers,
livereload: None, livereload,
}) })
} }
@ -142,32 +146,47 @@ impl MDBook {
} }
/// Tells the renderer to build our book and put it in the build directory. /// 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"); debug!("[fn]: build");
let dest = self.get_destination(); for renderer in &self.renderers {
if dest.exists() { self.run_renderer(renderer.as_ref())?;
utils::fs::remove_dir_content(&dest).chain_err(|| "Unable to clear output directory")?;
} }
self.renderer.render(self) Ok(())
} }
// FIXME: This doesn't belong as part of `MDBook`. It is only used by the HTML renderer fn run_renderer(&self, renderer: &Renderer) -> Result<()> {
#[doc(hidden)] let name = renderer.name();
pub fn write_file<P: AsRef<Path>>(&self, filename: P, content: &[u8]) -> Result<()> { let build_dir = self.build_dir_for(name);
let path = self.get_destination().join(filename); if build_dir.exists() {
debug!(
"Cleaning build dir for the \"{}\" renderer ({})",
name,
build_dir.display()
);
utils::fs::create_file(&path)? utils::fs::remove_dir_content(&build_dir)
.write_all(content) .chain_err(|| "Unable to clear output directory")?;
.map_err(|e| e.into()) }
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. /// You can change the default renderer to another one by using this method.
/// The only requirement is for your renderer to implement the [Renderer /// The only requirement is for your renderer to implement the [Renderer
/// trait](../../renderer/renderer/trait.Renderer.html) /// trait](../../renderer/renderer/trait.Renderer.html)
pub fn set_renderer<R: Renderer + 'static>(mut self, renderer: R) -> Self { pub fn with_renderer<R: Renderer + 'static>(&mut self, renderer: R) -> &mut Self {
self.renderer = Box::new(renderer); self.renderers.push(Box::new(renderer));
self self
} }
@ -215,10 +234,38 @@ impl MDBook {
Ok(()) Ok(())
} }
// FIXME: This doesn't belong under `MDBook`, it should really be passed to the renderer directly. /// The logic for determining where a backend should put its build
#[doc(hidden)] /// artefacts.
pub fn get_destination(&self) -> PathBuf { ///
self.root.join(&self.config.build.build_dir) /// 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. /// Get the directory containing this book's source files.
@ -226,7 +273,7 @@ impl MDBook {
self.root.join(&self.config.book.src) 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)] #[doc(hidden)]
pub fn theme_dir(&self) -> PathBuf { pub fn theme_dir(&self) -> PathBuf {
match self.config.html_config().and_then(|h| h.theme) { 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<Box<Renderer>> {
let mut renderers: Vec<Box<Renderer>> = 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<Renderer> {
// 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");
}
}

View File

@ -25,9 +25,10 @@ impl Config {
/// Load the configuration file from disk. /// Load the configuration file from disk.
pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> { pub fn from_disk<P: AsRef<Path>>(config_file: P) -> Result<Config> {
let mut buffer = String::new(); let mut buffer = String::new();
File::open(config_file).chain_err(|| "Unable to open the configuration file")? File::open(config_file)
.read_to_string(&mut buffer) .chain_err(|| "Unable to open the configuration file")?
.chain_err(|| "Couldn't read the file")?; .read_to_string(&mut buffer)
.chain_err(|| "Couldn't read the file")?;
Config::from_str(&buffer) Config::from_str(&buffer)
} }
@ -53,7 +54,8 @@ impl Config {
/// # Note /// # Note
/// ///
/// This is for compatibility only. It will be removed completely once the /// 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<HtmlConfig> { pub fn html_config(&self) -> Option<HtmlConfig> {
self.get_deserialized("output.html").ok() self.get_deserialized("output.html").ok()
} }
@ -64,14 +66,28 @@ impl Config {
let name = name.as_ref(); let name = name.as_ref();
if let Some(value) = self.get(name) { if let Some(value) = self.get(name) {
value.clone() value
.try_into() .clone()
.chain_err(|| "Couldn't deserialize the value") .try_into()
.chain_err(|| "Couldn't deserialize the value")
} else { } else {
bail!("Key not found, {:?}", name) 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<S: Serialize, I: AsRef<str>>(&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 { fn from_legacy(mut table: Table) -> Config {
let mut cfg = Config::default(); let mut cfg = Config::default();
@ -91,10 +107,11 @@ impl Config {
get_and_insert!(table, "source" => cfg.book.src); get_and_insert!(table, "source" => cfg.book.src);
get_and_insert!(table, "description" => cfg.book.description); get_and_insert!(table, "description" => cfg.book.description);
// This complicated chain of and_then's is so we can move // This complicated chain of and_then's is so we can move
// "output.html.destination" to "build.build_dir" and parse it into a // "output.html.destination" to "build.build_dir" and parse it into a
// PathBuf. // PathBuf.
let destination: Option<PathBuf> = table.get_mut("output") let destination: Option<PathBuf> = table
.get_mut("output")
.and_then(|output| output.as_table_mut()) .and_then(|output| output.as_table_mut())
.and_then(|output| output.get_mut("html")) .and_then(|output| output.get_mut("html"))
.and_then(|html| html.as_table_mut()) .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> { fn recursive_get<'a>(key: &[&str], table: &'a Table) -> Option<&'a Value> {
if key.is_empty() { if key.is_empty() {
return None; 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> { 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 // TODO: Figure out how to abstract over mutability to reduce copy-pasta
if key.is_empty() { if key.is_empty() {
@ -171,13 +215,15 @@ impl<'de> Deserialize<'de> for Config {
return Ok(Config::from_legacy(table)); return Ok(Config::from_legacy(table));
} }
let book: BookConfig = table.remove("book") let book: BookConfig = table
.and_then(|value| value.try_into().ok()) .remove("book")
.unwrap_or_default(); .and_then(|value| value.try_into().ok())
.unwrap_or_default();
let build: BuildConfig = table.remove("build") let build: BuildConfig = table
.and_then(|value| value.try_into().ok()) .remove("build")
.unwrap_or_default(); .and_then(|value| value.try_into().ok())
.unwrap_or_default();
Ok(Config { Ok(Config {
book: book, book: book,
@ -200,7 +246,7 @@ impl Serialize for Config {
}; };
table.insert("book".to_string(), book_config); table.insert("book".to_string(), book_config);
Value::Table(table).serialize(s) Value::Table(table).serialize(s)
} }
} }
@ -208,10 +254,11 @@ impl Serialize for Config {
fn is_legacy_format(table: &Table) -> bool { fn is_legacy_format(table: &Table) -> bool {
let top_level_items = ["title", "author", "authors"]; 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 /// Configuration options which are specific to the book and required for
/// loading it from disk. /// loading it from disk.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -271,6 +318,14 @@ pub struct HtmlConfig {
pub additional_css: Vec<PathBuf>, pub additional_css: Vec<PathBuf>,
pub additional_js: Vec<PathBuf>, pub additional_js: Vec<PathBuf>,
pub playpen: Playpen, 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<String>,
} }
/// Configuration for tweaking how the the HTML renderer handles the playpen. /// Configuration for tweaking how the the HTML renderer handles the playpen.
@ -290,7 +345,6 @@ impl Default for Playpen {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -450,4 +504,17 @@ mod tests {
assert_eq!(got.build, build_should_be); assert_eq!(got.build, build_should_be);
assert_eq!(got.html_config().unwrap(), html_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);
}
} }

View File

@ -69,7 +69,7 @@
//! # let your_renderer = HtmlHandlebars::new(); //! # let your_renderer = HtmlHandlebars::new();
//! # //! #
//! let mut book = MDBook::load("my-book").unwrap(); //! 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; extern crate serde_derive;
#[macro_use] #[macro_use]
extern crate serde_json; extern crate serde_json;
extern crate shlex;
extern crate tempdir; extern crate tempdir;
extern crate tempfile;
extern crate toml; extern crate toml;
#[cfg(test)] #[cfg(test)]

View File

@ -1,18 +1,17 @@
use renderer::html_handlebars::helpers; use renderer::html_handlebars::helpers;
use preprocess; use preprocess;
use renderer::Renderer; use renderer::{RenderContext, Renderer};
use book::MDBook; use book::{Book, BookItem, Chapter};
use book::{BookItem, Chapter}; use config::{Config, HtmlConfig, Playpen};
use config::{Config, Playpen, HtmlConfig}; use {theme, utils};
use {utils, theme}; use theme::{playpen_editor, Theme};
use theme::{Theme, playpen_editor};
use errors::*; use errors::*;
use regex::{Captures, Regex}; use regex::{Captures, Regex};
#[allow(unused_imports)] use std::ascii::AsciiExt; #[allow(unused_imports)] use std::ascii::AsciiExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::{self, Read}; use std::io::{self, Read, Write};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::HashMap; use std::collections::HashMap;
@ -28,15 +27,28 @@ impl HtmlHandlebars {
HtmlHandlebars HtmlHandlebars
} }
fn render_item(&self, fn write_file<P: AsRef<Path>>(
&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, item: &BookItem,
mut ctx: RenderItemContext, mut ctx: RenderItemContext,
print_content: &mut String) print_content: &mut String,
-> Result<()> { ) -> Result<()> {
// FIXME: This should be made DRY-er and rely less on mutable state // FIXME: This should be made DRY-er and rely less on mutable state
match *item { match *item {
BookItem::Chapter(ref ch) => BookItem::Chapter(ref ch) => {
{
let content = ch.content.clone(); let content = ch.content.clone();
let base = ch.path.parent() let base = ch.path.parent()
.map(|dir| ctx.src_dir.join(dir)) .map(|dir| ctx.src_dir.join(dir))
@ -83,18 +95,18 @@ impl HtmlHandlebars {
let filepath = Path::new(&ch.path).with_extension("html"); let filepath = Path::new(&ch.path).with_extension("html");
let rendered = self.post_process( let rendered = self.post_process(
rendered, rendered,
&normalize_path(filepath.to_str().ok_or_else(|| Error::from( &normalize_path(filepath.to_str().ok_or_else(|| {
format!("Bad file name: {}", filepath.display()), Error::from(format!("Bad file name: {}", filepath.display()))
))?), })?),
&ctx.book.config.html_config().unwrap_or_default().playpen, &ctx.html_config.playpen,
); );
// Write to file // Write to file
info!("[*] Creating {:?} ✓", filepath.display()); 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 { 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 /// 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"); debug!("[*]: index.html");
let mut content = String::new(); let mut content = String::new();
@ -120,10 +132,10 @@ impl HtmlHandlebars {
.collect::<Vec<&str>>() .collect::<Vec<&str>>()
.join("\n"); .join("\n");
book.write_file("index.html", content.as_bytes())?; self.write_file(destination, "index.html", content.as_bytes())?;
info!("[*] Creating index.html from {:?} ✓", info!("[*] Creating index.html from {:?} ✓",
book.get_destination().join(&ch.path.with_extension("html"))); destination.join(&ch.path.with_extension("html")));
Ok(()) Ok(())
} }
@ -142,30 +154,57 @@ impl HtmlHandlebars {
rendered rendered
} }
fn copy_static_files(&self, book: &MDBook, theme: &Theme, html_config: &HtmlConfig) -> Result<()> { fn copy_static_files(
book.write_file("book.js", &theme.js)?; &self,
book.write_file("book.css", &theme.css)?; destination: &Path,
book.write_file("favicon.png", &theme.favicon)?; theme: &Theme,
book.write_file("jquery.js", &theme.jquery)?; html_config: &HtmlConfig,
book.write_file("highlight.css", &theme.highlight_css)?; ) -> Result<()> {
book.write_file("tomorrow-night.css", &theme.tomorrow_night_css)?; self.write_file(destination, "book.js", &theme.js)?;
book.write_file("ayu-highlight.css", &theme.ayu_highlight_css)?; self.write_file(destination, "book.css", &theme.css)?;
book.write_file("highlight.js", &theme.highlight_js)?; self.write_file(destination, "favicon.png", &theme.favicon)?;
book.write_file("clipboard.min.js", &theme.clipboard_js)?; self.write_file(destination, "jquery.js", &theme.jquery)?;
book.write_file("store.js", &theme.store_js)?; self.write_file(destination, "highlight.css", &theme.highlight_css)?;
book.write_file("_FontAwesome/css/font-awesome.css", theme::FONT_AWESOME)?; self.write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?;
book.write_file("_FontAwesome/fonts/fontawesome-webfont.eot", self.write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?;
theme::FONT_AWESOME_EOT)?; self.write_file(destination, "highlight.js", &theme.highlight_js)?;
book.write_file("_FontAwesome/fonts/fontawesome-webfont.svg", self.write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
theme::FONT_AWESOME_SVG)?; self.write_file(destination, "store.js", &theme.store_js)?;
book.write_file("_FontAwesome/fonts/fontawesome-webfont.ttf", self.write_file(
theme::FONT_AWESOME_TTF)?; destination,
book.write_file("_FontAwesome/fonts/fontawesome-webfont.woff", "_FontAwesome/css/font-awesome.css",
theme::FONT_AWESOME_WOFF)?; theme::FONT_AWESOME,
book.write_file("_FontAwesome/fonts/fontawesome-webfont.woff2", )?;
theme::FONT_AWESOME_WOFF2)?; self.write_file(
book.write_file("_FontAwesome/fonts/FontAwesome.ttf", destination,
theme::FONT_AWESOME_TTF)?; "_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; let playpen_config = &html_config.playpen;
@ -173,38 +212,19 @@ impl HtmlHandlebars {
if playpen_config.editable { if playpen_config.editable {
// Load the editor // Load the editor
let editor = playpen_editor::PlaypenEditor::new(&playpen_config.editor); let editor = playpen_editor::PlaypenEditor::new(&playpen_config.editor);
book.write_file("editor.js", &editor.js)?; self.write_file(destination, "editor.js", &editor.js)?;
book.write_file("ace.js", &editor.ace_js)?; self.write_file(destination, "ace.js", &editor.ace_js)?;
book.write_file("mode-rust.js", &editor.mode_rust_js)?; self.write_file(destination, "mode-rust.js", &editor.mode_rust_js)?;
book.write_file("theme-dawn.js", &editor.theme_dawn_js)?; self.write_file(destination, "theme-dawn.js", &editor.theme_dawn_js)?;
book.write_file("theme-tomorrow_night.js", &editor.theme_tomorrow_night_js)?; self.write_file(destination,
"theme-tomorrow_night.js",
&editor.theme_tomorrow_night_js,
)?;
} }
Ok(()) 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 /// Update the context with data for this file
fn configure_print_version(&self, fn configure_print_version(&self,
data: &mut serde_json::Map<String, serde_json::Value>, data: &mut serde_json::Map<String, serde_json::Value>,
@ -227,27 +247,42 @@ impl HtmlHandlebars {
/// Copy across any additional CSS and JavaScript files which the book /// Copy across any additional CSS and JavaScript files which the book
/// has been configured to use. /// has been configured to use.
fn copy_additional_css_and_js(&self, book: &MDBook) -> Result<()> { fn copy_additional_css_and_js(&self, html: &HtmlConfig, destination: &Path) -> Result<()> {
let html = book.config.html_config().unwrap_or_default(); let custom_files = html.additional_css.iter().chain(html.additional_js.iter());
let custom_files = html.additional_css debug!("Copying additional CSS and JS");
.iter()
.chain(html.additional_js.iter());
for custom_file in custom_files { for custom_file in custom_files {
self.write_custom_file(&custom_file, book) let output_location = destination.join(custom_file);
.chain_err(|| format!("Copying {} failed", custom_file.display()))?; 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(()) Ok(())
} }
} }
impl Renderer for HtmlHandlebars { impl Renderer for HtmlHandlebars {
fn render(&self, book: &MDBook) -> Result<()> { fn name(&self) -> &str {
let html_config = book.config.html_config().unwrap_or_default(); "html"
let src_dir = book.root.join(&book.config.book.src); }
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"); debug!("[fn]: render");
let mut handlebars = Handlebars::new(); let mut handlebars = Handlebars::new();
@ -274,21 +309,17 @@ impl Renderer for HtmlHandlebars {
debug!("[*]: Register handlebars helpers"); debug!("[*]: Register handlebars helpers");
self.register_hbs_helpers(&mut handlebars); 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 // Print version
let mut print_content = String::new(); 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"); debug!("[*]: Check if destination directory exists");
fs::create_dir_all(&destination) fs::create_dir_all(&destination)
.chain_err(|| "Unexpected error when constructing destination path")?; .chain_err(|| "Unexpected error when constructing destination path")?;
for (i, item) in book.iter().enumerate() { for (i, item) in book.iter().enumerate() {
let ctx = RenderItemContext { let ctx = RenderItemContext {
book: book,
handlebars: &handlebars, handlebars: &handlebars,
destination: destination.to_path_buf(), destination: destination.to_path_buf(),
src_dir: src_dir.clone(), src_dir: src_dir.clone(),
@ -301,7 +332,7 @@ impl Renderer for HtmlHandlebars {
// Print version // Print version
self.configure_print_version(&mut data, &print_content); 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)); data.insert("title".to_owned(), json!(title));
} }
@ -314,25 +345,23 @@ impl Renderer for HtmlHandlebars {
"print.html", "print.html",
&html_config.playpen); &html_config.playpen);
book.write_file(Path::new("print").with_extension("html"), self.write_file(&destination, "print.html", &rendered.into_bytes())?;
&rendered.into_bytes())?;
info!("[*] Creating print.html ✓"); info!("[*] Creating print.html ✓");
debug!("[*] Copy static files"); 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")?; .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")?; .chain_err(|| "Unable to copy across additional CSS and JS")?;
// Copy all remaining files // Copy all remaining files
let src = book.source_dir(); utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?;
utils::fs::copy_files_except_ext(&src, &destination, true, &["md"])?;
Ok(()) Ok(())
} }
} }
fn make_data(book: &MDBook, config: &Config) -> Result<serde_json::Map<String, serde_json::Value>> { fn make_data(root: &Path, book: &Book, config: &Config, html_config: &HtmlConfig) -> Result<serde_json::Map<String, serde_json::Value>> {
debug!("[fn]: make_data"); debug!("[fn]: make_data");
let html = config.html_config().unwrap_or_default(); let html = config.html_config().unwrap_or_default();
@ -341,7 +370,7 @@ fn make_data(book: &MDBook, config: &Config) -> Result<serde_json::Map<String, s
data.insert("book_title".to_owned(), json!(config.book.title.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())); data.insert("description".to_owned(), json!(config.book.description.clone().unwrap_or_default()));
data.insert("favicon".to_owned(), json!("favicon.png")); data.insert("favicon".to_owned(), json!("favicon.png"));
if let Some(ref livereload) = book.livereload { if let Some(ref livereload) = html_config.livereload_url {
data.insert("livereload".to_owned(), json!(livereload)); data.insert("livereload".to_owned(), json!(livereload));
} }
@ -358,7 +387,7 @@ fn make_data(book: &MDBook, config: &Config) -> Result<serde_json::Map<String, s
if !html.additional_css.is_empty() { if !html.additional_css.is_empty() {
let mut css = Vec::new(); let mut css = Vec::new();
for style in &html.additional_css { for style in &html.additional_css {
match style.strip_prefix(&book.root) { match style.strip_prefix(root) {
Ok(p) => css.push(p.to_str().expect("Could not convert to str")), Ok(p) => css.push(p.to_str().expect("Could not convert to str")),
Err(_) => { Err(_) => {
css.push(style.file_name() css.push(style.file_name()
@ -375,7 +404,7 @@ fn make_data(book: &MDBook, config: &Config) -> Result<serde_json::Map<String, s
if !html.additional_js.is_empty() { if !html.additional_js.is_empty() {
let mut js = Vec::new(); let mut js = Vec::new();
for script in &html.additional_js { for script in &html.additional_js {
match script.strip_prefix(&book.root) { match script.strip_prefix(root) {
Ok(p) => js.push(p.to_str().expect("Could not convert to str")), Ok(p) => js.push(p.to_str().expect("Could not convert to str")),
Err(_) => { Err(_) => {
js.push(script.file_name() js.push(script.file_name()
@ -604,7 +633,6 @@ fn partition_source(s: &str) -> (String, String) {
struct RenderItemContext<'a> { struct RenderItemContext<'a> {
handlebars: &'a Handlebars, handlebars: &'a Handlebars,
book: &'a MDBook,
destination: PathBuf, destination: PathBuf,
src_dir: PathBuf, src_dir: PathBuf,
data: serde_json::Map<String, serde_json::Value>, data: serde_json::Map<String, serde_json::Value>,

View File

@ -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; pub use self::html_handlebars::HtmlHandlebars;
mod html_handlebars; 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 { 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<P, Q>(root: P, book: Book, config: Config, destination: Q) -> RenderContext
where
P: Into<PathBuf>,
Q: Into<PathBuf>,
{
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<R: Read>(reader: R) -> Result<RenderContext> {
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<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 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(())
}
}
} }

View File

@ -139,8 +139,22 @@
} }
</script> </script>
{{#if livereload}}
<!-- Livereload script (if served using the cli tool) --> <!-- Livereload script (if served using the cli tool) -->
{{{livereload}}} <script type="text/javascript">
var socket = new WebSocket("{{{livereload}}}");
socket.onmessage = function (event) {
if (event.data === "reload") {
socket.close();
location.reload(true); // force reload from server (not from cache)
}
};
window.onbeforeunload = function() {
socket.close();
}
</script>
{{/if}}
{{#if google_analytics}} {{#if google_analytics}}
<!-- Google Analytics Tag --> <!-- Google Analytics Tag -->

View File

@ -1,5 +1,6 @@
pub mod fs; pub mod fs;
mod string; mod string;
use errors::Error;
use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES, use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES,
OPTION_ENABLE_TABLES}; OPTION_ENABLE_TABLES};
@ -7,10 +8,7 @@ use std::borrow::Cow;
pub use self::string::{RangeArgument, take_lines}; pub use self::string::{RangeArgument, take_lines};
/// /// Wrapper around the pulldown-cmark parser for rendering markdown to HTML.
///
/// Wrapper around the pulldown-cmark parser and renderer to render markdown
pub fn render_markdown(text: &str, curly_quotes: bool) -> String { pub fn render_markdown(text: &str, curly_quotes: bool) -> String {
let mut s = String::with_capacity(text.len() * 3 / 2); let mut s = String::with_capacity(text.len() * 3 / 2);
@ -105,6 +103,15 @@ fn convert_quotes_to_curly(original_text: &str) -> String {
.collect() .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)] #[cfg(test)]
mod tests { mod tests {
mod render_markdown { mod render_markdown {

View File

@ -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)
}

View File

@ -62,7 +62,7 @@ fn run_mdbook_init_with_custom_book_and_src_locations() {
#[test] #[test]
fn book_toml_isnt_required() { fn book_toml_isnt_required() {
let temp = TempDir::new("mdbook").unwrap(); 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")); let _ = fs::remove_file(temp.path().join("book.toml"));

View File

@ -22,7 +22,6 @@ use mdbook::utils::fs::file_to_string;
use mdbook::config::Config; use mdbook::config::Config;
use mdbook::MDBook; use mdbook::MDBook;
const BOOK_ROOT: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/dummy_book"); const BOOK_ROOT: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/dummy_book");
const TOC_TOP_LEVEL: &[&'static str] = &[ const TOC_TOP_LEVEL: &[&'static str] = &[
"1. First Chapter", "1. First Chapter",
@ -36,7 +35,7 @@ const TOC_SECOND_LEVEL: &[&'static str] = &["1.1. Nested Chapter"];
#[test] #[test]
fn build_the_dummy_book() { fn build_the_dummy_book() {
let temp = DummyBook::new().build().unwrap(); let temp = DummyBook::new().build().unwrap();
let mut md = MDBook::load(temp.path()).unwrap(); let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap(); md.build().unwrap();
} }
@ -44,7 +43,7 @@ fn build_the_dummy_book() {
#[test] #[test]
fn by_default_mdbook_generates_rendered_content_in_the_book_directory() { fn by_default_mdbook_generates_rendered_content_in_the_book_directory() {
let temp = DummyBook::new().build().unwrap(); 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()); assert!(!temp.path().join("book").exists());
md.build().unwrap(); md.build().unwrap();
@ -56,7 +55,7 @@ fn by_default_mdbook_generates_rendered_content_in_the_book_directory() {
#[test] #[test]
fn make_sure_bottom_level_files_contain_links_to_chapters() { fn make_sure_bottom_level_files_contain_links_to_chapters() {
let temp = DummyBook::new().build().unwrap(); let temp = DummyBook::new().build().unwrap();
let mut md = MDBook::load(temp.path()).unwrap(); let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap(); md.build().unwrap();
let dest = temp.path().join("book"); let dest = temp.path().join("book");
@ -78,7 +77,7 @@ fn make_sure_bottom_level_files_contain_links_to_chapters() {
#[test] #[test]
fn check_correct_cross_links_in_nested_dir() { fn check_correct_cross_links_in_nested_dir() {
let temp = DummyBook::new().build().unwrap(); let temp = DummyBook::new().build().unwrap();
let mut md = MDBook::load(temp.path()).unwrap(); let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap(); md.build().unwrap();
let first = temp.path().join("book").join("first"); let first = temp.path().join("book").join("first");
@ -115,7 +114,7 @@ fn check_correct_cross_links_in_nested_dir() {
#[test] #[test]
fn rendered_code_has_playpen_stuff() { fn rendered_code_has_playpen_stuff() {
let temp = DummyBook::new().build().unwrap(); let temp = DummyBook::new().build().unwrap();
let mut md = MDBook::load(temp.path()).unwrap(); let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap(); md.build().unwrap();
let nested = temp.path().join("book/first/nested.html"); 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 temp = DummyBook::new().build().unwrap();
let mut md = MDBook::load(temp.path()).unwrap(); let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap(); md.build().unwrap();
let destination = temp.path().join("book"); 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 /// Apply a series of predicates to some root predicate, where each
/// successive predicate is the descendant of the last one. Similar to how you /// 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. /// 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 /// Make sure that all `*.md` files (excluding `SUMMARY.md`) were rendered
/// and placed in the `book` directory with their extensions set to `*.html`. /// and placed in the `book` directory with their extensions set to `*.html`.
#[test] #[test]
@ -286,7 +283,7 @@ fn create_missing_file_with_config() {
#[test] #[test]
fn able_to_include_rust_files_in_chapters() { fn able_to_include_rust_files_in_chapters() {
let temp = DummyBook::new().build().unwrap(); let temp = DummyBook::new().build().unwrap();
let mut md = MDBook::load(temp.path()).unwrap(); let md = MDBook::load(temp.path()).unwrap();
md.build().unwrap(); md.build().unwrap();
let second = temp.path().join("book/second.html"); 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() { fn example_book_can_build() {
let example_book_dir = dummy_book::new_copy_of_example_book().unwrap(); 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(); md.build().unwrap();
assert!(got.is_ok());
} }
#[test] #[test]