1use crate::book::{Book, BookItem};
2use crate::config::{BookConfig, Code, Config, HtmlConfig, Playground, RustEdition};
3use crate::errors::*;
4use crate::renderer::html_handlebars::helpers;
5use crate::renderer::html_handlebars::StaticFiles;
6use crate::renderer::{RenderContext, Renderer};
7use crate::theme::{self, Theme};
8use crate::utils;
9
10use std::borrow::Cow;
11use std::collections::BTreeMap;
12use std::collections::HashMap;
13use std::fs::{self, File};
14use std::path::{Path, PathBuf};
15use std::sync::LazyLock;
16
17use crate::utils::fs::get_404_output_file;
18use handlebars::Handlebars;
19use log::{debug, trace, warn};
20use regex::{Captures, Regex};
21use serde_json::json;
22
23#[derive(Default)]
25pub struct HtmlHandlebars;
26
27impl HtmlHandlebars {
28    pub fn new() -> Self {
30        HtmlHandlebars
31    }
32
33    fn render_item(
34        &self,
35        item: &BookItem,
36        mut ctx: RenderItemContext<'_>,
37        print_content: &mut String,
38    ) -> Result<()> {
39        let (ch, path) = match item {
42            BookItem::Chapter(ch) if !ch.is_draft_chapter() => (ch, ch.path.as_ref().unwrap()),
43            _ => return Ok(()),
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 content = utils::render_markdown(&ch.content, ctx.html_config.smart_punctuation());
61
62        let fixed_content = utils::render_markdown_with_path(
63            &ch.content,
64            ctx.html_config.smart_punctuation(),
65            Some(path),
66        );
67        if !ctx.is_index && ctx.html_config.print.page_break {
68            print_content
72                .push_str(r#"<div style="break-before: page; page-break-before: always;"></div>"#);
73        }
74        print_content.push_str(&fixed_content);
75
76        let ctx_path = path
78            .to_str()
79            .with_context(|| "Could not convert path to str")?;
80        let filepath = Path::new(&ctx_path).with_extension("html");
81
82        if path == Path::new("print.md") {
84            bail!("{} is reserved for internal use", path.display());
85        };
86
87        let book_title = ctx
88            .data
89            .get("book_title")
90            .and_then(serde_json::Value::as_str)
91            .unwrap_or("");
92
93        let title = if let Some(title) = ctx.chapter_titles.get(path) {
94            title.clone()
95        } else if book_title.is_empty() {
96            ch.name.clone()
97        } else {
98            ch.name.clone() + " - " + book_title
99        };
100
101        ctx.data.insert("path".to_owned(), json!(path));
102        ctx.data.insert("content".to_owned(), json!(content));
103        ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
104        ctx.data.insert("title".to_owned(), json!(title));
105        ctx.data.insert(
106            "path_to_root".to_owned(),
107            json!(utils::fs::path_to_root(path)),
108        );
109        if let Some(ref section) = ch.number {
110            ctx.data
111                .insert("section".to_owned(), json!(section.to_string()));
112        }
113
114        let redirects = collect_redirects_for_path(&filepath, &ctx.html_config.redirect)?;
115        if !redirects.is_empty() {
116            ctx.data.insert(
117                "fragment_map".to_owned(),
118                json!(serde_json::to_string(&redirects)?),
119            );
120        }
121
122        debug!("Render template");
124        let rendered = ctx.handlebars.render("index", &ctx.data)?;
125
126        let rendered = self.post_process(
127            rendered,
128            &ctx.html_config.playground,
129            &ctx.html_config.code,
130            ctx.edition,
131        );
132
133        debug!("Creating {}", filepath.display());
135        utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?;
136
137        if ctx.is_index {
138            ctx.data.insert("path".to_owned(), json!("index.md"));
139            ctx.data.insert("path_to_root".to_owned(), json!(""));
140            ctx.data.insert("is_index".to_owned(), json!(true));
141            let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
142            let rendered_index = self.post_process(
143                rendered_index,
144                &ctx.html_config.playground,
145                &ctx.html_config.code,
146                ctx.edition,
147            );
148            debug!("Creating index.html from {}", ctx_path);
149            utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?;
150        }
151
152        Ok(())
153    }
154
155    fn render_404(
156        &self,
157        ctx: &RenderContext,
158        html_config: &HtmlConfig,
159        src_dir: &Path,
160        handlebars: &mut Handlebars<'_>,
161        data: &mut serde_json::Map<String, serde_json::Value>,
162    ) -> Result<()> {
163        let destination = &ctx.destination;
164        let content_404 = if let Some(ref filename) = html_config.input_404 {
165            let path = src_dir.join(filename);
166            std::fs::read_to_string(&path)
167                .with_context(|| format!("unable to open 404 input file {path:?}"))?
168        } else {
169            let default_404_location = src_dir.join("404.md");
171            if default_404_location.exists() {
172                std::fs::read_to_string(&default_404_location).with_context(|| {
173                    format!("unable to open 404 input file {default_404_location:?}")
174                })?
175            } else {
176                "# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \
177                navigation bar or search to continue."
178                    .to_string()
179            }
180        };
181        let html_content_404 =
182            utils::render_markdown(&content_404, html_config.smart_punctuation());
183
184        let mut data_404 = data.clone();
185        let base_url = if let Some(site_url) = &html_config.site_url {
186            site_url
187        } else {
188            debug!(
189                "HTML 'site-url' parameter not set, defaulting to '/'. Please configure \
190                this to ensure the 404 page work correctly, especially if your site is hosted in a \
191                subdirectory on the HTTP server."
192            );
193            "/"
194        };
195        data_404.insert("base_url".to_owned(), json!(base_url));
196        data_404.insert("path".to_owned(), json!("404.md"));
198        data_404.insert("content".to_owned(), json!(html_content_404));
199
200        let mut title = String::from("Page not found");
201        if let Some(book_title) = &ctx.config.book.title {
202            title.push_str(" - ");
203            title.push_str(book_title);
204        }
205        data_404.insert("title".to_owned(), json!(title));
206        let rendered = handlebars.render("index", &data_404)?;
207
208        let rendered = self.post_process(
209            rendered,
210            &html_config.playground,
211            &html_config.code,
212            ctx.config.rust.edition,
213        );
214        let output_file = get_404_output_file(&html_config.input_404);
215        utils::fs::write_file(destination, output_file, rendered.as_bytes())?;
216        debug!("Creating 404.html ✓");
217        Ok(())
218    }
219
220    fn post_process(
221        &self,
222        rendered: String,
223        playground_config: &Playground,
224        code_config: &Code,
225        edition: Option<RustEdition>,
226    ) -> String {
227        let rendered = build_header_links(&rendered);
228        let rendered = fix_code_blocks(&rendered);
229        let rendered = add_playground_pre(&rendered, playground_config, edition);
230        let rendered = hide_lines(&rendered, code_config);
231
232        rendered
233    }
234
235    fn configure_print_version(
237        &self,
238        data: &mut serde_json::Map<String, serde_json::Value>,
239        print_content: &str,
240    ) {
241        data.remove("title");
244        data.insert("is_print".to_owned(), json!(true));
245        data.insert("path".to_owned(), json!("print.md"));
246        data.insert("content".to_owned(), json!(print_content));
247        data.insert(
248            "path_to_root".to_owned(),
249            json!(utils::fs::path_to_root(Path::new("print.md"))),
250        );
251    }
252
253    fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) {
254        handlebars.register_helper(
255            "toc",
256            Box::new(helpers::toc::RenderToc {
257                no_section_label: html_config.no_section_label,
258            }),
259        );
260        handlebars.register_helper("previous", Box::new(helpers::navigation::previous));
261        handlebars.register_helper("next", Box::new(helpers::navigation::next));
262        handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option));
264    }
265
266    fn emit_redirects(
267        &self,
268        root: &Path,
269        handlebars: &Handlebars<'_>,
270        redirects: &HashMap<String, String>,
271    ) -> Result<()> {
272        if redirects.is_empty() {
273            return Ok(());
274        }
275
276        log::debug!("Emitting redirects");
277        let redirects = combine_fragment_redirects(redirects);
278
279        for (original, (dest, fragment_map)) in redirects {
280            let original = original.trim_start_matches('/');
284            let filename = root.join(original);
285            if filename.exists() {
286                continue;
288            }
289            if dest.is_empty() {
290                bail!(
291                    "redirect entry for `{original}` only has source paths with `#` fragments\n\
292                     There must be an entry without the `#` fragment to determine the default \
293                     destination."
294                );
295            }
296            log::debug!("Redirecting \"{}\" → \"{}\"", original, dest);
297            self.emit_redirect(handlebars, &filename, &dest, &fragment_map)?;
298        }
299
300        Ok(())
301    }
302
303    fn emit_redirect(
304        &self,
305        handlebars: &Handlebars<'_>,
306        original: &Path,
307        destination: &str,
308        fragment_map: &BTreeMap<String, String>,
309    ) -> Result<()> {
310        if let Some(parent) = original.parent() {
311            std::fs::create_dir_all(parent)
312                .with_context(|| format!("Unable to ensure \"{}\" exists", parent.display()))?;
313        }
314
315        let js_map = serde_json::to_string(fragment_map)?;
316
317        let ctx = json!({
318            "fragment_map": js_map,
319            "url": destination,
320        });
321        let f = File::create(original)?;
322        handlebars
323            .render_to_write("redirect", &ctx, f)
324            .with_context(|| {
325                format!(
326                    "Unable to create a redirect file at \"{}\"",
327                    original.display()
328                )
329            })?;
330
331        Ok(())
332    }
333}
334
335impl Renderer for HtmlHandlebars {
336    fn name(&self) -> &str {
337        "html"
338    }
339
340    fn render(&self, ctx: &RenderContext) -> Result<()> {
341        let book_config = &ctx.config.book;
342        let html_config = ctx.config.html_config().unwrap_or_default();
343        let src_dir = ctx.root.join(&ctx.config.book.src);
344        let destination = &ctx.destination;
345        let book = &ctx.book;
346        let build_dir = ctx.root.join(&ctx.config.build.build_dir);
347
348        if destination.exists() {
349            utils::fs::remove_dir_content(destination)
350                .with_context(|| "Unable to remove stale HTML output")?;
351        }
352
353        trace!("render");
354        let mut handlebars = Handlebars::new();
355
356        let theme_dir = match html_config.theme {
357            Some(ref theme) => {
358                let dir = ctx.root.join(theme);
359                if !dir.is_dir() {
360                    bail!("theme dir {} does not exist", dir.display());
361                }
362                dir
363            }
364            None => ctx.root.join("theme"),
365        };
366
367        let theme = theme::Theme::new(theme_dir);
368
369        debug!("Register the index handlebars template");
370        handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?;
371
372        debug!("Register the head handlebars template");
373        handlebars.register_partial("head", String::from_utf8(theme.head.clone())?)?;
374
375        debug!("Register the redirect handlebars template");
376        handlebars
377            .register_template_string("redirect", String::from_utf8(theme.redirect.clone())?)?;
378
379        debug!("Register the header handlebars template");
380        handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?;
381
382        debug!("Register the toc handlebars template");
383        handlebars.register_template_string("toc_js", String::from_utf8(theme.toc_js.clone())?)?;
384        handlebars
385            .register_template_string("toc_html", String::from_utf8(theme.toc_html.clone())?)?;
386
387        debug!("Register handlebars helpers");
388        self.register_hbs_helpers(&mut handlebars, &html_config);
389
390        let mut data = make_data(&ctx.root, book, &ctx.config, &html_config, &theme)?;
391
392        let mut print_content = String::new();
394
395        fs::create_dir_all(destination)
396            .with_context(|| "Unexpected error when constructing destination path")?;
397
398        let mut static_files = StaticFiles::new(&theme, &html_config, &ctx.root)?;
399
400        #[cfg(feature = "search")]
402        {
403            let default = crate::config::Search::default();
404            let search = html_config.search.as_ref().unwrap_or(&default);
405            if search.enable {
406                super::search::create_files(&search, &mut static_files, &book)?;
407            }
408        }
409
410        debug!("Render toc js");
411        {
412            let rendered_toc = handlebars.render("toc_js", &data)?;
413            static_files.add_builtin("toc.js", rendered_toc.as_bytes());
414            debug!("Creating toc.js ✓");
415        }
416
417        if html_config.hash_files {
418            static_files.hash_files()?;
419        }
420
421        debug!("Copy static files");
422        let resource_helper = static_files
423            .write_files(&destination)
424            .with_context(|| "Unable to copy across static files")?;
425
426        handlebars.register_helper("resource", Box::new(resource_helper));
427
428        debug!("Render toc html");
429        {
430            data.insert("is_toc_html".to_owned(), json!(true));
431            data.insert("path".to_owned(), json!("toc.html"));
432            let rendered_toc = handlebars.render("toc_html", &data)?;
433            utils::fs::write_file(destination, "toc.html", rendered_toc.as_bytes())?;
434            debug!("Creating toc.html ✓");
435            data.remove("path");
436            data.remove("is_toc_html");
437        }
438
439        utils::fs::write_file(
440            destination,
441            ".nojekyll",
442            b"This file makes sure that Github Pages doesn't process mdBook's output.\n",
443        )?;
444
445        if let Some(cname) = &html_config.cname {
446            utils::fs::write_file(destination, "CNAME", format!("{cname}\n").as_bytes())?;
447        }
448
449        let mut is_index = true;
450        for item in book.iter() {
451            let ctx = RenderItemContext {
452                handlebars: &handlebars,
453                destination: destination.to_path_buf(),
454                data: data.clone(),
455                is_index,
456                book_config: book_config.clone(),
457                html_config: html_config.clone(),
458                edition: ctx.config.rust.edition,
459                chapter_titles: &ctx.chapter_titles,
460            };
461            self.render_item(item, ctx, &mut print_content)?;
462            is_index &= !matches!(item, BookItem::Chapter(ch) if !ch.is_draft_chapter());
464        }
465
466        if html_config.input_404 != Some("".to_string()) {
468            self.render_404(ctx, &html_config, &src_dir, &mut handlebars, &mut data)?;
469        }
470
471        self.configure_print_version(&mut data, &print_content);
473        if let Some(ref title) = ctx.config.book.title {
474            data.insert("title".to_owned(), json!(title));
475        }
476
477        if html_config.print.enable {
479            debug!("Render template");
480            let rendered = handlebars.render("index", &data)?;
481
482            let rendered = self.post_process(
483                rendered,
484                &html_config.playground,
485                &html_config.code,
486                ctx.config.rust.edition,
487            );
488
489            utils::fs::write_file(destination, "print.html", rendered.as_bytes())?;
490            debug!("Creating print.html ✓");
491        }
492
493        self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect)
494            .context("Unable to emit redirects")?;
495
496        utils::fs::copy_files_except_ext(&src_dir, destination, true, Some(&build_dir), &["md"])?;
498
499        Ok(())
500    }
501}
502
503fn make_data(
504    root: &Path,
505    book: &Book,
506    config: &Config,
507    html_config: &HtmlConfig,
508    theme: &Theme,
509) -> Result<serde_json::Map<String, serde_json::Value>> {
510    trace!("make_data");
511
512    let mut data = serde_json::Map::new();
513    data.insert(
514        "language".to_owned(),
515        json!(config.book.language.clone().unwrap_or_default()),
516    );
517    data.insert(
518        "text_direction".to_owned(),
519        json!(config.book.realized_text_direction()),
520    );
521    data.insert(
522        "book_title".to_owned(),
523        json!(config.book.title.clone().unwrap_or_default()),
524    );
525    data.insert(
526        "description".to_owned(),
527        json!(config.book.description.clone().unwrap_or_default()),
528    );
529    if theme.favicon_png.is_some() {
530        data.insert("favicon_png".to_owned(), json!("favicon.png"));
531    }
532    if theme.favicon_svg.is_some() {
533        data.insert("favicon_svg".to_owned(), json!("favicon.svg"));
534    }
535    if let Some(ref live_reload_endpoint) = html_config.live_reload_endpoint {
536        data.insert(
537            "live_reload_endpoint".to_owned(),
538            json!(live_reload_endpoint),
539        );
540    }
541
542    let default_theme = match html_config.default_theme {
544        Some(ref theme) => theme.to_lowercase(),
545        None => "light".to_string(),
546    };
547    data.insert("default_theme".to_owned(), json!(default_theme));
548
549    let preferred_dark_theme = match html_config.preferred_dark_theme {
550        Some(ref theme) => theme.to_lowercase(),
551        None => "navy".to_string(),
552    };
553    data.insert(
554        "preferred_dark_theme".to_owned(),
555        json!(preferred_dark_theme),
556    );
557
558    if let Some(ref ga) = html_config.google_analytics {
560        data.insert("google_analytics".to_owned(), json!(ga));
561    }
562
563    if html_config.mathjax_support {
564        data.insert("mathjax_support".to_owned(), json!(true));
565    }
566
567    if html_config.copy_fonts || matches!(theme.fonts_css.as_deref(), Some([_, ..])) {
569        data.insert("copy_fonts".to_owned(), json!(true));
570    }
571
572    if !html_config.additional_css.is_empty() {
574        let mut css = Vec::new();
575        for style in &html_config.additional_css {
576            match style.strip_prefix(root) {
577                Ok(p) => css.push(p.to_str().expect("Could not convert to str")),
578                Err(_) => css.push(style.to_str().expect("Could not convert to str")),
579            }
580        }
581        data.insert("additional_css".to_owned(), json!(css));
582    }
583
584    if !html_config.additional_js.is_empty() {
586        let mut js = Vec::new();
587        for script in &html_config.additional_js {
588            match script.strip_prefix(root) {
589                Ok(p) => js.push(p.to_str().expect("Could not convert to str")),
590                Err(_) => js.push(script.to_str().expect("Could not convert to str")),
591            }
592        }
593        data.insert("additional_js".to_owned(), json!(js));
594    }
595
596    if html_config.playground.editable && html_config.playground.copy_js {
597        data.insert("playground_js".to_owned(), json!(true));
598        if html_config.playground.line_numbers {
599            data.insert("playground_line_numbers".to_owned(), json!(true));
600        }
601    }
602    if html_config.playground.copyable {
603        data.insert("playground_copyable".to_owned(), json!(true));
604    }
605
606    data.insert("print_enable".to_owned(), json!(html_config.print.enable));
607    data.insert("fold_enable".to_owned(), json!(html_config.fold.enable));
608    data.insert("fold_level".to_owned(), json!(html_config.fold.level));
609
610    let search = html_config.search.clone();
611    if cfg!(feature = "search") {
612        let search = search.unwrap_or_default();
613        data.insert("search_enabled".to_owned(), json!(search.enable));
614        data.insert(
615            "search_js".to_owned(),
616            json!(search.enable && search.copy_js),
617        );
618    } else if search.is_some() {
619        warn!("mdBook compiled without search support, ignoring `output.html.search` table");
620        warn!(
621            "please reinstall with `cargo install mdbook --force --features search`to use the \
622             search feature"
623        )
624    }
625
626    if let Some(ref git_repository_url) = html_config.git_repository_url {
627        data.insert("git_repository_url".to_owned(), json!(git_repository_url));
628    }
629
630    let git_repository_icon = match html_config.git_repository_icon {
631        Some(ref git_repository_icon) => git_repository_icon,
632        None => "fa-github",
633    };
634    data.insert("git_repository_icon".to_owned(), json!(git_repository_icon));
635
636    let mut chapters = vec![];
637
638    for item in book.iter() {
639        let mut chapter = BTreeMap::new();
641
642        match *item {
643            BookItem::PartTitle(ref title) => {
644                chapter.insert("part".to_owned(), json!(title));
645            }
646            BookItem::Chapter(ref ch) => {
647                if let Some(ref section) = ch.number {
648                    chapter.insert("section".to_owned(), json!(section.to_string()));
649                }
650
651                chapter.insert(
652                    "has_sub_items".to_owned(),
653                    json!((!ch.sub_items.is_empty()).to_string()),
654                );
655
656                chapter.insert("name".to_owned(), json!(ch.name));
657                if let Some(ref path) = ch.path {
658                    let p = path
659                        .to_str()
660                        .with_context(|| "Could not convert path to str")?;
661                    chapter.insert("path".to_owned(), json!(p));
662                }
663            }
664            BookItem::Separator => {
665                chapter.insert("spacer".to_owned(), json!("_spacer_"));
666            }
667        }
668
669        chapters.push(chapter);
670    }
671
672    data.insert("chapters".to_owned(), json!(chapters));
673
674    debug!("[*]: JSON constructed");
675    Ok(data)
676}
677
678fn build_header_links(html: &str) -> String {
681    static BUILD_HEADER_LINKS: LazyLock<Regex> = LazyLock::new(|| {
682        Regex::new(r#"<h(\d)(?: id="([^"]+)")?(?: class="([^"]+)")?>(.*?)</h\d>"#).unwrap()
683    });
684    static IGNORE_CLASS: &[&str] = &["menu-title", "mdbook-help-title"];
685
686    let mut id_counter = HashMap::new();
687
688    BUILD_HEADER_LINKS
689        .replace_all(html, |caps: &Captures<'_>| {
690            let level = caps[1]
691                .parse()
692                .expect("Regex should ensure we only ever get numbers here");
693
694            if let Some(classes) = caps.get(3) {
696                for class in classes.as_str().split(" ") {
697                    if IGNORE_CLASS.contains(&class) {
698                        return caps[0].to_string();
699                    }
700                }
701            }
702
703            insert_link_into_header(
704                level,
705                &caps[4],
706                caps.get(2).map(|x| x.as_str().to_string()),
707                caps.get(3).map(|x| x.as_str().to_string()),
708                &mut id_counter,
709            )
710        })
711        .into_owned()
712}
713
714fn insert_link_into_header(
717    level: usize,
718    content: &str,
719    id: Option<String>,
720    classes: Option<String>,
721    id_counter: &mut HashMap<String, usize>,
722) -> String {
723    let id = id.unwrap_or_else(|| utils::unique_id_from_content(content, id_counter));
724    let classes = classes
725        .map(|s| format!(" class=\"{s}\""))
726        .unwrap_or_default();
727
728    format!(
729        r##"<h{level} id="{id}"{classes}><a class="header" href="#{id}">{content}</a></h{level}>"##
730    )
731}
732
733fn fix_code_blocks(html: &str) -> String {
742    static FIX_CODE_BLOCKS: LazyLock<Regex> =
743        LazyLock::new(|| Regex::new(r##"<code([^>]+)class="([^"]+)"([^>]*)>"##).unwrap());
744
745    FIX_CODE_BLOCKS
746        .replace_all(html, |caps: &Captures<'_>| {
747            let before = &caps[1];
748            let classes = &caps[2].replace(',', " ");
749            let after = &caps[3];
750
751            format!(r#"<code{before}class="{classes}"{after}>"#)
752        })
753        .into_owned()
754}
755
756static CODE_BLOCK_RE: LazyLock<Regex> =
757    LazyLock::new(|| Regex::new(r##"((?s)<code[^>]?class="([^"]+)".*?>(.*?)</code>)"##).unwrap());
758
759fn add_playground_pre(
760    html: &str,
761    playground_config: &Playground,
762    edition: Option<RustEdition>,
763) -> String {
764    CODE_BLOCK_RE
765        .replace_all(html, |caps: &Captures<'_>| {
766            let text = &caps[1];
767            let classes = &caps[2];
768            let code = &caps[3];
769
770            if classes.contains("language-rust")
771                && ((!classes.contains("ignore")
772                    && !classes.contains("noplayground")
773                    && !classes.contains("noplaypen")
774                    && playground_config.runnable)
775                    || classes.contains("mdbook-runnable"))
776            {
777                let contains_e2015 = classes.contains("edition2015");
778                let contains_e2018 = classes.contains("edition2018");
779                let contains_e2021 = classes.contains("edition2021");
780                let edition_class = if contains_e2015 || contains_e2018 || contains_e2021 {
781                    ""
783                } else {
784                    match edition {
785                        Some(RustEdition::E2015) => " edition2015",
786                        Some(RustEdition::E2018) => " edition2018",
787                        Some(RustEdition::E2021) => " edition2021",
788                        Some(RustEdition::E2024) => " edition2024",
789                        None => "",
790                    }
791                };
792
793                format!(
795                    "<pre class=\"playground\"><code class=\"{}{}\">{}</code></pre>",
796                    classes,
797                    edition_class,
798                    {
799                        let content: Cow<'_, str> = if playground_config.editable
800                            && classes.contains("editable")
801                            || text.contains("fn main")
802                            || text.contains("quick_main!")
803                        {
804                            code.into()
805                        } else {
806                            let (attrs, code) = partition_source(code);
808
809                            format!("# #![allow(unused)]\n{attrs}# fn main() {{\n{code}# }}").into()
810                        };
811                        content
812                    }
813                )
814            } else {
815                text.to_owned()
817            }
818        })
819        .into_owned()
820}
821
822fn hide_lines(html: &str, code_config: &Code) -> String {
825    static LANGUAGE_REGEX: LazyLock<Regex> =
826        LazyLock::new(|| Regex::new(r"\blanguage-(\w+)\b").unwrap());
827    static HIDELINES_REGEX: LazyLock<Regex> =
828        LazyLock::new(|| Regex::new(r"\bhidelines=(\S+)").unwrap());
829
830    CODE_BLOCK_RE
831        .replace_all(html, |caps: &Captures<'_>| {
832            let text = &caps[1];
833            let classes = &caps[2];
834            let code = &caps[3];
835
836            if classes.contains("language-rust") {
837                format!(
838                    "<code class=\"{}\">{}</code>",
839                    classes,
840                    hide_lines_rust(code)
841                )
842            } else {
843                let hidelines_capture = HIDELINES_REGEX.captures(classes);
845                let hidelines_prefix = match &hidelines_capture {
846                    Some(capture) => Some(&capture[1]),
847                    None => {
848                        LANGUAGE_REGEX.captures(classes).and_then(|capture| {
850                            code_config.hidelines.get(&capture[1]).map(|p| p.as_str())
851                        })
852                    }
853                };
854
855                match hidelines_prefix {
856                    Some(prefix) => format!(
857                        "<code class=\"{}\">{}</code>",
858                        classes,
859                        hide_lines_with_prefix(code, prefix)
860                    ),
861                    None => text.to_owned(),
862                }
863            }
864        })
865        .into_owned()
866}
867
868fn hide_lines_rust(content: &str) -> String {
869    static BORING_LINES_REGEX: LazyLock<Regex> =
870        LazyLock::new(|| Regex::new(r"^(\s*)#(.?)(.*)$").unwrap());
871
872    let mut result = String::with_capacity(content.len());
873    let mut lines = content.lines().peekable();
874    while let Some(line) = lines.next() {
875        let newline = if lines.peek().is_none() { "" } else { "\n" };
877        if let Some(caps) = BORING_LINES_REGEX.captures(line) {
878            if &caps[2] == "#" {
879                result += &caps[1];
880                result += &caps[2];
881                result += &caps[3];
882                result += newline;
883                continue;
884            } else if matches!(&caps[2], "" | " ") {
885                result += "<span class=\"boring\">";
886                result += &caps[1];
887                result += &caps[3];
888                result += newline;
889                result += "</span>";
890                continue;
891            }
892        }
893        result += line;
894        result += newline;
895    }
896    result
897}
898
899fn hide_lines_with_prefix(content: &str, prefix: &str) -> String {
900    let mut result = String::with_capacity(content.len());
901    for line in content.lines() {
902        if line.trim_start().starts_with(prefix) {
903            let pos = line.find(prefix).unwrap();
904            let (ws, rest) = (&line[..pos], &line[pos + prefix.len()..]);
905
906            result += "<span class=\"boring\">";
907            result += ws;
908            result += rest;
909            result += "\n";
910            result += "</span>";
911            continue;
912        }
913        result += line;
914        result += "\n";
915    }
916    result
917}
918
919fn partition_source(s: &str) -> (String, String) {
920    let mut after_header = false;
921    let mut before = String::new();
922    let mut after = String::new();
923
924    for line in s.lines() {
925        let trimline = line.trim();
926        let header = trimline.chars().all(char::is_whitespace) || trimline.starts_with("#![");
927        if !header || after_header {
928            after_header = true;
929            after.push_str(line);
930            after.push('\n');
931        } else {
932            before.push_str(line);
933            before.push('\n');
934        }
935    }
936
937    (before, after)
938}
939
940struct RenderItemContext<'a> {
941    handlebars: &'a Handlebars<'a>,
942    destination: PathBuf,
943    data: serde_json::Map<String, serde_json::Value>,
944    is_index: bool,
945    book_config: BookConfig,
946    html_config: HtmlConfig,
947    edition: Option<RustEdition>,
948    chapter_titles: &'a HashMap<PathBuf, String>,
949}
950
951type CombinedRedirects = BTreeMap<String, (String, BTreeMap<String, String>)>;
959fn combine_fragment_redirects(redirects: &HashMap<String, String>) -> CombinedRedirects {
960    let mut combined: CombinedRedirects = BTreeMap::new();
961    for (original, new) in redirects {
963        if let Some((source_path, source_fragment)) = original.rsplit_once('#') {
964            let e = combined.entry(source_path.to_string()).or_default();
965            if let Some(old) = e.1.insert(format!("#{source_fragment}"), new.clone()) {
966                log::error!(
967                    "internal error: found duplicate fragment redirect \
968                     {old} for {source_path}#{source_fragment}"
969                );
970            }
971        } else {
972            let e = combined.entry(original.to_string()).or_default();
973            e.0 = new.clone();
974        }
975    }
976    combined
977}
978
979fn collect_redirects_for_path(
984    path: &Path,
985    redirects: &HashMap<String, String>,
986) -> Result<BTreeMap<String, String>> {
987    let path = format!("/{}", path.display().to_string().replace('\\', "/"));
988    if redirects.contains_key(&path) {
989        bail!(
990            "redirect found for existing chapter at `{path}`\n\
991            Either delete the redirect or remove the chapter."
992        );
993    }
994
995    let key_prefix = format!("{path}#");
996    let map = redirects
997        .iter()
998        .filter_map(|(source, dest)| {
999            source
1000                .strip_prefix(&key_prefix)
1001                .map(|fragment| (format!("#{fragment}"), dest.to_string()))
1002        })
1003        .collect();
1004    Ok(map)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use crate::config::TextDirection;
1010
1011    use super::*;
1012    use pretty_assertions::assert_eq;
1013
1014    #[test]
1015    fn original_build_header_links() {
1016        let inputs = vec![
1017            (
1018                "blah blah <h1>Foo</h1>",
1019                r##"blah blah <h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##,
1020            ),
1021            (
1022                "<h1>Foo</h1>",
1023                r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##,
1024            ),
1025            (
1026                "<h3>Foo^bar</h3>",
1027                r##"<h3 id="foobar"><a class="header" href="#foobar">Foo^bar</a></h3>"##,
1028            ),
1029            (
1030                "<h4></h4>",
1031                r##"<h4 id=""><a class="header" href="#"></a></h4>"##,
1032            ),
1033            (
1034                "<h4><em>Hï</em></h4>",
1035                r##"<h4 id="hï"><a class="header" href="#hï"><em>Hï</em></a></h4>"##,
1036            ),
1037            (
1038                "<h1>Foo</h1><h3>Foo</h3>",
1039                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>"##,
1040            ),
1041            (
1043                r##"<h1 id="foobar">Foo</h1>"##,
1044                r##"<h1 id="foobar"><a class="header" href="#foobar">Foo</a></h1>"##,
1045            ),
1046            (
1048                r##"<h1 class="class1 class2">Foo</h1>"##,
1049                r##"<h1 id="foo" class="class1 class2"><a class="header" href="#foo">Foo</a></h1>"##,
1050            ),
1051            (
1053                r##"<h1 id="foobar" class="class1 class2">Foo</h1>"##,
1054                r##"<h1 id="foobar" class="class1 class2"><a class="header" href="#foobar">Foo</a></h1>"##,
1055            ),
1056        ];
1057
1058        for (src, should_be) in inputs {
1059            let got = build_header_links(src);
1060            assert_eq!(got, should_be);
1061        }
1062    }
1063
1064    #[test]
1065    fn add_playground() {
1066        let inputs = [
1067          ("<code class=\"language-rust\">x()</code>",
1068           "<pre class=\"playground\"><code class=\"language-rust\"># #![allow(unused)]\n# fn main() {\nx()\n# }</code></pre>"),
1069          ("<code class=\"language-rust\">fn main() {}</code>",
1070           "<pre class=\"playground\"><code class=\"language-rust\">fn main() {}</code></pre>"),
1071          ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code>",
1072           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code></pre>"),
1073          ("<code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code>",
1074           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code></pre>"),
1075          ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code>",
1076           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code></pre>"),
1077          ("<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>",
1078           "<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>"),
1079          ("<code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code>",
1080           "<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code></pre>"),
1081        ];
1082        for (src, should_be) in &inputs {
1083            let got = add_playground_pre(
1084                src,
1085                &Playground {
1086                    editable: true,
1087                    ..Playground::default()
1088                },
1089                None,
1090            );
1091            assert_eq!(&*got, *should_be);
1092        }
1093    }
1094    #[test]
1095    fn add_playground_edition2015() {
1096        let inputs = [
1097          ("<code class=\"language-rust\">x()</code>",
1098           "<pre class=\"playground\"><code class=\"language-rust edition2015\"># #![allow(unused)]\n# fn main() {\nx()\n# }</code></pre>"),
1099          ("<code class=\"language-rust\">fn main() {}</code>",
1100           "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1101          ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1102           "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1103          ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1104           "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1105        ];
1106        for (src, should_be) in &inputs {
1107            let got = add_playground_pre(
1108                src,
1109                &Playground {
1110                    editable: true,
1111                    ..Playground::default()
1112                },
1113                Some(RustEdition::E2015),
1114            );
1115            assert_eq!(&*got, *should_be);
1116        }
1117    }
1118    #[test]
1119    fn add_playground_edition2018() {
1120        let inputs = [
1121          ("<code class=\"language-rust\">x()</code>",
1122           "<pre class=\"playground\"><code class=\"language-rust edition2018\"># #![allow(unused)]\n# fn main() {\nx()\n# }</code></pre>"),
1123          ("<code class=\"language-rust\">fn main() {}</code>",
1124           "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1125          ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1126           "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1127          ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1128           "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1129        ];
1130        for (src, should_be) in &inputs {
1131            let got = add_playground_pre(
1132                src,
1133                &Playground {
1134                    editable: true,
1135                    ..Playground::default()
1136                },
1137                Some(RustEdition::E2018),
1138            );
1139            assert_eq!(&*got, *should_be);
1140        }
1141    }
1142    #[test]
1143    fn add_playground_edition2021() {
1144        let inputs = [
1145            ("<code class=\"language-rust\">x()</code>",
1146             "<pre class=\"playground\"><code class=\"language-rust edition2021\"># #![allow(unused)]\n# fn main() {\nx()\n# }</code></pre>"),
1147            ("<code class=\"language-rust\">fn main() {}</code>",
1148             "<pre class=\"playground\"><code class=\"language-rust edition2021\">fn main() {}</code></pre>"),
1149            ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1150             "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1151            ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1152             "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1153        ];
1154        for (src, should_be) in &inputs {
1155            let got = add_playground_pre(
1156                src,
1157                &Playground {
1158                    editable: true,
1159                    ..Playground::default()
1160                },
1161                Some(RustEdition::E2021),
1162            );
1163            assert_eq!(&*got, *should_be);
1164        }
1165    }
1166
1167    #[test]
1168    fn hide_lines_language_rust() {
1169        let inputs = [
1170          (
1171           "<pre class=\"playground\"><code class=\"language-rust\">\n# #![allow(unused)]\n# fn main() {\nx()\n# }</code></pre>",
1172           "<pre class=\"playground\"><code class=\"language-rust\">\n<span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>",),
1173          (
1175           "<pre class=\"playground\"><code class=\"language-rust\">\n#fn main() {\nx()\n#}</code></pre>",
1176           "<pre class=\"playground\"><code class=\"language-rust\">\n#fn main() {\nx()\n#}</code></pre>",),
1177          (
1178           "<pre class=\"playground\"><code class=\"language-rust\">fn main() {}</code></pre>",
1179           "<pre class=\"playground\"><code class=\"language-rust\">fn main() {}</code></pre>",),
1180          (
1181           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code></pre>",
1182           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";</code></pre>",),
1183          (
1184           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code></pre>",
1185           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code></pre>",),
1186          (
1187           "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code></pre>",
1188           "<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>",),
1189          (
1190           "<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>",
1191           "<code class=\"language-rust ignore\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";</code>",),
1192          (
1193           "<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code></pre>",
1194           "<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code></pre>",),
1195        ];
1196        for (src, should_be) in &inputs {
1197            let got = hide_lines(src, &Code::default());
1198            assert_eq!(&*got, *should_be);
1199        }
1200    }
1201
1202    #[test]
1203    fn hide_lines_language_other() {
1204        let inputs = [
1205          (
1206           "<code class=\"language-python\">~hidden()\nnothidden():\n~    hidden()\n    ~hidden()\n    nothidden()</code>",
1207           "<code class=\"language-python\"><span class=\"boring\">hidden()\n</span>nothidden():\n<span class=\"boring\">    hidden()\n</span><span class=\"boring\">    hidden()\n</span>    nothidden()\n</code>",),
1208           (
1209            "<code class=\"language-python hidelines=!!!\">!!!hidden()\nnothidden():\n!!!    hidden()\n    !!!hidden()\n    nothidden()</code>",
1210            "<code class=\"language-python hidelines=!!!\"><span class=\"boring\">hidden()\n</span>nothidden():\n<span class=\"boring\">    hidden()\n</span><span class=\"boring\">    hidden()\n</span>    nothidden()\n</code>",),
1211        ];
1212        for (src, should_be) in &inputs {
1213            let got = hide_lines(
1214                src,
1215                &Code {
1216                    hidelines: {
1217                        let mut map = HashMap::new();
1218                        map.insert("python".to_string(), "~".to_string());
1219                        map
1220                    },
1221                },
1222            );
1223            assert_eq!(&*got, *should_be);
1224        }
1225    }
1226
1227    #[test]
1228    fn test_json_direction() {
1229        assert_eq!(json!(TextDirection::RightToLeft), json!("rtl"));
1230        assert_eq!(json!(TextDirection::LeftToRight), json!("ltr"));
1231    }
1232}