diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 1520aa36c0..680729d8f9 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -1,19 +1,15 @@ use crate::book::{Book, BookItem}; -use crate::config::{Config, HtmlConfig, Playpen}; +use crate::config::{Config, HtmlConfig}; use crate::errors::*; -use crate::renderer::html_handlebars::helpers; +use crate::renderer::html_handlebars::hbs_wrapper::{HbsConfig, HbsWrapper}; use crate::renderer::{RenderContext, Renderer}; use crate::theme::{self, playpen_editor, Theme}; use crate::utils; use std::collections::BTreeMap; -use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use handlebars::Handlebars; -use regex::{Captures, Regex}; - #[derive(Default)] pub struct HtmlHandlebars; @@ -76,9 +72,9 @@ impl HtmlHandlebars { // Render the handlebars template with the data debug!("Render template"); - let rendered = ctx.handlebars.render("index", &ctx.data)?; - - let rendered = self.post_process(rendered, &ctx.html_config.playpen); + let rendered = ctx + .handlebars + .render("index", &ctx.data, &ctx.html_config.playpen)?; // Write to file debug!("Creating {}", filepath.display()); @@ -87,8 +83,9 @@ impl HtmlHandlebars { if ctx.is_index { ctx.data.insert("path".to_owned(), json!("index.md")); ctx.data.insert("path_to_root".to_owned(), json!("")); - let rendered_index = ctx.handlebars.render("index", &ctx.data)?; - let rendered_index = self.post_process(rendered_index, &ctx.html_config.playpen); + let rendered_index = + ctx.handlebars + .render("index", &ctx.data, &ctx.html_config.playpen)?; debug!("Creating index.html from {}", path); utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?; } @@ -97,15 +94,6 @@ impl HtmlHandlebars { Ok(()) } - #[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))] - fn post_process(&self, rendered: String, playpen_config: &Playpen) -> String { - let rendered = build_header_links(&rendered); - let rendered = fix_code_blocks(&rendered); - let rendered = add_playpen_pre(&rendered, playpen_config); - - rendered - } - fn copy_static_files( &self, destination: &Path, @@ -204,18 +192,6 @@ impl HtmlHandlebars { ); } - fn register_hbs_helpers(&self, handlebars: &mut Handlebars, html_config: &HtmlConfig) { - handlebars.register_helper( - "toc", - Box::new(helpers::toc::RenderToc { - no_section_label: html_config.no_section_label, - }), - ); - handlebars.register_helper("previous", Box::new(helpers::navigation::previous)); - handlebars.register_helper("next", Box::new(helpers::navigation::next)); - handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option)); - } - /// Copy across any additional CSS and JavaScript files which the book /// has been configured to use. fn copy_additional_css_and_js( @@ -285,8 +261,6 @@ impl Renderer for HtmlHandlebars { let book = &ctx.book; trace!("render"); - let mut handlebars = Handlebars::new(); - let theme_dir = match html_config.theme { Some(ref theme) => theme.to_path_buf(), None => ctx.root.join("theme"), @@ -303,15 +277,12 @@ impl Renderer for HtmlHandlebars { } let theme = theme::Theme::new(theme_dir); - - debug!("Register the index handlebars template"); - handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?; - - debug!("Register the header handlebars template"); - handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?; - - debug!("Register handlebars helpers"); - self.register_hbs_helpers(&mut handlebars, &html_config); + let config = HbsConfig { + index_template: String::from_utf8(theme.index.clone())?, + header_template: String::from_utf8(theme.header.clone())?, + no_section_label: html_config.no_section_label, + }; + let handlebars = HbsWrapper::with_config(config)?; let mut data = make_data(&ctx.root, &book, &ctx.config, &html_config)?; @@ -342,9 +313,7 @@ impl Renderer for HtmlHandlebars { // Render the handlebars template with the data debug!("Render template"); - let rendered = handlebars.render("index", &data)?; - - let rendered = self.post_process(rendered, &html_config.playpen); + let rendered = handlebars.render("index", &data, &html_config.playpen)?; utils::fs::write_file(&destination, "print.html", rendered.as_bytes())?; debug!("Creating print.html ✓"); @@ -499,177 +468,10 @@ fn make_data( Ok(data) } -/// Goes through the rendered HTML, making sure all header tags have -/// an anchor respectively so people can link to sections directly. -fn build_header_links(html: &str) -> String { - let regex = Regex::new(r"(.*?)").unwrap(); - let mut id_counter = HashMap::new(); - - regex - .replace_all(html, |caps: &Captures<'_>| { - let level = caps[1] - .parse() - .expect("Regex should ensure we only ever get numbers here"); - - insert_link_into_header(level, &caps[2], &mut id_counter) - }) - .into_owned() -} - -/// Insert a sinle link into a header, making sure each link gets its own -/// unique ID by appending an auto-incremented number (if necessary). -fn insert_link_into_header( - level: usize, - content: &str, - id_counter: &mut HashMap, -) -> String { - let raw_id = utils::id_from_content(content); - - let id_count = id_counter.entry(raw_id.clone()).or_insert(0); - - let id = match *id_count { - 0 => raw_id, - other => format!("{}-{}", raw_id, other), - }; - - *id_count += 1; - - format!( - r##"{text}"##, - level = level, - id = id, - text = content - ) -} - -// The rust book uses annotations for rustdoc to test code snippets, -// like the following: -// ```rust,should_panic -// fn main() { -// // Code here -// } -// ``` -// This function replaces all commas by spaces in the code block classes -fn fix_code_blocks(html: &str) -> String { - let regex = Regex::new(r##"]+)class="([^"]+)"([^>]*)>"##).unwrap(); - regex - .replace_all(html, |caps: &Captures<'_>| { - let before = &caps[1]; - let classes = &caps[2].replace(",", " "); - let after = &caps[3]; - - format!( - r#""#, - before = before, - classes = classes, - after = after - ) - }) - .into_owned() -} - -fn add_playpen_pre(html: &str, playpen_config: &Playpen) -> String { - let regex = Regex::new(r##"((?s)]?class="([^"]+)".*?>(.*?))"##).unwrap(); - regex - .replace_all(html, |caps: &Captures<'_>| { - let text = &caps[1]; - let classes = &caps[2]; - let code = &caps[3]; - - if (classes.contains("language-rust") - && !classes.contains("ignore") - && !classes.contains("noplaypen")) - || classes.contains("mdbook-runnable") - { - // wrap the contents in an external pre block - if playpen_config.editable && classes.contains("editable") - || text.contains("fn main") - || text.contains("quick_main!") - { - format!("
{}
", text) - } else { - // we need to inject our own main - let (attrs, code) = partition_source(code); - - format!( - "
\n# \
-                         #![allow(unused_variables)]\n{}#fn main() {{\n{}#}}
", - classes, attrs, code - ) - } - } else { - // not language-rust, so no-op - text.to_owned() - } - }) - .into_owned() -} - -fn partition_source(s: &str) -> (String, String) { - let mut after_header = false; - let mut before = String::new(); - let mut after = String::new(); - - for line in s.lines() { - let trimline = line.trim(); - let header = trimline.chars().all(char::is_whitespace) || trimline.starts_with("#!["); - if !header || after_header { - after_header = true; - after.push_str(line); - after.push_str("\n"); - } else { - before.push_str(line); - before.push_str("\n"); - } - } - - (before, after) -} - struct RenderItemContext<'a> { - handlebars: &'a Handlebars, + handlebars: &'a HbsWrapper, destination: PathBuf, data: serde_json::Map, is_index: bool, html_config: HtmlConfig, } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn original_build_header_links() { - let inputs = vec![ - ( - "blah blah

Foo

", - r##"blah blah

Foo

"##, - ), - ( - "

Foo

", - r##"

Foo

"##, - ), - ( - "

Foo^bar

", - r##"

Foo^bar

"##, - ), - ( - "

", - r##"

"##, - ), - ( - "

", - r##"

"##, - ), - ( - "

Foo

Foo

", - r##"

Foo

Foo

"##, - ), - ]; - - for (src, should_be) in inputs { - let got = build_header_links(&src); - assert_eq!(got, should_be); - } - } -} diff --git a/src/renderer/html_handlebars/hbs_wrapper.rs b/src/renderer/html_handlebars/hbs_wrapper.rs new file mode 100644 index 0000000000..1f3d84d106 --- /dev/null +++ b/src/renderer/html_handlebars/hbs_wrapper.rs @@ -0,0 +1,278 @@ +use super::helpers; +use crate::config::Playpen; +use crate::errors::Result; +use crate::utils; +use handlebars::Handlebars; +use regex::{Captures, Regex}; +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Debug)] +pub struct HbsConfig { + pub index_template: String, + pub header_template: String, + pub no_section_label: bool, +} + +#[derive(Debug)] +pub struct HbsWrapper { + handlebars: Handlebars, +} + +impl HbsWrapper { + /// Factory function for create configured handlebars + pub fn with_config(cfg: HbsConfig) -> Result { + let mut handlebars = Handlebars::new(); + debug!("Register the index handlebars template"); + handlebars.register_template_string("index", cfg.index_template)?; + + debug!("Register the header handlebars template"); + handlebars.register_partial("header", cfg.header_template)?; + + debug!("Register handlebars helpers"); + + handlebars.register_helper( + "toc", + Box::new(helpers::toc::RenderToc { + no_section_label: cfg.no_section_label, + }), + ); + handlebars.register_helper("previous", Box::new(helpers::navigation::previous)); + handlebars.register_helper("next", Box::new(helpers::navigation::next)); + handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option)); + + Ok(HbsWrapper { handlebars }) + } + + pub fn render(&self, name: &str, data: &T, playpen: &Playpen) -> Result + where + T: Serialize, + { + let rendered = self.handlebars.render(name, data)?; + let rendered = post_process(rendered, &playpen); + + Ok(rendered) + } +} + +#[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))] +fn post_process(rendered: String, playpen_config: &Playpen) -> String { + let rendered = build_header_links(&rendered); + let rendered = fix_code_blocks(&rendered); + let rendered = add_playpen_pre(&rendered, playpen_config); + + rendered +} + +/// Goes through the rendered HTML, making sure all header tags have +/// an anchor respectively so people can link to sections directly. +fn build_header_links(html: &str) -> String { + let regex = Regex::new(r"(.*?)").unwrap(); + let mut id_counter = HashMap::new(); + + regex + .replace_all(html, |caps: &Captures<'_>| { + let level = caps[1] + .parse() + .expect("Regex should ensure we only ever get numbers here"); + + insert_link_into_header(level, &caps[2], &mut id_counter) + }) + .into_owned() +} + +/// Insert a single link into a header, making sure each link gets its own +/// unique ID by appending an auto-incremented number (if necessary). +fn insert_link_into_header( + level: usize, + content: &str, + id_counter: &mut HashMap, +) -> String { + let raw_id = utils::id_from_content(content); + + let id_count = id_counter.entry(raw_id.clone()).or_insert(0); + + let id = match *id_count { + 0 => raw_id, + other => format!("{}-{}", raw_id, other), + }; + + *id_count += 1; + + format!( + r##"{text}"##, + level = level, + id = id, + text = content + ) +} + +// The rust book uses annotations for rustdoc to test code snippets, +// like the following: +// ```rust,should_panic +// fn main() { +// // Code here +// } +// ``` +// This function replaces all commas by spaces in the code block classes +fn fix_code_blocks(html: &str) -> String { + let regex = Regex::new(r##"]+)class="([^"]+)"([^>]*)>"##).unwrap(); + regex + .replace_all(html, |caps: &Captures<'_>| { + let before = &caps[1]; + let classes = &caps[2].replace(",", " "); + let after = &caps[3]; + + format!( + r#""#, + before = before, + classes = classes, + after = after + ) + }) + .into_owned() +} + +fn add_playpen_pre(html: &str, playpen_config: &Playpen) -> String { + let regex = Regex::new(r##"((?s)]?class="([^"]+)".*?>(.*?))"##).unwrap(); + regex + .replace_all(html, |caps: &Captures<'_>| { + let text = &caps[1]; + let classes = &caps[2]; + let code = &caps[3]; + + if (classes.contains("language-rust") + && !classes.contains("ignore") + && !classes.contains("noplaypen")) + || classes.contains("mdbook-runnable") + { + // wrap the contents in an external pre block + if playpen_config.editable && classes.contains("editable") + || text.contains("fn main") + || text.contains("quick_main!") + { + format!("
{}
", text) + } else { + // we need to inject our own main + let (attrs, code) = partition_source(code); + + format!( + "
\n# \
+                         #![allow(unused_variables)]\n{}#fn main() {{\n{}#}}
", + classes, attrs, code + ) + } + } else { + // not language-rust, so no-op + text.to_owned() + } + }) + .into_owned() +} + +/// Split source code to module-level attributes (from head of text) and rest text +fn partition_source(s: &str) -> (String, String) { + let mut after_header = false; + let mut before = String::new(); + let mut after = String::new(); + + for line in s.lines() { + let trimline = line.trim(); + let header = trimline.is_empty() || trimline.starts_with("#!["); + if !header || after_header { + after_header = true; + after.push_str(line); + after.push_str("\n"); + } else { + before.push_str(line); + before.push_str("\n"); + } + } + + (before, after) +} + +#[cfg(test)] +mod partition_source_tests { + use super::partition_source; + + macro_rules! run_tests { + ($($name:ident, $initial:expr => ($before:expr, $after:expr)),+) => { + $( + #[test] + fn $name() { + let sources = partition_source($initial); + assert_eq!(sources, (String::from($before), String::from($after))); + } + )+ + }; + } + + run_tests!( + without_header, "some text" => ("", "some text\n"), + two_string_without_header, "some\ntext" => ("", "some\ntext\n"), + three_string_separated_by_empty_string, "some\n\ntext" => ("", "some\n\ntext\n"), + + some_empty_lines_before_text, "\n\n\nsome text" => ("\n\n\n", "some text\n"), + + only_header, "#![allow(unused_variables)]" => ( + "#![allow(unused_variables)]\n", + "" + ), + + one_line_header, "#![allow(unused_variables)]\n#fn main() {\nlet a = 5;\n#}" => ( + "#![allow(unused_variables)]\n", + "#fn main() {\nlet a = 5;\n#}\n" + ), + + multiline_header, "#![allow(unused_variables)]\n#![allow(missing_docs)]\n#fn main() {\nlet a = 5;\n#}" => ( + "#![allow(unused_variables)]\n#![allow(missing_docs)]\n", + "#fn main() {\nlet a = 5;\n#}\n" + ), + + multiline_header_with_empty_string, "#![allow(unused_variables)]\n\n#![allow(missing_docs)]\n#fn main() {\nlet a = 5;\n#}" => ( + "#![allow(unused_variables)]\n\n#![allow(missing_docs)]\n", + "#fn main() {\nlet a = 5;\n#}\n" + ) + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn original_build_header_links() { + let inputs = vec![ + ( + "blah blah

Foo

", + r##"blah blah

Foo

"##, + ), + ( + "

Foo

", + r##"

Foo

"##, + ), + ( + "

Foo^bar

", + r##"

Foo^bar

"##, + ), + ( + "

", + r##"

"##, + ), + ( + "

", + r##"

"##, + ), + ( + "

Foo

Foo

", + r##"

Foo

Foo

"##, + ), + ]; + + for (src, should_be) in inputs { + let got = build_header_links(&src); + assert_eq!(got, should_be); + } + } +} diff --git a/src/renderer/html_handlebars/mod.rs b/src/renderer/html_handlebars/mod.rs index f1155ed759..ffd718b1ac 100644 --- a/src/renderer/html_handlebars/mod.rs +++ b/src/renderer/html_handlebars/mod.rs @@ -3,6 +3,7 @@ pub use self::hbs_renderer::HtmlHandlebars; mod hbs_renderer; +mod hbs_wrapper; mod helpers; #[cfg(feature = "search")]