mdbook/renderer/
mod.rs

1//! `mdbook`'s low level rendering interface.
2//!
3//! # Note
4//!
5//! You usually don't need to work with this module directly. If you want to
6//! implement your own backend, then check out the [For Developers] section of
7//! the user guide.
8//!
9//! The definition for [RenderContext] may be useful though.
10//!
11//! [For Developers]: https://rust-lang.github.io/mdBook/for_developers/index.html
12//! [RenderContext]: struct.RenderContext.html
13
14pub use self::html_handlebars::HtmlHandlebars;
15pub use self::markdown_renderer::MarkdownRenderer;
16
17mod html_handlebars;
18mod markdown_renderer;
19
20use shlex::Shlex;
21use std::collections::HashMap;
22use std::fs;
23use std::io::{self, ErrorKind, Read};
24use std::path::{Path, PathBuf};
25use std::process::{Command, Stdio};
26
27use crate::book::Book;
28use crate::config::Config;
29use crate::errors::*;
30use log::{error, info, trace, warn};
31use toml::Value;
32
33use serde::{Deserialize, Serialize};
34
35/// An arbitrary `mdbook` backend.
36///
37/// Although it's quite possible for you to import `mdbook` as a library and
38/// provide your own renderer, there are two main renderer implementations that
39/// 99% of users will ever use:
40///
41/// - [`HtmlHandlebars`] - the built-in HTML renderer
42/// - [`CmdRenderer`] - a generic renderer which shells out to a program to do the
43///   actual rendering
44pub trait Renderer {
45    /// The `Renderer`'s name.
46    fn name(&self) -> &str;
47
48    /// Invoke the `Renderer`, passing in all the necessary information for
49    /// describing a book.
50    fn render(&self, ctx: &RenderContext) -> Result<()>;
51}
52
53/// The context provided to all renderers.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct RenderContext {
56    /// Which version of `mdbook` did this come from (as written in `mdbook`'s
57    /// `Cargo.toml`). Useful if you know the renderer is only compatible with
58    /// certain versions of `mdbook`.
59    pub version: String,
60    /// The book's root directory.
61    pub root: PathBuf,
62    /// A loaded representation of the book itself.
63    pub book: Book,
64    /// The loaded configuration file.
65    pub config: Config,
66    /// Where the renderer *must* put any build artefacts generated. To allow
67    /// renderers to cache intermediate results, this directory is not
68    /// guaranteed to be empty or even exist.
69    pub destination: PathBuf,
70    #[serde(skip)]
71    pub(crate) chapter_titles: HashMap<PathBuf, String>,
72    #[serde(skip)]
73    __non_exhaustive: (),
74}
75
76impl RenderContext {
77    /// Create a new `RenderContext`.
78    pub fn new<P, Q>(root: P, book: Book, config: Config, destination: Q) -> RenderContext
79    where
80        P: Into<PathBuf>,
81        Q: Into<PathBuf>,
82    {
83        RenderContext {
84            book,
85            config,
86            version: crate::MDBOOK_VERSION.to_string(),
87            root: root.into(),
88            destination: destination.into(),
89            chapter_titles: HashMap::new(),
90            __non_exhaustive: (),
91        }
92    }
93
94    /// Get the source directory's (absolute) path on disk.
95    pub fn source_dir(&self) -> PathBuf {
96        self.root.join(&self.config.book.src)
97    }
98
99    /// Load a `RenderContext` from its JSON representation.
100    pub fn from_json<R: Read>(reader: R) -> Result<RenderContext> {
101        serde_json::from_reader(reader).with_context(|| "Unable to deserialize the `RenderContext`")
102    }
103}
104
105/// A generic renderer which will shell out to an arbitrary executable.
106///
107/// # Rendering Protocol
108///
109/// When the renderer's `render()` method is invoked, `CmdRenderer` will spawn
110/// the `cmd` as a subprocess. The `RenderContext` is passed to the subprocess
111/// as a JSON string (using `serde_json`).
112///
113/// > **Note:** The command used doesn't necessarily need to be a single
114/// > executable (i.e. `/path/to/renderer`). The `cmd` string lets you pass
115/// > in command line arguments, so there's no reason why it couldn't be
116/// > `python /path/to/renderer --from mdbook --to epub`.
117///
118/// Anything the subprocess writes to `stdin` or `stdout` will be passed through
119/// to the user. While this gives the renderer maximum flexibility to output
120/// whatever it wants, to avoid spamming users it is recommended to avoid
121/// unnecessary output.
122///
123/// To help choose the appropriate output level, the `RUST_LOG` environment
124/// variable will be passed through to the subprocess, if set.
125///
126/// If the subprocess wishes to indicate that rendering failed, it should exit
127/// with a non-zero return code.
128#[derive(Debug, Clone, PartialEq)]
129pub struct CmdRenderer {
130    name: String,
131    cmd: String,
132}
133
134impl CmdRenderer {
135    /// Create a new `CmdRenderer` which will invoke the provided `cmd` string.
136    pub fn new(name: String, cmd: String) -> CmdRenderer {
137        CmdRenderer { name, cmd }
138    }
139
140    fn compose_command(&self, root: &Path, destination: &Path) -> Result<Command> {
141        let mut words = Shlex::new(&self.cmd);
142        let exe = match words.next() {
143            Some(e) => PathBuf::from(e),
144            None => bail!("Command string was empty"),
145        };
146
147        let exe = if exe.components().count() == 1 {
148            // Search PATH for the executable.
149            exe
150        } else {
151            // Relative paths are preferred to be relative to the book root.
152            let abs_exe = root.join(&exe);
153            if abs_exe.exists() {
154                abs_exe
155            } else {
156                // Historically paths were relative to the destination, but
157                // this is not the preferred way.
158                let legacy_path = destination.join(&exe);
159                if legacy_path.exists() {
160                    warn!(
161                        "Renderer command `{}` uses a path relative to the \
162                        renderer output directory `{}`. This was previously \
163                        accepted, but has been deprecated. Relative executable \
164                        paths should be relative to the book root.",
165                        exe.display(),
166                        destination.display()
167                    );
168                    legacy_path
169                } else {
170                    // Let this bubble through to later be handled by
171                    // handle_render_command_error.
172                    abs_exe
173                }
174            }
175        };
176
177        let mut cmd = Command::new(exe);
178
179        for arg in words {
180            cmd.arg(arg);
181        }
182
183        Ok(cmd)
184    }
185}
186
187impl CmdRenderer {
188    fn handle_render_command_error(&self, ctx: &RenderContext, error: io::Error) -> Result<()> {
189        if let ErrorKind::NotFound = error.kind() {
190            // Look for "output.{self.name}.optional".
191            // If it exists and is true, treat this as a warning.
192            // Otherwise, fail the build.
193
194            let optional_key = format!("output.{}.optional", self.name);
195
196            let is_optional = match ctx.config.get(&optional_key) {
197                Some(Value::Boolean(value)) => *value,
198                _ => false,
199            };
200
201            if is_optional {
202                warn!(
203                    "The command `{}` for backend `{}` was not found, \
204                    but was marked as optional.",
205                    self.cmd, self.name
206                );
207                return Ok(());
208            } else {
209                error!(
210                    "The command `{0}` wasn't found, is the \"{1}\" backend installed? \
211                    If you want to ignore this error when the \"{1}\" backend is not installed, \
212                    set `optional = true` in the `[output.{1}]` section of the book.toml configuration file.",
213                    self.cmd, self.name
214                );
215            }
216        }
217        Err(error).with_context(|| "Unable to start the backend")?
218    }
219}
220
221impl Renderer for CmdRenderer {
222    fn name(&self) -> &str {
223        &self.name
224    }
225
226    fn render(&self, ctx: &RenderContext) -> Result<()> {
227        info!("Invoking the \"{}\" renderer", self.name);
228
229        let _ = fs::create_dir_all(&ctx.destination);
230
231        let mut child = match self
232            .compose_command(&ctx.root, &ctx.destination)?
233            .stdin(Stdio::piped())
234            .stdout(Stdio::inherit())
235            .stderr(Stdio::inherit())
236            .current_dir(&ctx.destination)
237            .spawn()
238        {
239            Ok(c) => c,
240            Err(e) => return self.handle_render_command_error(ctx, e),
241        };
242
243        let mut stdin = child.stdin.take().expect("Child has stdin");
244        if let Err(e) = serde_json::to_writer(&mut stdin, &ctx) {
245            // Looks like the backend hung up before we could finish
246            // sending it the render context. Log the error and keep going
247            warn!("Error writing the RenderContext to the backend, {}", e);
248        }
249
250        // explicitly close the `stdin` file handle
251        drop(stdin);
252
253        let status = child
254            .wait()
255            .with_context(|| "Error waiting for the backend to complete")?;
256
257        trace!("{} exited with output: {:?}", self.cmd, status);
258
259        if !status.success() {
260            error!("Renderer exited with non-zero return code.");
261            bail!("The \"{}\" renderer failed", self.name);
262        } else {
263            Ok(())
264        }
265    }
266}