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:
parent
dedc208a6a
commit
fd7e8d1b7b
|
@ -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 }
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
|
@ -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
|
|
@ -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.
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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) ");
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
190
src/book/mod.rs
190
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::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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
101
src/config.rs
101
src/config.rs
|
@ -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();
|
||||||
|
|
||||||
|
@ -94,7 +110,8 @@ impl Config {
|
||||||
// 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,
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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"));
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
|
Loading…
Reference in New Issue