mdbook_html/html_handlebars/
hbs_renderer.rs

1use super::helpers;
2use super::static_files::StaticFiles;
3use crate::html::ChapterTree;
4use crate::html::{build_trees, render_markdown, serialize};
5use crate::theme::Theme;
6use crate::utils::ToUrlPath;
7use anyhow::{Context, Result, bail};
8use handlebars::Handlebars;
9use mdbook_core::book::{Book, BookItem, Chapter};
10use mdbook_core::config::{BookConfig, Config, HtmlConfig};
11use mdbook_core::utils::fs;
12use mdbook_renderer::{RenderContext, Renderer};
13use serde_json::json;
14use std::collections::{BTreeMap, HashMap};
15use std::path::{Path, PathBuf};
16use tracing::error;
17use tracing::{debug, info, trace, warn};
18
19/// The HTML renderer for mdBook.
20#[derive(Default)]
21#[non_exhaustive]
22pub struct HtmlHandlebars;
23
24impl HtmlHandlebars {
25    /// Returns a new instance of [`HtmlHandlebars`].
26    pub fn new() -> Self {
27        HtmlHandlebars
28    }
29
30    fn render_chapter(
31        &self,
32        chapter_tree: &ChapterTree<'_>,
33        prev_ch: Option<&Chapter>,
34        next_ch: Option<&Chapter>,
35        mut ctx: RenderChapterContext<'_>,
36    ) -> Result<()> {
37        // FIXME: This should be made DRY-er and rely less on mutable state
38        let ch = chapter_tree.chapter;
39
40        let path = ch.path.as_ref().unwrap();
41        // "print.html" is used for the print page.
42        if path == Path::new("print.md") {
43            bail!("{} is reserved for internal use", path.display());
44        };
45
46        if let Some(ref edit_url_template) = ctx.html_config.edit_url_template {
47            let full_path = ctx.book_config.src.to_str().unwrap_or_default().to_owned()
48                + "/"
49                + ch.source_path
50                    .clone()
51                    .unwrap_or_default()
52                    .to_str()
53                    .unwrap_or_default();
54
55            let edit_url = edit_url_template.replace("{path}", &full_path);
56            ctx.data
57                .insert("git_repository_edit_url".to_owned(), json!(edit_url));
58        }
59
60        let mut content = String::new();
61        serialize(&chapter_tree.tree, &mut content);
62
63        let ctx_path = path
64            .to_str()
65            .with_context(|| "Could not convert path to str")?;
66        let filepath = Path::new(&ctx_path).with_extension("html");
67
68        let book_title = ctx
69            .data
70            .get("book_title")
71            .and_then(serde_json::Value::as_str)
72            .unwrap_or("");
73
74        let title = if let Some(title) = ctx.chapter_titles.get(path) {
75            title.clone()
76        } else if book_title.is_empty() {
77            ch.name.clone()
78        } else {
79            ch.name.clone() + " - " + book_title
80        };
81
82        ctx.data.insert("path".to_owned(), json!(path));
83        ctx.data.insert("content".to_owned(), json!(content));
84        ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
85        ctx.data.insert("title".to_owned(), json!(title));
86        ctx.data
87            .insert("path_to_root".to_owned(), json!(fs::path_to_root(path)));
88        if let Some(ref section) = ch.number {
89            ctx.data
90                .insert("section".to_owned(), json!(section.to_string()));
91        }
92
93        let redirects = collect_redirects_for_path(&filepath, &ctx.html_config.redirect)?;
94        if !redirects.is_empty() {
95            ctx.data.insert(
96                "fragment_map".to_owned(),
97                json!(serde_json::to_string(&redirects)?),
98            );
99        }
100
101        let mut nav = |name: &str, ch: Option<&Chapter>| {
102            let Some(ch) = ch else { return };
103            let path = ch
104                .path
105                .as_ref()
106                .unwrap()
107                .with_extension("html")
108                .to_url_path();
109            let obj = json!( {
110                "title": ch.name,
111                "link": path,
112            });
113            ctx.data.insert(name.to_string(), obj);
114        };
115        nav("previous", prev_ch);
116        nav("next", next_ch);
117
118        // Render the handlebars template with the data
119        debug!("Render template");
120        let rendered = ctx.handlebars.render("index", &ctx.data)?;
121
122        // Write to file
123        let out_path = ctx.destination.join(filepath);
124        fs::write(&out_path, rendered)?;
125
126        if prev_ch.is_none() {
127            ctx.data.insert("path".to_owned(), json!("index.md"));
128            ctx.data.insert("path_to_root".to_owned(), json!(""));
129            ctx.data.insert("is_index".to_owned(), json!(true));
130            let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
131            debug!("Creating index.html from {}", ctx_path);
132            fs::write(ctx.destination.join("index.html"), rendered_index)?;
133        }
134
135        Ok(())
136    }
137
138    fn render_404(
139        &self,
140        ctx: &RenderContext,
141        html_config: &HtmlConfig,
142        src_dir: &Path,
143        handlebars: &mut Handlebars<'_>,
144        data: &mut serde_json::Map<String, serde_json::Value>,
145    ) -> Result<()> {
146        let content_404 = if let Some(ref filename) = html_config.input_404 {
147            let path = src_dir.join(filename);
148            fs::read_to_string(&path).with_context(|| "failed to read the 404 input file")?
149        } else {
150            // 404 input not explicitly configured try the default file 404.md
151            let default_404_location = src_dir.join("404.md");
152            if default_404_location.exists() {
153                fs::read_to_string(&default_404_location)
154                    .with_context(|| "failed to read the 404 input file")?
155            } else {
156                "# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \
157                navigation bar or search to continue."
158                    .to_string()
159            }
160        };
161        let options = crate::html::HtmlRenderOptions::new(
162            Path::new("404.md"),
163            html_config,
164            ctx.config.rust.edition,
165        );
166        let html_content_404 = render_markdown(&content_404, &options);
167
168        let mut data_404 = data.clone();
169        let base_url = if let Some(site_url) = &html_config.site_url {
170            site_url
171        } else {
172            debug!(
173                "HTML 'site-url' parameter not set, defaulting to '/'. Please configure \
174                this to ensure the 404 page work correctly, especially if your site is hosted in a \
175                subdirectory on the HTTP server."
176            );
177            "/"
178        };
179        data_404.insert("base_url".to_owned(), json!(base_url));
180        // Set a dummy path to ensure other paths (e.g. in the TOC) are generated correctly
181        data_404.insert("path".to_owned(), json!("404.md"));
182        data_404.insert("content".to_owned(), json!(html_content_404));
183
184        let mut title = String::from("Page not found");
185        if let Some(book_title) = &ctx.config.book.title {
186            title.push_str(" - ");
187            title.push_str(book_title);
188        }
189        data_404.insert("title".to_owned(), json!(title));
190        let rendered = handlebars.render("index", &data_404)?;
191
192        let output_file = ctx.destination.join(html_config.get_404_output_file());
193        fs::write(output_file, rendered)?;
194        debug!("Creating 404.html ✓");
195        Ok(())
196    }
197
198    fn render_print_page(
199        &self,
200        ctx: &RenderContext,
201        handlebars: &Handlebars<'_>,
202        data: &mut serde_json::Map<String, serde_json::Value>,
203        chapter_trees: Vec<ChapterTree<'_>>,
204    ) -> Result<String> {
205        let print_content = crate::html::render_print_page(chapter_trees);
206
207        if let Some(ref title) = ctx.config.book.title {
208            data.insert("title".to_owned(), json!(title));
209        } else {
210            // Make sure that the Print chapter does not display the title from
211            // the last rendered chapter by removing it from its context
212            data.remove("title");
213        }
214        data.insert("is_print".to_owned(), json!(true));
215        data.insert("path".to_owned(), json!("print.md"));
216        data.insert("content".to_owned(), json!(print_content));
217        data.insert(
218            "path_to_root".to_owned(),
219            json!(fs::path_to_root(Path::new("print.md"))),
220        );
221
222        debug!("Render template");
223        let rendered = handlebars.render("index", &data)?;
224        Ok(rendered)
225    }
226
227    fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) {
228        handlebars.register_helper(
229            "toc",
230            Box::new(helpers::toc::RenderToc {
231                no_section_label: html_config.no_section_label,
232            }),
233        );
234        handlebars.register_helper("fa", Box::new(helpers::fontawesome::fa_helper));
235    }
236
237    fn emit_redirects(
238        &self,
239        root: &Path,
240        handlebars: &Handlebars<'_>,
241        redirects: &HashMap<String, String>,
242    ) -> Result<()> {
243        if redirects.is_empty() {
244            return Ok(());
245        }
246
247        debug!("Emitting redirects");
248        let redirects = combine_fragment_redirects(redirects);
249
250        for (original, (dest, fragment_map)) in redirects {
251            // Note: all paths are relative to the build directory, so the
252            // leading slash in an absolute path means nothing (and would mess
253            // up `root.join(original)`).
254            let original = original.trim_start_matches('/');
255            let filename = root.join(original);
256            if filename.exists() {
257                // This redirect is handled by the in-page fragment mapper.
258                continue;
259            }
260            if dest.is_empty() {
261                bail!(
262                    "redirect entry for `{original}` only has source paths with `#` fragments\n\
263                     There must be an entry without the `#` fragment to determine the default \
264                     destination."
265                );
266            }
267            debug!("Redirecting \"{}\" → \"{}\"", original, dest);
268            self.emit_redirect(handlebars, &filename, &dest, &fragment_map)?;
269        }
270
271        Ok(())
272    }
273
274    fn emit_redirect(
275        &self,
276        handlebars: &Handlebars<'_>,
277        original: &Path,
278        destination: &str,
279        fragment_map: &BTreeMap<String, String>,
280    ) -> Result<()> {
281        if let Some(parent) = original.parent() {
282            fs::create_dir_all(parent)?
283        }
284
285        let js_map = serde_json::to_string(fragment_map)?;
286
287        let ctx = json!({
288            "fragment_map": js_map,
289            "url": destination,
290        });
291        let rendered = handlebars.render("redirect", &ctx).with_context(|| {
292            format!(
293                "Unable to create a redirect file at `{}`",
294                original.display()
295            )
296        })?;
297        fs::write(original, rendered)?;
298
299        Ok(())
300    }
301}
302
303impl Renderer for HtmlHandlebars {
304    fn name(&self) -> &str {
305        "html"
306    }
307
308    fn render(&self, ctx: &RenderContext) -> Result<()> {
309        let book_config = &ctx.config.book;
310        let html_config = ctx.config.html_config().unwrap_or_default();
311        let src_dir = ctx.root.join(&ctx.config.book.src);
312        let destination = &ctx.destination;
313        let book = &ctx.book;
314        let build_dir = ctx.root.join(&ctx.config.build.build_dir);
315
316        if destination.exists() {
317            fs::remove_dir_content(destination)
318                .with_context(|| "Unable to remove stale HTML output")?;
319        }
320
321        trace!("render");
322        let mut handlebars = Handlebars::new();
323
324        let theme_dir = match html_config.theme {
325            Some(ref theme) => {
326                let dir = ctx.root.join(theme);
327                if !dir.is_dir() {
328                    bail!("theme dir {} does not exist", dir.display());
329                }
330                dir
331            }
332            None => ctx.root.join("theme"),
333        };
334
335        let theme = Theme::new(theme_dir);
336
337        debug!("Register the index handlebars template");
338        handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?;
339
340        debug!("Register the head handlebars template");
341        handlebars.register_partial("head", String::from_utf8(theme.head.clone())?)?;
342
343        debug!("Register the redirect handlebars template");
344        handlebars
345            .register_template_string("redirect", String::from_utf8(theme.redirect.clone())?)?;
346
347        debug!("Register the header handlebars template");
348        handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?;
349
350        debug!("Register the toc handlebars template");
351        handlebars.register_template_string("toc_js", String::from_utf8(theme.toc_js.clone())?)?;
352        handlebars
353            .register_template_string("toc_html", String::from_utf8(theme.toc_html.clone())?)?;
354
355        debug!("Register handlebars helpers");
356        self.register_hbs_helpers(&mut handlebars, &html_config);
357
358        let mut data = make_data(&ctx.root, book, &ctx.config, &html_config, &theme)?;
359
360        let chapter_trees = build_trees(book, &html_config, ctx.config.rust.edition);
361
362        fs::create_dir_all(destination)
363            .with_context(|| "Unexpected error when constructing destination path")?;
364
365        let mut static_files = StaticFiles::new(&theme, &html_config, &ctx.root)?;
366
367        // Render search index
368        #[cfg(feature = "search")]
369        {
370            let default = mdbook_core::config::Search::default();
371            let search = html_config.search.as_ref().unwrap_or(&default);
372            if search.enable {
373                super::search::create_files(&search, &mut static_files, &chapter_trees)?;
374            }
375        }
376
377        debug!("Render toc js");
378        {
379            let rendered_toc = handlebars.render("toc_js", &data)?;
380            static_files.add_builtin("toc.js", rendered_toc.as_bytes());
381            debug!("Creating toc.js ✓");
382        }
383
384        if html_config.hash_files {
385            static_files.hash_files()?;
386        }
387
388        debug!("Copy static files");
389        let resource_helper = static_files
390            .write_files(&destination)
391            .with_context(|| "Unable to copy across static files")?;
392
393        handlebars.register_helper("resource", Box::new(resource_helper));
394
395        debug!("Render toc html");
396        {
397            data.insert("is_toc_html".to_owned(), json!(true));
398            data.insert("path".to_owned(), json!("toc.html"));
399            let rendered_toc = handlebars.render("toc_html", &data)?;
400            fs::write(destination.join("toc.html"), rendered_toc)?;
401            debug!("Creating toc.html ✓");
402            data.remove("path");
403            data.remove("is_toc_html");
404        }
405
406        fs::write(
407            destination.join(".nojekyll"),
408            b"This file makes sure that Github Pages doesn't process mdBook's output.\n",
409        )?;
410
411        if let Some(cname) = &html_config.cname {
412            fs::write(destination.join("CNAME"), format!("{cname}\n"))?;
413        }
414
415        for (i, chapter_tree) in chapter_trees.iter().enumerate() {
416            let previous = (i != 0).then(|| chapter_trees[i - 1].chapter);
417            let next = (i != chapter_trees.len() - 1).then(|| chapter_trees[i + 1].chapter);
418            let ctx = RenderChapterContext {
419                handlebars: &handlebars,
420                destination: destination.to_path_buf(),
421                data: data.clone(),
422                book_config: book_config.clone(),
423                html_config: html_config.clone(),
424                chapter_titles: &ctx.chapter_titles,
425            };
426            self.render_chapter(chapter_tree, previous, next, ctx)?;
427        }
428
429        // Render 404 page
430        if html_config.input_404 != Some("".to_string()) {
431            self.render_404(ctx, &html_config, &src_dir, &mut handlebars, &mut data)?;
432        }
433
434        // Render the print version.
435        if html_config.print.enable {
436            let print_rendered =
437                self.render_print_page(ctx, &handlebars, &mut data, chapter_trees)?;
438
439            fs::write(destination.join("print.html"), print_rendered)?;
440            debug!("Creating print.html ✓");
441        }
442
443        self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect)
444            .context("Unable to emit redirects")?;
445
446        // Copy all remaining files, avoid a recursive copy from/to the book build dir
447        fs::copy_files_except_ext(&src_dir, destination, true, Some(&build_dir), &["md"])?;
448
449        info!("HTML book written to `{}`", destination.display());
450
451        Ok(())
452    }
453}
454
455fn make_data(
456    root: &Path,
457    book: &Book,
458    config: &Config,
459    html_config: &HtmlConfig,
460    theme: &Theme,
461) -> Result<serde_json::Map<String, serde_json::Value>> {
462    trace!("make_data");
463
464    let mut data = serde_json::Map::new();
465    data.insert(
466        "language".to_owned(),
467        json!(config.book.language.clone().unwrap_or_default()),
468    );
469    data.insert(
470        "text_direction".to_owned(),
471        json!(config.book.realized_text_direction()),
472    );
473    data.insert(
474        "book_title".to_owned(),
475        json!(config.book.title.clone().unwrap_or_default()),
476    );
477    data.insert(
478        "description".to_owned(),
479        json!(config.book.description.clone().unwrap_or_default()),
480    );
481    if theme.favicon_png.is_some() {
482        data.insert("favicon_png".to_owned(), json!("favicon.png"));
483    }
484    if theme.favicon_svg.is_some() {
485        data.insert("favicon_svg".to_owned(), json!("favicon.svg"));
486    }
487    if let Some(ref live_reload_endpoint) = html_config.live_reload_endpoint {
488        data.insert(
489            "live_reload_endpoint".to_owned(),
490            json!(live_reload_endpoint),
491        );
492    }
493
494    let default_theme = match html_config.default_theme {
495        Some(ref theme) => theme.to_lowercase(),
496        None => "light".to_string(),
497    };
498    data.insert("default_theme".to_owned(), json!(default_theme));
499
500    let preferred_dark_theme = match html_config.preferred_dark_theme {
501        Some(ref theme) => theme.to_lowercase(),
502        None => "navy".to_string(),
503    };
504    data.insert(
505        "preferred_dark_theme".to_owned(),
506        json!(preferred_dark_theme),
507    );
508
509    if html_config.mathjax_support {
510        data.insert("mathjax_support".to_owned(), json!(true));
511    }
512
513    // Add check to see if there is an additional style
514    if !html_config.additional_css.is_empty() {
515        let mut css = Vec::new();
516        for style in &html_config.additional_css {
517            match style.strip_prefix(root) {
518                Ok(p) => css.push(p.to_str().expect("Could not convert to str")),
519                Err(_) => css.push(style.to_str().expect("Could not convert to str")),
520            }
521        }
522        data.insert("additional_css".to_owned(), json!(css));
523    }
524
525    // Add check to see if there is an additional script
526    if !html_config.additional_js.is_empty() {
527        let mut js = Vec::new();
528        for script in &html_config.additional_js {
529            match script.strip_prefix(root) {
530                Ok(p) => js.push(p.to_str().expect("Could not convert to str")),
531                Err(_) => js.push(script.to_str().expect("Could not convert to str")),
532            }
533        }
534        data.insert("additional_js".to_owned(), json!(js));
535    }
536
537    if html_config.playground.editable && html_config.playground.copy_js {
538        data.insert("playground_js".to_owned(), json!(true));
539        if html_config.playground.line_numbers {
540            data.insert("playground_line_numbers".to_owned(), json!(true));
541        }
542    }
543    if html_config.playground.copyable {
544        data.insert("playground_copyable".to_owned(), json!(true));
545    }
546
547    data.insert("print_enable".to_owned(), json!(html_config.print.enable));
548    data.insert("fold_enable".to_owned(), json!(html_config.fold.enable));
549    data.insert("fold_level".to_owned(), json!(html_config.fold.level));
550    data.insert(
551        "sidebar_header_nav".to_owned(),
552        json!(html_config.sidebar_header_nav),
553    );
554
555    let search = html_config.search.clone();
556    if cfg!(feature = "search") {
557        let search = search.unwrap_or_default();
558        data.insert("search_enabled".to_owned(), json!(search.enable));
559        data.insert(
560            "search_js".to_owned(),
561            json!(search.enable && search.copy_js),
562        );
563    } else if search.is_some() {
564        warn!("mdBook compiled without search support, ignoring `output.html.search` table");
565        warn!(
566            "please reinstall with `cargo install mdbook --force --features search`to use the \
567             search feature"
568        )
569    }
570
571    if let Some(ref git_repository_url) = html_config.git_repository_url {
572        data.insert("git_repository_url".to_owned(), json!(git_repository_url));
573    }
574
575    let git_repository_icon = match html_config.git_repository_icon {
576        Some(ref git_repository_icon) => git_repository_icon,
577        None => "fab-github",
578    };
579    let git_repository_icon_class = match git_repository_icon.split('-').next() {
580        Some("fa") => "regular",
581        Some("fas") => "solid",
582        Some("fab") => "brands",
583        _ => "regular",
584    };
585    data.insert("git_repository_icon".to_owned(), json!(git_repository_icon));
586    data.insert(
587        "git_repository_icon_class".to_owned(),
588        json!(git_repository_icon_class),
589    );
590
591    let mut chapters = vec![];
592
593    for item in book.iter() {
594        // Create the data to inject in the template
595        let mut chapter = BTreeMap::new();
596
597        match *item {
598            BookItem::PartTitle(ref title) => {
599                chapter.insert("part".to_owned(), json!(title));
600            }
601            BookItem::Chapter(ref ch) => {
602                if let Some(ref section) = ch.number {
603                    chapter.insert("section".to_owned(), json!(section.to_string()));
604                }
605
606                chapter.insert(
607                    "has_sub_items".to_owned(),
608                    json!((!ch.sub_items.is_empty()).to_string()),
609                );
610
611                chapter.insert("name".to_owned(), json!(ch.name));
612                if let Some(ref path) = ch.path {
613                    let p = path
614                        .to_str()
615                        .with_context(|| "Could not convert path to str")?;
616                    chapter.insert("path".to_owned(), json!(p));
617                }
618            }
619            BookItem::Separator => {
620                chapter.insert("spacer".to_owned(), json!("_spacer_"));
621            }
622        }
623
624        chapters.push(chapter);
625    }
626
627    data.insert("chapters".to_owned(), json!(chapters));
628
629    debug!("[*]: JSON constructed");
630    Ok(data)
631}
632
633struct RenderChapterContext<'a> {
634    handlebars: &'a Handlebars<'a>,
635    destination: PathBuf,
636    data: serde_json::Map<String, serde_json::Value>,
637    book_config: BookConfig,
638    html_config: HtmlConfig,
639    chapter_titles: &'a HashMap<PathBuf, String>,
640}
641
642/// Redirect mapping.
643///
644/// The key is the source path (like `foo/bar.html`). The value is a tuple
645/// `(destination_path, fragment_map)`. The `destination_path` is the page to
646/// redirect to. `fragment_map` is the map of fragments that override the
647/// destination. For example, a fragment `#foo` could redirect to any other
648/// page or site.
649type CombinedRedirects = BTreeMap<String, (String, BTreeMap<String, String>)>;
650fn combine_fragment_redirects(redirects: &HashMap<String, String>) -> CombinedRedirects {
651    let mut combined: CombinedRedirects = BTreeMap::new();
652    // This needs to extract the fragments to generate the fragment map.
653    for (original, new) in redirects {
654        if let Some((source_path, source_fragment)) = original.rsplit_once('#') {
655            let e = combined.entry(source_path.to_string()).or_default();
656            if let Some(old) = e.1.insert(format!("#{source_fragment}"), new.clone()) {
657                error!(
658                    "internal error: found duplicate fragment redirect \
659                     {old} for {source_path}#{source_fragment}"
660                );
661            }
662        } else {
663            let e = combined.entry(original.to_string()).or_default();
664            e.0 = new.clone();
665        }
666    }
667    combined
668}
669
670/// Collects fragment redirects for an existing page.
671///
672/// The returned map has keys like `#foo` and the value is the new destination
673/// path or URL.
674fn collect_redirects_for_path(
675    path: &Path,
676    redirects: &HashMap<String, String>,
677) -> Result<BTreeMap<String, String>> {
678    let path = format!("/{}", path.to_url_path());
679    if redirects.contains_key(&path) {
680        bail!(
681            "redirect found for existing chapter at `{path}`\n\
682            Either delete the redirect or remove the chapter."
683        );
684    }
685
686    let key_prefix = format!("{path}#");
687    let map = redirects
688        .iter()
689        .filter_map(|(source, dest)| {
690            source
691                .strip_prefix(&key_prefix)
692                .map(|fragment| (format!("#{fragment}"), dest.to_string()))
693        })
694        .collect();
695    Ok(map)
696}