diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index 8edac3cace..1e8c99a498 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -117,7 +117,7 @@ impl HtmlHandlebars { // Render the handlebars template with the data debug!("Render template"); - let rendered = ctx.handlebars.render("index", &ctx.data)?; + let rendered = render_template(ctx.handlebars, "index", &ctx.data)?; // Write to file let out_path = ctx.destination.join(filepath); @@ -127,7 +127,7 @@ impl HtmlHandlebars { ctx.data.insert("path".to_owned(), json!("index.md")); ctx.data.insert("path_to_root".to_owned(), json!("")); ctx.data.insert("is_index".to_owned(), json!(true)); - let rendered_index = ctx.handlebars.render("index", &ctx.data)?; + let rendered_index = render_template(ctx.handlebars, "index", &ctx.data)?; debug!("Creating index.html from {}", ctx_path); fs::write(ctx.destination.join("index.html"), rendered_index)?; } @@ -187,7 +187,7 @@ impl HtmlHandlebars { title.push_str(book_title); } data_404.insert("title".to_owned(), json!(title)); - let rendered = handlebars.render("index", &data_404)?; + let rendered = render_template(handlebars, "index", &data_404)?; let output_file = ctx.destination.join(html_config.get_404_output_file()); fs::write(output_file, rendered)?; @@ -220,7 +220,7 @@ impl HtmlHandlebars { ); debug!("Render template"); - let rendered = handlebars.render("index", &data)?; + let rendered = render_template(handlebars, "index", &data)?; Ok(rendered) } @@ -245,6 +245,7 @@ impl HtmlHandlebars { } debug!("Emitting redirects"); + detect_redirect_loops(redirects)?; let redirects = combine_fragment_redirects(redirects); for (original, (dest, fragment_map)) in redirects { @@ -288,7 +289,7 @@ impl HtmlHandlebars { "fragment_map": js_map, "url": destination, }); - let rendered = handlebars.render("redirect", &ctx).with_context(|| { + let rendered = render_template(handlebars, "redirect", &ctx).with_context(|| { format!( "Unable to create a redirect file at `{}`", original.display() @@ -376,7 +377,7 @@ impl Renderer for HtmlHandlebars { debug!("Render toc js"); { - let rendered_toc = handlebars.render("toc_js", &data)?; + let rendered_toc = render_template(&handlebars, "toc_js", &data)?; static_files.add_builtin("toc.js", rendered_toc.as_bytes()); debug!("Creating toc.js ✓"); } @@ -396,7 +397,7 @@ impl Renderer for HtmlHandlebars { { data.insert("is_toc_html".to_owned(), json!(true)); data.insert("path".to_owned(), json!("toc.html")); - let rendered_toc = handlebars.render("toc_html", &data)?; + let rendered_toc = render_template(&handlebars, "toc_html", &data)?; fs::write(destination.join("toc.html"), rendered_toc)?; debug!("Creating toc.html ✓"); data.remove("path"); @@ -639,6 +640,69 @@ struct RenderChapterContext<'a> { chapter_titles: &'a HashMap, } +/// Returns the redirect source path used in `[output.html.redirect]` keys. +fn canonical_redirect_source(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + } +} + +fn redirect_destination_is_external(dest: &str) -> bool { + let dest = dest.trim(); + dest.starts_with("http://") || dest.starts_with("https://") || dest.starts_with("//") +} + +/// Detects cycles in `[output.html.redirect]` before emitting redirect pages. +fn detect_redirect_loops(redirects: &HashMap) -> Result<()> { + use std::collections::HashSet; + + let mut visited = HashSet::new(); + let mut sources: Vec<_> = redirects.keys().collect(); + sources.sort(); + for start in sources { + if visited.contains(start) { + continue; + } + let mut path: Vec<&str> = Vec::new(); + let mut current = start.as_str(); + loop { + if let Some(pos) = path.iter().position(|&p| p == current) { + let mut cycle = String::new(); + for source in &path[pos..] { + if !cycle.is_empty() { + cycle.push_str(" -> "); + } + cycle.push_str(source); + if let Some(dest) = redirects.get(*source) { + cycle.push_str(" -> "); + cycle.push_str(dest); + } + } + bail!("redirect loop detected: {cycle}"); + } + visited.insert(current.to_owned()); + path.push(current); + let Some(dest) = redirects.get(current) else { + break; + }; + if redirect_destination_is_external(dest) { + break; + } + let canonical = canonical_redirect_source(dest); + let Some((next, _)) = redirects + .get_key_value(&canonical) + .or_else(|| redirects.get_key_value(dest)) + else { + break; + }; + current = next.as_str(); + } + } + Ok(()) +} + /// Redirect mapping. /// /// The key is the source path (like `foo/bar.html`). The value is a tuple @@ -694,3 +758,68 @@ fn collect_redirects_for_path( .collect(); Ok(map) } + +fn render_template( + handlebars: &Handlebars<'_>, + name: &str, + data: &impl serde::Serialize, +) -> Result { + handlebars + .render(name, data) + .map_err(|err| enhance_template_render_error(name, err)) +} + +fn enhance_template_render_error(template: &str, err: handlebars::RenderError) -> anyhow::Error { + let message = err.to_string(); + let mut error: anyhow::Error = err.into(); + error = error.context(format!("Error rendering `{template}` template")); + if let Some(hint) = missing_helper_hint(&message) { + error = error.context(hint.to_owned()); + } + error +} + +fn missing_helper_hint(message: &str) -> Option<&'static str> { + if !message.contains("Helper not found") { + return None; + } + if message.contains("fa") { + Some( + "The `fa` Font Awesome helper is provided by mdBook's HTML renderer (mdBook 0.5+). \ + If you override `theme/index.hbs`, copy it from the same mdBook version you use to \ + build (for example with `mdbook init`), not from a different branch or release.", + ) + } else if message.contains("toc") { + Some( + "The `toc` helper is provided by mdBook's HTML renderer. \ + Copy theme templates from the mdBook version you use to build (for example with `mdbook init`).", + ) + } else if message.contains("resource") { + Some( + "The `resource` helper is provided by mdBook's HTML renderer. \ + Copy theme templates from the mdBook version you use to build (for example with `mdbook init`).", + ) + } else { + Some( + "A custom theme template uses a Handlebars helper that is not registered. \ + Copy theme files from the mdBook version you use to build (for example with `mdbook init`).", + ) + } +} + +#[cfg(test)] +mod render_hint_tests { + use super::missing_helper_hint; + + #[test] + fn hints_for_builtin_helpers() { + assert!(missing_helper_hint("Helper not found: fa").is_some()); + assert!(missing_helper_hint("Helper not found toc").is_some()); + assert!(missing_helper_hint("Helper not found resource").is_some()); + } + + #[test] + fn no_hint_for_other_errors() { + assert!(missing_helper_hint("syntax error").is_none()); + } +} diff --git a/tests/testsuite/rendering.rs b/tests/testsuite/rendering.rs index c128829835..6a29eeb215 100644 --- a/tests/testsuite/rendering.rs +++ b/tests/testsuite/rendering.rs @@ -71,6 +71,7 @@ fn fontawesome_error_message() { INFO Book building has started INFO Running the html backend ERROR Rendering failed +[TAB]Caused by: Error rendering `index` template [TAB]Caused by: Error rendering "index" line [..], col [..]: Unknown Font Awesome icon `github` for type `regular`. Hint: check the icon name and prefix (fas (solid), fab (brands), or far (regular)) at https://fontawesome.com/v6/search?m=free [TAB]Caused by: Unknown Font Awesome icon `github` for type `regular`. Hint: check the icon name and prefix (fas (solid), fab (brands), or far (regular)) at https://fontawesome.com/v6/search?m=free