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