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 | |
14 | pub use self::html_handlebars::HtmlHandlebars; |
15 | pub use self::markdown_renderer::MarkdownRenderer; |
16 | |
17 | mod html_handlebars; |
18 | mod markdown_renderer; |
19 | |
20 | use shlex::Shlex; |
21 | use std::collections::HashMap; |
22 | use std::fs; |
23 | use std::io::{self, ErrorKind, Read}; |
24 | use std::path::{Path, PathBuf}; |
25 | use std::process::{Command, Stdio}; |
26 | |
27 | use crate::book::Book; |
28 | use crate::config::Config; |
29 | use crate::errors::*; |
30 | use log::{error, info, trace, warn}; |
31 | use toml::Value; |
32 | |
33 | use 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 |
44 | pub 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)] |
55 | pub 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 | |
76 | impl 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)] |
129 | pub struct CmdRenderer { |
130 | name: String, |
131 | cmd: String, |
132 | } |
133 | |
134 | impl 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 | |
187 | impl 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 | |
221 | impl 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 | } |
267 | |