mdbook/renderer/html_handlebars/
hbs_renderer.rs

1use crate::book::{Book, BookItem};
2use crate::config::{BookConfig, Config, HtmlConfig, Playground, PlaygroundLanguage, RustEdition};
3use crate::errors::*;
4use crate::renderer::html_handlebars::helpers;
5use crate::renderer::{RenderContext, Renderer};
6use crate::theme::{self, playground_editor, Theme};
7use crate::utils;
8
9use std::borrow::Cow;
10use std::collections::BTreeMap;
11use std::collections::HashMap;
12use std::fs::{self, File};
13use std::path::{Path, PathBuf};
14
15use crate::utils::fs::get_404_output_file;
16use handlebars::Handlebars;
17use log::{debug, trace, warn};
18use once_cell::sync::Lazy;
19use regex::{escape, Captures, Regex};
20use serde_json::json;
21
22#[derive(Default)]
23pub struct HtmlHandlebars;
24
25impl HtmlHandlebars {
26    pub fn new() -> Self {
27        HtmlHandlebars
28    }
29
30    fn render_item(
31        &self,
32        item: &BookItem,
33        mut ctx: RenderItemContext<'_>,
34        print_content: &mut String,
35    ) -> Result<()> {
36        // FIXME: This should be made DRY-er and rely less on mutable state
37
38        let (ch, path) = match item {
39            BookItem::Chapter(ch) if !ch.is_draft_chapter() => (ch, ch.path.as_ref().unwrap()),
40            _ => return Ok(()),
41        };
42
43        if let Some(ref edit_url_template) = ctx.html_config.edit_url_template {
44            let full_path = ctx.book_config.src.to_str().unwrap_or_default().to_owned()
45                + "/"
46                + ch.source_path
47                    .clone()
48                    .unwrap_or_default()
49                    .to_str()
50                    .unwrap_or_default();
51
52            let edit_url = edit_url_template.replace("{path}", &full_path);
53            ctx.data
54                .insert("git_repository_edit_url".to_owned(), json!(edit_url));
55        }
56
57        let content = ch.content.clone();
58        let content = utils::render_markdown(&content, ctx.html_config.curly_quotes);
59
60        let fixed_content =
61            utils::render_markdown_with_path(&ch.content, ctx.html_config.curly_quotes, Some(path));
62        if !ctx.is_index && ctx.html_config.print.page_break {
63            // Add page break between chapters
64            // See https://developer.mozilla.org/en-US/docs/Web/CSS/break-before and https://developer.mozilla.org/en-US/docs/Web/CSS/page-break-before
65            // Add both two CSS properties because of the compatibility issue
66            print_content
67                .push_str(r#"<div style="break-before: page; page-break-before: always;"></div>"#);
68        }
69        print_content.push_str(&fixed_content);
70
71        // Update the context with data for this file
72        let ctx_path = path
73            .to_str()
74            .with_context(|| "Could not convert path to str")?;
75        let filepath = Path::new(&ctx_path).with_extension("html");
76
77        // "print.html" is used for the print page.
78        if path == Path::new("print.md") {
79            bail!("{} is reserved for internal use", path.display());
80        };
81
82        let book_title = ctx
83            .data
84            .get("book_title")
85            .and_then(serde_json::Value::as_str)
86            .unwrap_or("");
87
88        let title = if let Some(title) = ctx.chapter_titles.get(path) {
89            title.clone()
90        } else if book_title.is_empty() {
91            ch.name.clone()
92        } else {
93            ch.name.clone() + " - " + book_title
94        };
95
96        ctx.data.insert("path".to_owned(), json!(path));
97        ctx.data.insert("content".to_owned(), json!(content));
98        ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
99        ctx.data.insert("title".to_owned(), json!(title));
100        ctx.data.insert(
101            "path_to_root".to_owned(),
102            json!(utils::fs::path_to_root(&path)),
103        );
104        if let Some(ref section) = ch.number {
105            ctx.data
106                .insert("section".to_owned(), json!(section.to_string()));
107        }
108
109        // Render the handlebars template with the data
110        debug!("Render template");
111        let rendered = ctx.handlebars.render("index", &ctx.data)?;
112
113        let rendered = self.post_process(rendered, &ctx.html_config.playground, ctx.edition);
114
115        // Write to file
116        debug!("Creating {}", filepath.display());
117        utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?;
118
119        if ctx.is_index {
120            ctx.data.insert("path".to_owned(), json!("index.md"));
121            ctx.data.insert("path_to_root".to_owned(), json!(""));
122            ctx.data.insert("is_index".to_owned(), json!(true));
123            let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
124            let rendered_index =
125                self.post_process(rendered_index, &ctx.html_config.playground, ctx.edition);
126            debug!("Creating index.html from {}", ctx_path);
127            utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?;
128        }
129
130        Ok(())
131    }
132
133    fn render_404(
134        &self,
135        ctx: &RenderContext,
136        html_config: &HtmlConfig,
137        src_dir: &Path,
138        handlebars: &mut Handlebars<'_>,
139        data: &mut serde_json::Map<String, serde_json::Value>,
140    ) -> Result<()> {
141        let destination = &ctx.destination;
142        let content_404 = if let Some(ref filename) = html_config.input_404 {
143            let path = src_dir.join(filename);
144            std::fs::read_to_string(&path)
145                .with_context(|| format!("unable to open 404 input file {:?}", path))?
146        } else {
147            // 404 input not explicitly configured try the default file 404.md
148            let default_404_location = src_dir.join("404.md");
149            if default_404_location.exists() {
150                std::fs::read_to_string(&default_404_location).with_context(|| {
151                    format!("unable to open 404 input file {:?}", default_404_location)
152                })?
153            } else {
154                "# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \
155                navigation bar or search to continue."
156                    .to_string()
157            }
158        };
159        let html_content_404 = utils::render_markdown(&content_404, html_config.curly_quotes);
160
161        let mut data_404 = data.clone();
162        let base_url = if let Some(site_url) = &html_config.site_url {
163            site_url
164        } else {
165            debug!(
166                "HTML 'site-url' parameter not set, defaulting to '/'. Please configure \
167                this to ensure the 404 page work correctly, especially if your site is hosted in a \
168                subdirectory on the HTTP server."
169            );
170            "/"
171        };
172        data_404.insert("base_url".to_owned(), json!(base_url));
173        // Set a dummy path to ensure other paths (e.g. in the TOC) are generated correctly
174        data_404.insert("path".to_owned(), json!("404.md"));
175        data_404.insert("content".to_owned(), json!(html_content_404));
176
177        let mut title = String::from("Page not found");
178        if let Some(book_title) = &ctx.config.book.title {
179            title.push_str(" - ");
180            title.push_str(book_title);
181        }
182        data_404.insert("title".to_owned(), json!(title));
183        let rendered = handlebars.render("index", &data_404)?;
184
185        let rendered =
186            self.post_process(rendered, &html_config.playground, ctx.config.rust.edition);
187        let output_file = get_404_output_file(&html_config.input_404);
188        utils::fs::write_file(destination, output_file, rendered.as_bytes())?;
189        debug!("Creating 404.html ✓");
190        Ok(())
191    }
192
193    #[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))]
194    fn post_process(
195        &self,
196        rendered: String,
197        playground_config: &Playground,
198        edition: Option<RustEdition>,
199    ) -> String {
200        let rendered = build_header_links(&rendered);
201        let rendered = fix_code_blocks(&rendered);
202        let rendered = add_playground_pre(&rendered, playground_config, edition);
203
204        rendered
205    }
206
207    fn copy_static_files(
208        &self,
209        destination: &Path,
210        theme: &Theme,
211        html_config: &HtmlConfig,
212    ) -> Result<()> {
213        use crate::utils::fs::write_file;
214
215        write_file(
216            destination,
217            ".nojekyll",
218            b"This file makes sure that Github Pages doesn't process mdBook's output.\n",
219        )?;
220
221        if let Some(cname) = &html_config.cname {
222            write_file(destination, "CNAME", format!("{}\n", cname).as_bytes())?;
223        }
224
225        write_file(destination, "book.js", &theme.js)?;
226        write_file(destination, "css/general.css", &theme.general_css)?;
227        write_file(destination, "css/chrome.css", &theme.chrome_css)?;
228        if html_config.print.enable {
229            write_file(destination, "css/print.css", &theme.print_css)?;
230        }
231        write_file(destination, "css/variables.css", &theme.variables_css)?;
232        if let Some(contents) = &theme.favicon_png {
233            write_file(destination, "favicon.png", contents)?;
234        }
235        if let Some(contents) = &theme.favicon_svg {
236            write_file(destination, "favicon.svg", contents)?;
237        }
238        write_file(destination, "highlight.css", &theme.highlight_css)?;
239        write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?;
240        write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?;
241        write_file(destination, "highlight.js", &theme.highlight_js)?;
242        write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
243        write_file(
244            destination,
245            "FontAwesome/css/font-awesome.css",
246            theme::FONT_AWESOME,
247        )?;
248        write_file(
249            destination,
250            "FontAwesome/fonts/fontawesome-webfont.eot",
251            theme::FONT_AWESOME_EOT,
252        )?;
253        write_file(
254            destination,
255            "FontAwesome/fonts/fontawesome-webfont.svg",
256            theme::FONT_AWESOME_SVG,
257        )?;
258        write_file(
259            destination,
260            "FontAwesome/fonts/fontawesome-webfont.ttf",
261            theme::FONT_AWESOME_TTF,
262        )?;
263        write_file(
264            destination,
265            "FontAwesome/fonts/fontawesome-webfont.woff",
266            theme::FONT_AWESOME_WOFF,
267        )?;
268        write_file(
269            destination,
270            "FontAwesome/fonts/fontawesome-webfont.woff2",
271            theme::FONT_AWESOME_WOFF2,
272        )?;
273        write_file(
274            destination,
275            "FontAwesome/fonts/FontAwesome.ttf",
276            theme::FONT_AWESOME_TTF,
277        )?;
278        if html_config.copy_fonts {
279            write_file(destination, "fonts/fonts.css", theme::fonts::CSS)?;
280            for (file_name, contents) in theme::fonts::LICENSES.iter() {
281                write_file(destination, file_name, contents)?;
282            }
283            for (file_name, contents) in theme::fonts::OPEN_SANS.iter() {
284                write_file(destination, file_name, contents)?;
285            }
286            write_file(
287                destination,
288                theme::fonts::SOURCE_CODE_PRO.0,
289                theme::fonts::SOURCE_CODE_PRO.1,
290            )?;
291        }
292
293        let playground_config = &html_config.playground;
294
295        // Ace is a very large dependency, so only load it when requested
296        if playground_config.editable && playground_config.copy_js {
297            // Load the editor
298            write_file(destination, "editor.js", playground_editor::JS)?;
299            write_file(destination, "ace.js", playground_editor::ACE_JS)?;
300            write_file(destination, "mode-rust.js", playground_editor::MODE_RUST_JS)?;
301            write_file(
302                destination,
303                "mode-c_cpp.js",
304                playground_editor::MODE_C_CPP_JS,
305            )?;
306            write_file(
307                destination,
308                "theme-dawn.js",
309                playground_editor::THEME_DAWN_JS,
310            )?;
311            write_file(
312                destination,
313                "theme-monokai.js",
314                playground_editor::THEME_MONOKAI_JS,
315            )?;
316            write_file(
317                destination,
318                "theme-tomorrow_night.js",
319                playground_editor::THEME_TOMORROW_NIGHT_JS,
320            )?;
321        }
322
323        Ok(())
324    }
325
326    /// Update the context with data for this file
327    fn configure_print_version(
328        &self,
329        data: &mut serde_json::Map<String, serde_json::Value>,
330        print_content: &str,
331    ) {
332        // Make sure that the Print chapter does not display the title from
333        // the last rendered chapter by removing it from its context
334        data.remove("title");
335        data.insert("is_print".to_owned(), json!(true));
336        data.insert("path".to_owned(), json!("print.md"));
337        data.insert("content".to_owned(), json!(print_content));
338        data.insert(
339            "path_to_root".to_owned(),
340            json!(utils::fs::path_to_root(Path::new("print.md"))),
341        );
342    }
343
344    fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) {
345        handlebars.register_helper(
346            "toc",
347            Box::new(helpers::toc::RenderToc {
348                no_section_label: html_config.no_section_label,
349            }),
350        );
351        handlebars.register_helper("previous", Box::new(helpers::navigation::previous));
352        handlebars.register_helper("next", Box::new(helpers::navigation::next));
353        // TODO: remove theme_option in 0.5, it is not needed.
354        handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option));
355    }
356
357    /// Copy across any additional CSS and JavaScript files which the book
358    /// has been configured to use.
359    fn copy_additional_css_and_js(
360        &self,
361        html: &HtmlConfig,
362        root: &Path,
363        destination: &Path,
364    ) -> Result<()> {
365        let custom_files = html.additional_css.iter().chain(html.additional_js.iter());
366
367        debug!("Copying additional CSS and JS");
368
369        for custom_file in custom_files {
370            let input_location = root.join(custom_file);
371            let output_location = destination.join(custom_file);
372            if let Some(parent) = output_location.parent() {
373                fs::create_dir_all(parent)
374                    .with_context(|| format!("Unable to create {}", parent.display()))?;
375            }
376            debug!(
377                "Copying {} -> {}",
378                input_location.display(),
379                output_location.display()
380            );
381
382            fs::copy(&input_location, &output_location).with_context(|| {
383                format!(
384                    "Unable to copy {} to {}",
385                    input_location.display(),
386                    output_location.display()
387                )
388            })?;
389        }
390
391        Ok(())
392    }
393
394    fn emit_redirects(
395        &self,
396        root: &Path,
397        handlebars: &Handlebars<'_>,
398        redirects: &HashMap<String, String>,
399    ) -> Result<()> {
400        if redirects.is_empty() {
401            return Ok(());
402        }
403
404        log::debug!("Emitting redirects");
405
406        for (original, new) in redirects {
407            log::debug!("Redirecting \"{}\" → \"{}\"", original, new);
408            // Note: all paths are relative to the build directory, so the
409            // leading slash in an absolute path means nothing (and would mess
410            // up `root.join(original)`).
411            let original = original.trim_start_matches('/');
412            let filename = root.join(original);
413            self.emit_redirect(handlebars, &filename, new)?;
414        }
415
416        Ok(())
417    }
418
419    fn emit_redirect(
420        &self,
421        handlebars: &Handlebars<'_>,
422        original: &Path,
423        destination: &str,
424    ) -> Result<()> {
425        if original.exists() {
426            // sanity check to avoid accidentally overwriting a real file.
427            let msg = format!(
428                "Not redirecting \"{}\" to \"{}\" because it already exists. Are you sure it needs to be redirected?",
429                original.display(),
430                destination,
431            );
432            return Err(Error::msg(msg));
433        }
434
435        if let Some(parent) = original.parent() {
436            std::fs::create_dir_all(parent)
437                .with_context(|| format!("Unable to ensure \"{}\" exists", parent.display()))?;
438        }
439
440        let ctx = json!({
441            "url": destination,
442        });
443        let f = File::create(original)?;
444        handlebars
445            .render_to_write("redirect", &ctx, f)
446            .with_context(|| {
447                format!(
448                    "Unable to create a redirect file at \"{}\"",
449                    original.display()
450                )
451            })?;
452
453        Ok(())
454    }
455}
456
457// TODO(mattico): Remove some time after the 0.1.8 release
458fn maybe_wrong_theme_dir(dir: &Path) -> Result<bool> {
459    fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result<bool> {
460        Ok(entry.file_type()?.is_file()
461            && entry.path().extension().map_or(false, |ext| ext == "md"))
462    }
463
464    if dir.is_dir() {
465        for entry in fs::read_dir(dir)? {
466            if entry_is_maybe_book_file(entry?).unwrap_or(false) {
467                return Ok(false);
468            }
469        }
470        Ok(true)
471    } else {
472        Ok(false)
473    }
474}
475
476impl Renderer for HtmlHandlebars {
477    fn name(&self) -> &str {
478        "html"
479    }
480
481    fn render(&self, ctx: &RenderContext) -> Result<()> {
482        let book_config = &ctx.config.book;
483        let html_config = ctx.config.html_config().unwrap_or_default();
484        let src_dir = ctx.root.join(&ctx.config.book.src);
485        let destination = &ctx.destination;
486        let book = &ctx.book;
487        let build_dir = ctx.root.join(&ctx.config.build.build_dir);
488
489        if destination.exists() {
490            utils::fs::remove_dir_content(destination)
491                .with_context(|| "Unable to remove stale HTML output")?;
492        }
493
494        trace!("render");
495        let mut handlebars = Handlebars::new();
496
497        let theme_dir = match html_config.theme {
498            Some(ref theme) => {
499                let dir = ctx.root.join(theme);
500                if !dir.is_dir() {
501                    bail!("theme dir {} does not exist", dir.display());
502                }
503                dir
504            }
505            None => ctx.root.join("theme"),
506        };
507
508        if html_config.theme.is_none()
509            && maybe_wrong_theme_dir(&src_dir.join("theme")).unwrap_or(false)
510        {
511            warn!(
512                "Previous versions of mdBook erroneously accepted `./src/theme` as an automatic \
513                 theme directory"
514            );
515            warn!("Please move your theme files to `./theme` for them to continue being used");
516        }
517
518        let theme = theme::Theme::new(theme_dir);
519
520        debug!("Register the index handlebars template");
521        handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?;
522
523        debug!("Register the head handlebars template");
524        handlebars.register_partial("head", String::from_utf8(theme.head.clone())?)?;
525
526        debug!("Register the redirect handlebars template");
527        handlebars
528            .register_template_string("redirect", String::from_utf8(theme.redirect.clone())?)?;
529
530        debug!("Register the header handlebars template");
531        handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?;
532
533        debug!("Register handlebars helpers");
534        self.register_hbs_helpers(&mut handlebars, &html_config);
535
536        let mut data = make_data(&ctx.root, book, &ctx.config, &html_config, &theme)?;
537
538        // Print version
539        let mut print_content = String::new();
540
541        fs::create_dir_all(&destination)
542            .with_context(|| "Unexpected error when constructing destination path")?;
543
544        let mut is_index = true;
545        for item in book.iter() {
546            let ctx = RenderItemContext {
547                handlebars: &handlebars,
548                destination: destination.to_path_buf(),
549                data: data.clone(),
550                is_index,
551                book_config: book_config.clone(),
552                html_config: html_config.clone(),
553                edition: ctx.config.rust.edition,
554                chapter_titles: &ctx.chapter_titles,
555            };
556            self.render_item(item, ctx, &mut print_content)?;
557            // Only the first non-draft chapter item should be treated as the "index"
558            is_index &= !matches!(item, BookItem::Chapter(ch) if !ch.is_draft_chapter());
559        }
560
561        // Render 404 page
562        if html_config.input_404 != Some("".to_string()) {
563            self.render_404(ctx, &html_config, &src_dir, &mut handlebars, &mut data)?;
564        }
565
566        // Print version
567        self.configure_print_version(&mut data, &print_content);
568        if let Some(ref title) = ctx.config.book.title {
569            data.insert("title".to_owned(), json!(title));
570        }
571
572        // Render the handlebars template with the data
573        if html_config.print.enable {
574            debug!("Render template");
575            let rendered = handlebars.render("index", &data)?;
576
577            let rendered =
578                self.post_process(rendered, &html_config.playground, ctx.config.rust.edition);
579
580            utils::fs::write_file(destination, "print.html", rendered.as_bytes())?;
581            debug!("Creating print.html ✓");
582        }
583
584        debug!("Copy static files");
585        self.copy_static_files(destination, &theme, &html_config)
586            .with_context(|| "Unable to copy across static files")?;
587        self.copy_additional_css_and_js(&html_config, &ctx.root, destination)
588            .with_context(|| "Unable to copy across additional CSS and JS")?;
589
590        // Render search index
591        #[cfg(feature = "search")]
592        {
593            let search = html_config.search.unwrap_or_default();
594            if search.enable {
595                super::search::create_files(&search, destination, book)?;
596            }
597        }
598
599        self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect)
600            .context("Unable to emit redirects")?;
601
602        // Copy all remaining files, avoid a recursive copy from/to the book build dir
603        utils::fs::copy_files_except_ext(&src_dir, destination, true, Some(&build_dir), &["md"])?;
604
605        Ok(())
606    }
607}
608
609fn make_data(
610    root: &Path,
611    book: &Book,
612    config: &Config,
613    html_config: &HtmlConfig,
614    theme: &Theme,
615) -> Result<serde_json::Map<String, serde_json::Value>> {
616    trace!("make_data");
617
618    let mut data = serde_json::Map::new();
619    data.insert(
620        "language".to_owned(),
621        json!(config.book.language.clone().unwrap_or_default()),
622    );
623    data.insert(
624        "book_title".to_owned(),
625        json!(config.book.title.clone().unwrap_or_default()),
626    );
627    data.insert(
628        "description".to_owned(),
629        json!(config.book.description.clone().unwrap_or_default()),
630    );
631    if theme.favicon_png.is_some() {
632        data.insert("favicon_png".to_owned(), json!("favicon.png"));
633    }
634    if theme.favicon_svg.is_some() {
635        data.insert("favicon_svg".to_owned(), json!("favicon.svg"));
636    }
637    if let Some(ref live_reload_endpoint) = html_config.live_reload_endpoint {
638        data.insert(
639            "live_reload_endpoint".to_owned(),
640            json!(live_reload_endpoint),
641        );
642    }
643
644    // TODO: remove default_theme in 0.5, it is not needed.
645    let default_theme = match html_config.default_theme {
646        Some(ref theme) => theme.to_lowercase(),
647        None => "light".to_string(),
648    };
649    data.insert("default_theme".to_owned(), json!(default_theme));
650
651    let preferred_dark_theme = match html_config.preferred_dark_theme {
652        Some(ref theme) => theme.to_lowercase(),
653        None => "navy".to_string(),
654    };
655    data.insert(
656        "preferred_dark_theme".to_owned(),
657        json!(preferred_dark_theme),
658    );
659
660    // Add google analytics tag
661    if let Some(ref ga) = html_config.google_analytics {
662        data.insert("google_analytics".to_owned(), json!(ga));
663    }
664
665    if html_config.mathjax_support {
666        data.insert("mathjax_support".to_owned(), json!(true));
667    }
668
669    if html_config.copy_fonts {
670        data.insert("copy_fonts".to_owned(), json!(true));
671    }
672
673    // Add check to see if there is an additional style
674    if !html_config.additional_css.is_empty() {
675        let mut css = Vec::new();
676        for style in &html_config.additional_css {
677            match style.strip_prefix(root) {
678                Ok(p) => css.push(p.to_str().expect("Could not convert to str")),
679                Err(_) => css.push(style.to_str().expect("Could not convert to str")),
680            }
681        }
682        data.insert("additional_css".to_owned(), json!(css));
683    }
684
685    // Add check to see if there is an additional script
686    if !html_config.additional_js.is_empty() {
687        let mut js = Vec::new();
688        for script in &html_config.additional_js {
689            match script.strip_prefix(root) {
690                Ok(p) => js.push(p.to_str().expect("Could not convert to str")),
691                Err(_) => js.push(script.to_str().expect("Could not convert to str")),
692            }
693        }
694        data.insert("additional_js".to_owned(), json!(js));
695    }
696
697    if html_config.playground.editable && html_config.playground.copy_js {
698        data.insert("playground_js".to_owned(), json!(true));
699        if html_config.playground.line_numbers {
700            data.insert("playground_line_numbers".to_owned(), json!(true));
701        }
702    }
703    if html_config.playground.copyable {
704        data.insert("playground_copyable".to_owned(), json!(true));
705    }
706
707    data.insert("print_enable".to_owned(), json!(html_config.print.enable));
708    data.insert("fold_enable".to_owned(), json!(html_config.fold.enable));
709    data.insert("fold_level".to_owned(), json!(html_config.fold.level));
710
711    let search = html_config.search.clone();
712    if cfg!(feature = "search") {
713        let search = search.unwrap_or_default();
714        data.insert("search_enabled".to_owned(), json!(search.enable));
715        data.insert(
716            "search_js".to_owned(),
717            json!(search.enable && search.copy_js),
718        );
719    } else if search.is_some() {
720        warn!("mdBook compiled without search support, ignoring `output.html.search` table");
721        warn!(
722            "please reinstall with `cargo install mdbook --force --features search`to use the \
723             search feature"
724        )
725    }
726
727    if let Some(ref git_repository_url) = html_config.git_repository_url {
728        data.insert("git_repository_url".to_owned(), json!(git_repository_url));
729    }
730
731    let git_repository_icon = match html_config.git_repository_icon {
732        Some(ref git_repository_icon) => git_repository_icon,
733        None => "fa-github",
734    };
735    data.insert("git_repository_icon".to_owned(), json!(git_repository_icon));
736
737    let mut chapters = vec![];
738
739    for item in book.iter() {
740        // Create the data to inject in the template
741        let mut chapter = BTreeMap::new();
742
743        match *item {
744            BookItem::PartTitle(ref title) => {
745                chapter.insert("part".to_owned(), json!(title));
746            }
747            BookItem::Chapter(ref ch) => {
748                if let Some(ref section) = ch.number {
749                    chapter.insert("section".to_owned(), json!(section.to_string()));
750                }
751
752                chapter.insert(
753                    "has_sub_items".to_owned(),
754                    json!((!ch.sub_items.is_empty()).to_string()),
755                );
756
757                chapter.insert("name".to_owned(), json!(ch.name));
758                if let Some(ref path) = ch.path {
759                    let p = path
760                        .to_str()
761                        .with_context(|| "Could not convert path to str")?;
762                    chapter.insert("path".to_owned(), json!(p));
763                }
764            }
765            BookItem::Separator => {
766                chapter.insert("spacer".to_owned(), json!("_spacer_"));
767            }
768        }
769
770        chapters.push(chapter);
771    }
772
773    data.insert("chapters".to_owned(), json!(chapters));
774
775    debug!("[*]: JSON constructed");
776    Ok(data)
777}
778
779/// Goes through the rendered HTML, making sure all header tags have
780/// an anchor respectively so people can link to sections directly.
781fn build_header_links(html: &str) -> String {
782    static BUILD_HEADER_LINKS: Lazy<Regex> =
783        Lazy::new(|| Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap());
784
785    let mut id_counter = HashMap::new();
786
787    BUILD_HEADER_LINKS
788        .replace_all(html, |caps: &Captures<'_>| {
789            let level = caps[1]
790                .parse()
791                .expect("Regex should ensure we only ever get numbers here");
792
793            insert_link_into_header(level, &caps[2], &mut id_counter)
794        })
795        .into_owned()
796}
797
798/// Insert a sinle link into a header, making sure each link gets its own
799/// unique ID by appending an auto-incremented number (if necessary).
800fn insert_link_into_header(
801    level: usize,
802    content: &str,
803    id_counter: &mut HashMap<String, usize>,
804) -> String {
805    let id = utils::unique_id_from_content(content, id_counter);
806
807    format!(
808        r##"<h{level} id="{id}"><a class="header" href="#{id}">{text}</a></h{level}>"##,
809        level = level,
810        id = id,
811        text = content
812    )
813}
814
815// The rust book uses annotations for rustdoc to test code snippets,
816// like the following:
817// ```rust,should_panic
818// fn main() {
819//     // Code here
820// }
821// ```
822// This function replaces all commas by spaces in the code block classes
823fn fix_code_blocks(html: &str) -> String {
824    static FIX_CODE_BLOCKS: Lazy<Regex> =
825        Lazy::new(|| Regex::new(r##"<code([^>]+)class="([^"]+)"([^>]*)>"##).unwrap());
826
827    FIX_CODE_BLOCKS
828        .replace_all(html, |caps: &Captures<'_>| {
829            let before = &caps[1];
830            let classes = &caps[2].replace(',', " ");
831            let after = &caps[3];
832
833            format!(
834                r#"<code{before}class="{classes}"{after}>"#,
835                before = before,
836                classes = classes,
837                after = after
838            )
839        })
840        .into_owned()
841}
842
843struct PlaygroundConfig {
844    language: Option<String>,
845    runnable: bool,
846    editable: bool,
847    rust_edition: String,
848}
849
850fn add_playground_pre(
851    html: &str,
852    playground_config: &Playground,
853    edition: Option<RustEdition>,
854) -> String {
855    static ADD_PLAYGROUND_PRE: Lazy<Regex> =
856        Lazy::new(|| Regex::new(r##"((?s)<code[^>]?class="([^"]+)".*?>(.*?)</code>)"##).unwrap());
857
858    ADD_PLAYGROUND_PRE
859        .replace_all(html, |caps: &Captures<'_>| {
860            let text = &caps[1];
861            let classes = &caps[2];
862            let code = &caps[3];
863
864            let mut playground_languages: HashMap<String, PlaygroundLanguage> = playground_config.languages.iter().map(|lang| (lang.language.to_owned(), lang.to_owned())).collect();
865
866            let contains_rust = playground_languages.get("rust").is_some();
867            if !contains_rust {
868                playground_languages.insert(String::from("rust"), PlaygroundLanguage::default());
869            }
870
871            let language = classes.split_ascii_whitespace().find_map(|class| if class.contains("language-") { match class.get(9..) {
872                Some(l) => Some(l.to_string()),
873                None => None,
874            }} else { None });
875            let playground_config = PlaygroundConfig {
876                language,
877                runnable: (!classes.contains("ignore")
878                    && !classes.contains("noplayground")
879                    && !classes.contains("noplaypen")
880                    && playground_config.runnable)
881                    || classes.contains("mdbook-runnable"),
882                editable: classes.contains("editable"),
883                rust_edition: if classes.contains("edition2015") || classes.contains("edition2018") || classes.contains("edition2021") { String::from("") } else {
884                    match edition {
885                        Some(RustEdition::E2015) => String::from("edition2015"),
886                        Some(RustEdition::E2018) => String::from("edition2018"),
887                        Some(RustEdition::E2021) => String::from("edition2021"),
888                        None => String::from(""),
889                    }
890                },
891            };
892            let rust_lang = playground_languages.get("rust").unwrap();
893            if playground_config.language == Some(String::from("rust")) {
894                if playground_config.runnable {
895                    // wrap the contents in an external pre block
896                    format!(
897                        "<pre class=\"playground\"><code class=\"{}{}{}\">{}</code></pre>",
898                        classes,
899                        if playground_config.rust_edition != "" { " " } else { "" },
900                        playground_config.rust_edition,
901                        {
902                            let content: Cow<'_, str> = if playground_config.editable
903                                && classes.contains("editable")
904                                || text.contains("fn main")
905                                || text.contains("quick_main!")
906                            {
907                                code.into()
908                            } else {
909                                // we need to inject our own main
910                                let (attrs, code) = partition_source(code);
911
912                                format!("# #![allow(unused)]\n{}#fn main() {{\n{}#}}", attrs, code)
913                                    .into()
914                            };
915                            hide_lines(&content, &rust_lang.hidelines)
916                        }
917                    )
918                } else {
919                    format!("<code class=\"{}\">{}</code>", classes, hide_lines(code, &rust_lang.hidelines))
920                }
921            } else if playground_config.language.is_some() {
922                if playground_config.runnable {
923                    let language = playground_config.language.unwrap();
924                    let play_lang = playground_languages.get(&language);
925                    match play_lang {
926                        Some(play_lang) => {
927                            format!(
928                                "<pre data-endpoint={} class=\"playground\"><code class=\"{}\">{}</code></pre>",
929                                play_lang.endpoint,
930                                classes,
931                                {
932                                    let content: Cow<'_, str> = code.into();
933                                    hide_lines(&content, &play_lang.hidelines)
934                                }
935                            )
936                        },
937                        None => {
938                            format!("<code class=\"{}\">{}</code>", classes, hide_lines(code, &rust_lang.hidelines))
939                        }
940                    }
941
942                } else {
943                    format!("<code class=\"{}\">{}</code>", classes, hide_lines(code, &rust_lang.hidelines))
944                }
945            } else {
946                // not language-rust, so no-op
947                text.to_owned()
948            }
949        })
950        .into_owned()
951}
952
953fn hide_lines(content: &str, hidden: &str) -> String {
954    let hidden_regex = format!(r"^(\s*){}(.?)(.*)$", escape(hidden));
955    let boring_lines_regex: Regex = Regex::new(&hidden_regex).unwrap();
956
957    let mut result = String::with_capacity(content.len());
958    let mut lines = content.lines().peekable();
959    while let Some(line) = lines.next() {
960        // Don't include newline on the last line.
961        let newline = if lines.peek().is_none() { "" } else { "\n" };
962        if let Some(caps) = boring_lines_regex.captures(line) {
963            if &caps[2] == hidden {
964                result += &caps[1];
965                result += &caps[2];
966                result += &caps[3];
967                result += newline;
968                continue;
969            } else if &caps[2] != "!" && &caps[2] != "[" {
970                result += "<span class=\"boring\">";
971                result += &caps[1];
972                if &caps[2] != " " {
973                    result += &caps[2];
974                }
975                result += &caps[3];
976                result += newline;
977                result += "</span>";
978                continue;
979            }
980        }
981        result += line;
982        result += newline;
983    }
984    result
985}
986
987fn partition_source(s: &str) -> (String, String) {
988    let mut after_header = false;
989    let mut before = String::new();
990    let mut after = String::new();
991
992    for line in s.lines() {
993        let trimline = line.trim();
994        let header = trimline.chars().all(char::is_whitespace) || trimline.starts_with("#![");
995        if !header || after_header {
996            after_header = true;
997            after.push_str(line);
998            after.push('\n');
999        } else {
1000            before.push_str(line);
1001            before.push('\n');
1002        }
1003    }
1004
1005    (before, after)
1006}
1007
1008struct RenderItemContext<'a> {
1009    handlebars: &'a Handlebars<'a>,
1010    destination: PathBuf,
1011    data: serde_json::Map<String, serde_json::Value>,
1012    is_index: bool,
1013    book_config: BookConfig,
1014    html_config: HtmlConfig,
1015    edition: Option<RustEdition>,
1016    chapter_titles: &'a HashMap<PathBuf, String>,
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022
1023    #[test]
1024    fn original_build_header_links() {
1025        let inputs = vec![
1026            (
1027                "blah blah <h1>Foo</h1>",
1028                r##"blah blah <h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##,
1029            ),
1030            (
1031                "<h1>Foo</h1>",
1032                r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##,
1033            ),
1034            (
1035                "<h3>Foo^bar</h3>",
1036                r##"<h3 id="foobar"><a class="header" href="#foobar">Foo^bar</a></h3>"##,
1037            ),
1038            (
1039                "<h4></h4>",
1040                r##"<h4 id=""><a class="header" href="#"></a></h4>"##,
1041            ),
1042            (
1043                "<h4><em>Hï</em></h4>",
1044                r##"<h4 id="hï"><a class="header" href="#hï"><em>Hï</em></a></h4>"##,
1045            ),
1046            (
1047                "<h1>Foo</h1><h3>Foo</h3>",
1048                r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1><h3 id="foo-1"><a class="header" href="#foo-1">Foo</a></h3>"##,
1049            ),
1050        ];
1051
1052        for (src, should_be) in inputs {
1053            let got = build_header_links(src);
1054            assert_eq!(got, should_be);
1055        }
1056    }
1057
1058    #[test]
1059    fn add_playground() {
1060        let inputs = [
1061          ("<code class=\"language-rust\">x()</code>",
1062           "<pre class=\"playground\"><code class=\"language-rust\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1063          ("<code class=\"language-rust\">fn main() {}</code>",
1064           "<pre class=\"playground\"><code class=\"language-rust\">fn main() {}</code></pre>"),
1065          ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code>",
1066           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";</code></pre>"),
1067          ("<code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code>",
1068           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code></pre>"),
1069          ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code>",
1070           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span><span class=\"boring\">\n</span>\";</code></pre>"),
1071          ("<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>",
1072           "<code class=\"language-rust ignore\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";</code>"),
1073          ("<code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code>",
1074           "<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code></pre>"),
1075        ];
1076        for (src, should_be) in &inputs {
1077            let got = add_playground_pre(
1078                src,
1079                &Playground {
1080                    editable: true,
1081                    ..Playground::default()
1082                },
1083                None,
1084            );
1085            assert_eq!(&*got, *should_be);
1086        }
1087    }
1088    #[test]
1089    fn add_playground_edition2015() {
1090        let inputs = [
1091          ("<code class=\"language-rust\">x()</code>",
1092           "<pre class=\"playground\"><code class=\"language-rust edition2015\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1093          ("<code class=\"language-rust\">fn main() {}</code>",
1094           "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1095          ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1096           "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1097          ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1098           "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1099        ];
1100        for (src, should_be) in &inputs {
1101            let got = add_playground_pre(
1102                src,
1103                &Playground {
1104                    editable: true,
1105                    ..Playground::default()
1106                },
1107                Some(RustEdition::E2015),
1108            );
1109            assert_eq!(&*got, *should_be);
1110        }
1111    }
1112    #[test]
1113    fn add_playground_edition2018() {
1114        let inputs = [
1115          ("<code class=\"language-rust\">x()</code>",
1116           "<pre class=\"playground\"><code class=\"language-rust edition2018\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1117          ("<code class=\"language-rust\">fn main() {}</code>",
1118           "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1119          ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1120           "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1121          ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1122           "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1123        ];
1124        for (src, should_be) in &inputs {
1125            let got = add_playground_pre(
1126                src,
1127                &Playground {
1128                    editable: true,
1129                    ..Playground::default()
1130                },
1131                Some(RustEdition::E2018),
1132            );
1133            assert_eq!(&*got, *should_be);
1134        }
1135    }
1136    #[test]
1137    fn add_playground_edition2021() {
1138        let inputs = [
1139            ("<code class=\"language-rust\">x()</code>",
1140             "<pre class=\"playground\"><code class=\"language-rust edition2021\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1141            ("<code class=\"language-rust\">fn main() {}</code>",
1142             "<pre class=\"playground\"><code class=\"language-rust edition2021\">fn main() {}</code></pre>"),
1143            ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1144             "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1145            ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1146             "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1147        ];
1148        for (src, should_be) in &inputs {
1149            let got = add_playground_pre(
1150                src,
1151                &Playground {
1152                    editable: true,
1153                    ..Playground::default()
1154                },
1155                Some(RustEdition::E2021),
1156            );
1157            assert_eq!(&*got, *should_be);
1158        }
1159    }
1160}