mdbook_pandoc/
lib.rs

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