mdbook_pandoc/
lib.rs

1use std::fs::{self, File};
2
3use anyhow::{anyhow, Context as _};
4use indexmap::IndexMap;
5use mdbook::config::HtmlConfig;
6use once_cell::sync::Lazy;
7use serde::{Deserialize, Serialize};
8
9mod book;
10use book::Book;
11
12mod css;
13mod html;
14mod latex;
15mod pandoc;
16
17mod preprocess;
18use preprocess::Preprocessor;
19
20#[derive(Clone, Debug, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22struct Config {
23    #[serde(rename = "profile", default = "Default::default")]
24    pub profiles: IndexMap<String, pandoc::Profile>,
25    #[serde(default = "defaults::enabled")]
26    pub keep_preprocessed: bool,
27    pub hosted_html: Option<String>,
28    /// Code block related configuration.
29    #[serde(default = "Default::default")]
30    pub code: CodeConfig,
31    /// Skip running the renderer.
32    #[serde(default = "Default::default")]
33    pub disabled: bool,
34    /// Markdown-related configuration.
35    #[serde(default = "Default::default")]
36    pub markdown: MarkdownConfig,
37}
38
39/// Configuration for customizing how Markdown is parsed.
40#[derive(Clone, Debug, Default, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42struct MarkdownConfig {
43    /// Enable additional Markdown extensions.
44    pub extensions: MarkdownExtensionConfig,
45}
46
47/// [`pulldown_cmark`] Markdown extensions not enabled by default by [`mdbook`].
48#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50struct MarkdownExtensionConfig {
51    /// Enable [`pulldown_cmark::Options::ENABLE_GFM`].
52    #[serde(default = "defaults::disabled")]
53    pub gfm: bool,
54    /// Enable [`pulldown_cmark::Options::ENABLE_MATH`].
55    #[serde(default = "defaults::disabled")]
56    pub math: bool,
57    /// Enable [`pulldown_cmark::Options::ENABLE_DEFINITION_LIST`].
58    #[serde(default = "defaults::disabled")]
59    pub definition_lists: bool,
60    /// Enable [`pulldown_cmark::Options::ENABLE_SUPERSCRIPT`].
61    #[serde(default = "defaults::disabled")]
62    pub superscript: bool,
63    /// Enable [`pulldown_cmark::Options::ENABLE_SUBSCRIPT`].
64    #[serde(default = "defaults::disabled")]
65    pub subscript: bool,
66}
67
68/// Configuration for tweaking how code blocks are rendered.
69#[derive(Clone, Debug, Default, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71struct CodeConfig {
72    pub show_hidden_lines: bool,
73}
74
75mod defaults {
76    pub fn enabled() -> bool {
77        true
78    }
79
80    pub fn disabled() -> bool {
81        false
82    }
83}
84
85/// A [`mdbook`] backend supporting many output formats by relying on [`pandoc`](https://pandoc.org).
86#[derive(Default)]
87pub struct Renderer {
88    logfile: Option<File>,
89}
90
91impl Renderer {
92    pub fn new() -> Self {
93        Self { logfile: None }
94    }
95
96    const NAME: &'static str = "pandoc";
97    const CONFIG_KEY: &'static str = "output.pandoc";
98}
99
100impl mdbook::Renderer for Renderer {
101    fn name(&self) -> &str {
102        Self::NAME
103    }
104
105    fn render(&self, ctx: &mdbook::renderer::RenderContext) -> anyhow::Result<()> {
106        // If we're compiled against mdbook version I.J.K, require ^I.J
107        // This allows using a version of mdbook with an earlier patch version as a server
108        static MDBOOK_VERSION_REQ: Lazy<semver::VersionReq> = Lazy::new(|| {
109            let compiled_mdbook_version = semver::Version::parse(mdbook::MDBOOK_VERSION).unwrap();
110            semver::VersionReq {
111                comparators: vec![semver::Comparator {
112                    op: semver::Op::Caret,
113                    major: compiled_mdbook_version.major,
114                    minor: Some(compiled_mdbook_version.minor),
115                    patch: None,
116                    pre: Default::default(),
117                }],
118            }
119        });
120        let mdbook_server_version = semver::Version::parse(&ctx.version).unwrap();
121        if !MDBOOK_VERSION_REQ.matches(&mdbook_server_version) {
122            log::warn!(
123                "{} is semver-incompatible with mdbook {} (requires {})",
124                env!("CARGO_PKG_NAME"),
125                mdbook_server_version,
126                *MDBOOK_VERSION_REQ,
127            );
128        }
129
130        let cfg: Config = ctx
131            .config
132            .get_deserialized_opt(Self::CONFIG_KEY)
133            .with_context(|| format!("Unable to deserialize {}", Self::CONFIG_KEY))?
134            .ok_or(anyhow!("No {} table found", Self::CONFIG_KEY))?;
135
136        if cfg.disabled {
137            log::info!("Skipping rendering since `disabled` is set");
138            return Ok(());
139        }
140
141        pandoc::check_compatibility()?;
142
143        let html_cfg: Option<HtmlConfig> = ctx
144            .config
145            .get_deserialized_opt("output.html")
146            .unwrap_or_default();
147
148        let book = Book::new(ctx)?;
149
150        let stylesheets;
151        let mut css = css::Css::default();
152        if let Some(cfg) = &html_cfg {
153            stylesheets = css::read_stylesheets(cfg, &book).collect::<Vec<_>>();
154            for (stylesheet, stylesheet_css) in &stylesheets {
155                css.load(stylesheet, stylesheet_css);
156            }
157        }
158
159        for (name, profile) in cfg.profiles {
160            let ctx = pandoc::RenderContext {
161                book: &book,
162                mdbook_cfg: &ctx.config,
163                destination: book.destination.join(name),
164                output: profile.output_format(),
165                columns: profile.columns,
166                cur_list_depth: 0,
167                max_list_depth: 0,
168                code: &cfg.code,
169                html: html_cfg.as_ref(),
170                css: &css,
171            };
172
173            // Preprocess book
174            let mut preprocessor = Preprocessor::new(ctx, &cfg.markdown)?;
175
176            if let Some(uri) = cfg.hosted_html.as_deref() {
177                preprocessor.hosted_html(uri);
178            }
179
180            if let Some(redirects) = html_cfg.as_ref().map(|cfg| &cfg.redirect) {
181                if !redirects.is_empty() {
182                    log::debug!("Processing redirects in [output.html.redirect]");
183                    let redirects = redirects
184                        .iter()
185                        .map(|(src, dst)| (src.as_str(), dst.as_str()));
186                    // In tests, sort redirect map to ensure stable log output
187                    #[cfg(test)]
188                    let redirects = redirects
189                        .collect::<std::collections::BTreeMap<_, _>>()
190                        .into_iter();
191                    preprocessor.add_redirects(redirects);
192                }
193            }
194
195            let mut preprocessed = preprocessor.preprocess();
196
197            // Initialize renderer
198            let mut renderer = pandoc::Renderer::new();
199
200            // Add preprocessed book chapters to renderer
201            renderer.current_dir(&book.root);
202            for input in &mut preprocessed {
203                renderer.input(input?);
204            }
205
206            if preprocessed.unresolved_links() {
207                log::warn!(
208                    "Unable to resolve one or more relative links within the book, \
209                    consider setting the `hosted-html` option in `[output.pandoc]`"
210                );
211            }
212
213            if let Some(logfile) = &self.logfile {
214                renderer.stderr(logfile.try_clone()?);
215            }
216
217            // Render final output
218            renderer.render(profile, preprocessed.render_context())?;
219
220            if !cfg.keep_preprocessed {
221                fs::remove_dir_all(preprocessed.output_dir())?;
222            }
223        }
224
225        Ok(())
226    }
227}
228
229#[cfg(test)]
230mod tests;