diff --git a/Cargo.lock b/Cargo.lock index 0ae397e316..1143a86a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,11 @@ dependencies = [ "unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "handlebars" version = "1.1.0" @@ -637,6 +642,7 @@ dependencies = [ "elasticlunr-rs 2.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "env_logger 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)", "error-chain 0.12.1 (registry+https://github.com/rust-lang/crates.io-index)", + "glob 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "handlebars 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "iron 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "itertools 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1695,6 +1701,7 @@ dependencies = [ "checksum futf 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "7c9c1ce3fa9336301af935ab852c437817d14cd33690446569392e65170aac3b" "checksum generic-array 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3c0f28c2f5bfb5960175af447a2da7c18900693738343dc896ffbcabd9839592" "checksum getopts 0.2.19 (registry+https://github.com/rust-lang/crates.io-index)" = "72327b15c228bfe31f1390f93dd5e9279587f0463836393c9df719ce62a3e450" +"checksum glob 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" "checksum handlebars 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d82e5750d8027a97b9640e3fefa66bbaf852a35228e1c90790efd13c4b09c166" "checksum heck 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" "checksum html5ever 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a49d5001dd1bddf042ea41ed4e0a671d50b1bf187e66b349d7ec613bdce4ad90" diff --git a/Cargo.toml b/Cargo.toml index 94488d40c7..8e4726a92b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ shlex = "0.1" tempfile = "3.0" toml = "0.5.1" toml-query = "0.9" +glob = "0.3.0" # Watch feature notify = { version = "4.0", optional = true } diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index 3f40dceae9..4d2fe6fff8 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -161,6 +161,10 @@ The following configuration options are available: - **additional-js:** If you need to add some behaviour to your book without removing the current behaviour, you can specify a set of JavaScript files that will be loaded alongside the default one. +- **additional-resources:** If you need to include some additional resources, + like for instance image files, that are located outside of the book + directory, you can specify one or more additional-resources sections to + include these. - **no-section-label:** mdBook by defaults adds section label in table of contents column. For example, "1.", "2.1". Set this option to true to disable those labels. Defaults to `false`. @@ -172,6 +176,16 @@ The following configuration options are available: - **git-repository-icon:** The FontAwesome icon class to use for the git repository link. Defaults to `fa-github`. +Available configuration options for the `[[output.html.additional-resources]]` +table array: + +- **output-dir:** The directory to copy the files to (relative to book output + directory). The directory is created recursively. +- **src:** The glob pattern to use when locating the additional resource files. + +Note that the found files are flattened in the output dircetory, meaning the +original directory structure is lost. + Available configuration options for the `[output.html.playpen]` table: - **editable:** Allow editing the source code. Defaults to `false`. @@ -238,6 +252,14 @@ boost-paragraph = 1 expand = true heading-split-level = 3 copy-js = true + +[[output.html.additional-resources]] +output-dir="img" +src="../img/*.png" + +[[output.html.additional-resources]] +output-dir="img/project" +src="../project/uml/*.png" ``` ### Custom Renderers diff --git a/src/cmd/watch.rs b/src/cmd/watch.rs index bf903f90a7..3accad93ba 100644 --- a/src/cmd/watch.rs +++ b/src/cmd/watch.rs @@ -1,9 +1,11 @@ +extern crate notify; +use self::notify::Watcher; use crate::{get_book_dir, open}; use clap::{App, ArgMatches, SubCommand}; +use glob::glob; use mdbook::errors::Result; use mdbook::utils; use mdbook::MDBook; -use notify::Watcher; use std::path::{Path, PathBuf}; use std::sync::mpsc::channel; use std::thread::sleep; @@ -48,6 +50,24 @@ pub fn execute(args: &ArgMatches) -> Result<()> { Ok(()) } +fn watch_additional_resources(book: &MDBook, watcher: &mut impl notify::Watcher) { + use notify::RecursiveMode::NonRecursive; + + book.config + .html_config() + .and_then(|html_config| html_config.additional_resources) + .map(|additional_resources| { + for res in additional_resources { + let found_files = glob(res.src.as_str()) + .expect("Failed to read glob pattern for additional resource"); + for path in found_files.filter_map(std::result::Result::ok) { + let _ = watcher.watch(&path, NonRecursive); + debug!("Watching {:?}", path); + } + } + }); +} + /// Calls the closure when a book source file is changed, blocking indefinitely. pub fn trigger_on_change(book: &MDBook, closure: F) where @@ -78,6 +98,8 @@ where // Add the book.toml file to the watcher if it exists let _ = watcher.watch(book.root.join("book.toml"), NonRecursive); + watch_additional_resources(book, &mut watcher); + info!("Listening for changes..."); loop { diff --git a/src/config.rs b/src/config.rs index 59ae8ad049..9c88f91051 100644 --- a/src/config.rs +++ b/src/config.rs @@ -390,6 +390,39 @@ impl Default for BookConfig { } } +/// Additional files not normaly copied (like external images etc), which +/// you want to add to the book output. +/// In book.toml: +/// ```toml +/// [[output.html.additional-resources]] +/// output-dir="img" +/// src="../plantuml/*.png" +/// +/// [[output.html.additional-resources]] +/// output-dir="foo" +/// src="bar/*.xml" +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct AdditionalResource { + /// Glob string for the files to add (e.g. "../foo/*.png"), path is + /// relative to book root dir. The files found by the pattern + /// are flattened in the output dir (i.e. the directory structure + /// is not copied, only the files) + pub src: String, + /// Path relative to the book output dir + pub output_dir: PathBuf, +} + +impl Default for AdditionalResource { + fn default() -> AdditionalResource { + AdditionalResource { + src: String::from(""), + output_dir: PathBuf::from("src"), + } + } +} + /// Configuration for the build procedure. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case")] @@ -452,6 +485,9 @@ pub struct HtmlConfig { /// FontAwesome icon class to use for the Git repository link. /// Defaults to `fa-github` if `None`. pub git_repository_icon: Option, + + /// The additional resources to copy + pub additional_resources: Option>, } impl HtmlConfig { @@ -587,6 +623,14 @@ mod tests { git-repository-url = "https://foo.com/" git-repository-icon = "fa-code-fork" + [[output.html.additional-resources]] + src="foo*.*" + output-dir="bar" + + [[output.html.additional-resources]] + src="chuck*.*" + output-dir="norris" + [output.html.playpen] editable = true editor = "ace" @@ -625,6 +669,16 @@ mod tests { playpen: playpen_should_be, git_repository_url: Some(String::from("https://foo.com/")), git_repository_icon: Some(String::from("fa-code-fork")), + additional_resources: Some(vec![ + AdditionalResource { + src: String::from("foo*.*"), + output_dir: PathBuf::from("bar"), + }, + AdditionalResource { + src: String::from("chuck*.*"), + output_dir: PathBuf::from("norris"), + }, + ]), ..Default::default() }; diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 8f3c332077..72f0b73f49 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -1,5 +1,5 @@ use crate::book::{Book, BookItem}; -use crate::config::{Config, HtmlConfig, Playpen}; +use crate::config::{AdditionalResource, Config, HtmlConfig, Playpen}; use crate::errors::*; use crate::renderer::html_handlebars::helpers; use crate::renderer::{RenderContext, Renderer}; @@ -11,6 +11,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +use glob::glob; use handlebars::Handlebars; use regex::{Captures, Regex}; @@ -366,11 +367,66 @@ impl Renderer for HtmlHandlebars { // Copy all remaining files utils::fs::copy_files_except_ext(&src_dir, &destination, true, &["md"])?; + copy_additional_resources(&html_config.additional_resources, &destination)?; Ok(()) } } +/// Copy the additional resources specified in the config +fn copy_additional_resources( + resources: &Option>, + destination: &PathBuf, +) -> Result<()> { + match resources { + Some(additional_resources) => { + for res in additional_resources { + copy_additional_resource(&res, &destination)?; + } + } + None => (), //Optional, so no issue here + } + + Ok(()) +} + +/// Copy the files from a single AdditionalResource entry in config to the book dir +/// The found files are copied by name only, the original directory structure is +/// flattened +fn copy_additional_resource(res: &AdditionalResource, destination: &PathBuf) -> Result<()> { + let dest_dir = destination.join(&res.output_dir); + + match fs::create_dir_all(&dest_dir) { + Ok(_) => debug!("Created: {}", dest_dir.display()), + Err(e) => { + error!( + "Failed to create output directory {} for additional resources.", + dest_dir.display() + ); + return Err(e.into()); + } + } + + let found_files = + glob(res.src.as_str()).expect("Failed to read glob pattern for additional resource"); + for path in found_files.filter_map(std::result::Result::ok) { + let file_name = path.file_name().unwrap(); + let dest_file = dest_dir.join(file_name); + + match fs::copy(&path, &dest_file) { + Ok(_) => debug!("Copied {} to {}", path.display(), dest_file.display()), + Err(e) => warn!( + "Failed to copy {} to {} ({})", + path.display(), + dest_file.display(), + e + ), + } + } + + Ok(()) +} + fn make_data( root: &Path, book: &Book, @@ -669,4 +725,68 @@ mod tests { assert_eq!(got, should_be); } } + #[test] + fn test_copy_additional_resource_fails_to_create_output_dir() { + use tempfile::NamedTempFile; + + // Create a file with the same name as the directory we're + // about to create. This will prevent the system from creating + // the directory + let mask_file = NamedTempFile::new().expect("Failed to create dir mask file"); + + let destination = PathBuf::from(&mask_file.path()); + let resource = AdditionalResource { + src: String::from("*.foo"), + output_dir: PathBuf::from(&destination), + }; + + match copy_additional_resource(&resource, &destination) { + Ok(_) => assert!(false, "Expected a failure when creating the output dir"), + //Error is OS dependant, so not much use in checking the error text + Err(_e) => (), + } + } + + #[test] + fn test_copy_additional_resource() { + use tempfile::tempdir; + + let src_dir = tempdir().unwrap(); + let output_dir = tempdir().unwrap(); + + let destination = PathBuf::from(&output_dir.path()); + let resource = AdditionalResource { + src: String::from(src_dir.path().join("*.txt").to_str().unwrap()), + output_dir: PathBuf::from(&destination), + }; + + //Create some files + fs::File::create(src_dir.path().join("i_will_not_be_copied.doc")).unwrap(); + fs::File::create(src_dir.path().join("i_will_be_copied.txt")).unwrap(); + fs::File::create(src_dir.path().join("i_will_be_copied_too.txt")).unwrap(); + + match copy_additional_resource(&resource, &destination) { + Ok(_) => { + //Just test the most likely candidates have been copied + let test_file = destination.join("i_will_be_copied.txt"); + assert!( + test_file.is_file(), + "Expected 'i_will_be_copied.txt' to be copied" + ); + + let test_file = destination.join("i_will_be_copied_too.txt"); + assert!( + test_file.is_file(), + "Expected 'i_will_be_copied_too.txt' to be copied" + ); + + let test_file = destination.join("i_will_not_be_copied.doc"); + assert!( + !test_file.is_file(), + "Did not expect 'i_will_not_be_copied.doc' to be copied" + ); + } + Err(e) => assert!(false, "Failed to copy additional resources ({})", e), + } + } }