use crate::book::{Book, BookItem}; use crate::renderer::html_handlebars::StaticFiles; use crate::renderer::html_handlebars::helpers; use crate::renderer::{RenderContext, Renderer}; use crate::theme::{self, Theme}; use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::HashMap; use std::fs::{self, File}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use anyhow::{Context, Result, bail}; use handlebars::Handlebars; use log::{debug, info, trace, warn}; use mdbook_core::config::{BookConfig, Code, Config, HtmlConfig, Playground, RustEdition}; use mdbook_core::utils; use mdbook_core::utils::fs::get_404_output_file; use mdbook_markdown::{render_markdown, render_markdown_with_path}; use regex::{Captures, Regex}; use serde_json::json; /// The HTML renderer for mdBook. #[derive(Default)] pub struct HtmlHandlebars; impl HtmlHandlebars { /// Returns a new instance of [`HtmlHandlebars`]. pub fn new() -> Self { HtmlHandlebars } fn render_item( &self, item: &BookItem, mut ctx: RenderItemContext<'_>, print_content: &mut String, ) -> Result<()> { // FIXME: This should be made DRY-er and rely less on mutable state let (ch, path) = match item { BookItem::Chapter(ch) if !ch.is_draft_chapter() => (ch, ch.path.as_ref().unwrap()), _ => return Ok(()), }; if let Some(ref edit_url_template) = ctx.html_config.edit_url_template { let full_path = ctx.book_config.src.to_str().unwrap_or_default().to_owned() + "/" + ch.source_path .clone() .unwrap_or_default() .to_str() .unwrap_or_default(); let edit_url = edit_url_template.replace("{path}", &full_path); ctx.data .insert("git_repository_edit_url".to_owned(), json!(edit_url)); } let content = render_markdown(&ch.content, ctx.html_config.smart_punctuation()); let fixed_content = render_markdown_with_path(&ch.content, ctx.html_config.smart_punctuation(), Some(path)); if !ctx.is_index && ctx.html_config.print.page_break { // Add page break between chapters // 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 // Add both two CSS properties because of the compatibility issue print_content .push_str(r#"
"#); } print_content.push_str(&fixed_content); // Update the context with data for this file let ctx_path = path .to_str() .with_context(|| "Could not convert path to str")?; let filepath = Path::new(&ctx_path).with_extension("html"); // "print.html" is used for the print page. if path == Path::new("print.md") { bail!("{} is reserved for internal use", path.display()); }; let book_title = ctx .data .get("book_title") .and_then(serde_json::Value::as_str) .unwrap_or(""); let title = if let Some(title) = ctx.chapter_titles.get(path) { title.clone() } else if book_title.is_empty() { ch.name.clone() } else { ch.name.clone() + " - " + book_title }; ctx.data.insert("path".to_owned(), json!(path)); ctx.data.insert("content".to_owned(), json!(content)); ctx.data.insert("chapter_title".to_owned(), json!(ch.name)); ctx.data.insert("title".to_owned(), json!(title)); ctx.data.insert( "path_to_root".to_owned(), json!(utils::fs::path_to_root(path)), ); if let Some(ref section) = ch.number { ctx.data .insert("section".to_owned(), json!(section.to_string())); } let redirects = collect_redirects_for_path(&filepath, &ctx.html_config.redirect)?; if !redirects.is_empty() { ctx.data.insert( "fragment_map".to_owned(), json!(serde_json::to_string(&redirects)?), ); } // 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.playground, &ctx.html_config.code, ctx.edition, ); // Write to file debug!("Creating {}", filepath.display()); utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?; if ctx.is_index { 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 = self.post_process( rendered_index, &ctx.html_config.playground, &ctx.html_config.code, ctx.edition, ); debug!("Creating index.html from {}", ctx_path); utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?; } Ok(()) } fn render_404( &self, ctx: &RenderContext, html_config: &HtmlConfig, src_dir: &Path, handlebars: &mut Handlebars<'_>, data: &mut serde_json::Map]+)class="([^"]+)"([^>]*)>"##).unwrap());
FIX_CODE_BLOCKS
.replace_all(html, |caps: &Captures<'_>| {
let before = &caps[1];
let classes = &caps[2].replace(',', " ");
let after = &caps[3];
format!(r#""#)
})
.into_owned()
}
static CODE_BLOCK_RE: LazyLock =
LazyLock::new(|| Regex::new(r##"((?s)]?class="([^"]+)".*?>(.*?))"##).unwrap());
fn add_playground_pre(
html: &str,
playground_config: &Playground,
edition: Option,
) -> String {
CODE_BLOCK_RE
.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("noplayground")
&& !classes.contains("noplaypen")
&& playground_config.runnable)
|| classes.contains("mdbook-runnable"))
{
let contains_e2015 = classes.contains("edition2015");
let contains_e2018 = classes.contains("edition2018");
let contains_e2021 = classes.contains("edition2021");
let edition_class = if contains_e2015 || contains_e2018 || contains_e2021 {
// the user forced edition, we should not overwrite it
""
} else {
match edition {
Some(RustEdition::E2015) => " edition2015",
Some(RustEdition::E2018) => " edition2018",
Some(RustEdition::E2021) => " edition2021",
Some(RustEdition::E2024) => " edition2024",
None => "",
}
};
// wrap the contents in an external pre block
format!(
"{}
",
classes,
edition_class,
{
let content: Cow<'_, str> = if playground_config.editable
&& classes.contains("editable")
|| text.contains("fn main")
|| text.contains("quick_main!")
{
code.into()
} else {
// we need to inject our own main
let (attrs, code) = partition_source(code);
format!("# #![allow(unused)]\n{attrs}# fn main() {{\n{code}# }}").into()
};
content
}
)
} else {
// not language-rust, so no-op
text.to_owned()
}
})
.into_owned()
}
/// Modifies all `` blocks to convert "hidden" lines and to wrap them in
/// a ``.
fn hide_lines(html: &str, code_config: &Code) -> String {
static LANGUAGE_REGEX: LazyLock =
LazyLock::new(|| Regex::new(r"\blanguage-(\w+)\b").unwrap());
static HIDELINES_REGEX: LazyLock =
LazyLock::new(|| Regex::new(r"\bhidelines=(\S+)").unwrap());
CODE_BLOCK_RE
.replace_all(html, |caps: &Captures<'_>| {
let text = &caps[1];
let classes = &caps[2];
let code = &caps[3];
if classes.contains("language-rust") {
format!(
"{}",
classes,
hide_lines_rust(code)
)
} else {
// First try to get the prefix from the code block
let hidelines_capture = HIDELINES_REGEX.captures(classes);
let hidelines_prefix = match &hidelines_capture {
Some(capture) => Some(&capture[1]),
None => {
// Then look up the prefix by language
LANGUAGE_REGEX.captures(classes).and_then(|capture| {
code_config.hidelines.get(&capture[1]).map(|p| p.as_str())
})
}
};
match hidelines_prefix {
Some(prefix) => format!(
"{}",
classes,
hide_lines_with_prefix(code, prefix)
),
None => text.to_owned(),
}
}
})
.into_owned()
}
fn hide_lines_rust(content: &str) -> String {
static BORING_LINES_REGEX: LazyLock =
LazyLock::new(|| Regex::new(r"^(\s*)#(.?)(.*)$").unwrap());
let mut result = String::with_capacity(content.len());
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
// Don't include newline on the last line.
let newline = if lines.peek().is_none() { "" } else { "\n" };
if let Some(caps) = BORING_LINES_REGEX.captures(line) {
if &caps[2] == "#" {
result += &caps[1];
result += &caps[2];
result += &caps[3];
result += newline;
continue;
} else if matches!(&caps[2], "" | " ") {
result += "";
result += &caps[1];
result += &caps[3];
result += newline;
result += "";
continue;
}
}
result += line;
result += newline;
}
result
}
fn hide_lines_with_prefix(content: &str, prefix: &str) -> String {
let mut result = String::with_capacity(content.len());
for line in content.lines() {
if line.trim_start().starts_with(prefix) {
let pos = line.find(prefix).unwrap();
let (ws, rest) = (&line[..pos], &line[pos + prefix.len()..]);
result += "";
result += ws;
result += rest;
result += "\n";
result += "";
continue;
}
result += line;
result += "\n";
}
result
}
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('\n');
} else {
before.push_str(line);
before.push('\n');
}
}
(before, after)
}
struct RenderItemContext<'a> {
handlebars: &'a Handlebars<'a>,
destination: PathBuf,
data: serde_json::Map,
is_index: bool,
book_config: BookConfig,
html_config: HtmlConfig,
edition: Option,
chapter_titles: &'a HashMap,
}
/// Redirect mapping.
///
/// The key is the source path (like `foo/bar.html`). The value is a tuple
/// `(destination_path, fragment_map)`. The `destination_path` is the page to
/// redirect to. `fragment_map` is the map of fragments that override the
/// destination. For example, a fragment `#foo` could redirect to any other
/// page or site.
type CombinedRedirects = BTreeMap)>;
fn combine_fragment_redirects(redirects: &HashMap) -> CombinedRedirects {
let mut combined: CombinedRedirects = BTreeMap::new();
// This needs to extract the fragments to generate the fragment map.
for (original, new) in redirects {
if let Some((source_path, source_fragment)) = original.rsplit_once('#') {
let e = combined.entry(source_path.to_string()).or_default();
if let Some(old) = e.1.insert(format!("#{source_fragment}"), new.clone()) {
log::error!(
"internal error: found duplicate fragment redirect \
{old} for {source_path}#{source_fragment}"
);
}
} else {
let e = combined.entry(original.to_string()).or_default();
e.0 = new.clone();
}
}
combined
}
/// Collects fragment redirects for an existing page.
///
/// The returned map has keys like `#foo` and the value is the new destination
/// path or URL.
fn collect_redirects_for_path(
path: &Path,
redirects: &HashMap,
) -> Result> {
let path = format!("/{}", path.display().to_string().replace('\\', "/"));
if redirects.contains_key(&path) {
bail!(
"redirect found for existing chapter at `{path}`\n\
Either delete the redirect or remove the chapter."
);
}
let key_prefix = format!("{path}#");
let map = redirects
.iter()
.filter_map(|(source, dest)| {
source
.strip_prefix(&key_prefix)
.map(|fragment| (format!("#{fragment}"), dest.to_string()))
})
.collect();
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_core::config::TextDirection;
use pretty_assertions::assert_eq;
#[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##"
"##,
),
(
"Hï
",
r##"Hï
"##,
),
(
"Foo
Foo
",
r##"Foo
Foo
"##,
),
// id only
(
r##"Foo
"##,
r##"Foo
"##,
),
// class only
(
r##"Foo
"##,
r##"Foo
"##,
),
// both id and class
(
r##"Foo
"##,
r##"Foo
"##,
),
];
for (src, should_be) in inputs {
let got = build_header_links(src);
assert_eq!(got, should_be);
}
}
#[test]
fn add_playground() {
let inputs = [
(
"x()",
"# #![allow(unused)]\n# fn main() {\nx()\n# }
",
),
(
"fn main() {}",
"fn main() {}
",
),
(
"let s = \"foo\n # bar\n\";",
"let s = \"foo\n # bar\n\";
",
),
(
"let s = \"foo\n ## bar\n\";",
"let s = \"foo\n ## bar\n\";
",
),
(
"let s = \"foo\n # bar\n#\n\";",
"let s = \"foo\n # bar\n#\n\";
",
),
(
"let s = \"foo\n # bar\n\";",
"let s = \"foo\n # bar\n\";",
),
(
"#![no_std]\nlet s = \"foo\";\n #[some_attr]",
"#![no_std]\nlet s = \"foo\";\n #[some_attr]
",
),
];
for (src, should_be) in &inputs {
let got = add_playground_pre(
src,
&Playground {
editable: true,
..Playground::default()
},
None,
);
assert_eq!(&*got, *should_be);
}
}
#[test]
fn add_playground_edition2015() {
let inputs = [
(
"x()",
"# #![allow(unused)]\n# fn main() {\nx()\n# }
",
),
(
"fn main() {}",
"fn main() {}
",
),
(
"fn main() {}",
"fn main() {}
",
),
(
"fn main() {}",
"fn main() {}
",
),
];
for (src, should_be) in &inputs {
let got = add_playground_pre(
src,
&Playground {
editable: true,
..Playground::default()
},
Some(RustEdition::E2015),
);
assert_eq!(&*got, *should_be);
}
}
#[test]
fn add_playground_edition2018() {
let inputs = [
(
"x()",
"# #![allow(unused)]\n# fn main() {\nx()\n# }
",
),
(
"fn main() {}",
"fn main() {}
",
),
(
"fn main() {}",
"fn main() {}
",
),
(
"fn main() {}",
"fn main() {}
",
),
];
for (src, should_be) in &inputs {
let got = add_playground_pre(
src,
&Playground {
editable: true,
..Playground::default()
},
Some(RustEdition::E2018),
);
assert_eq!(&*got, *should_be);
}
}
#[test]
fn add_playground_edition2021() {
let inputs = [
(
"x()",
"# #![allow(unused)]\n# fn main() {\nx()\n# }
",
),
(
"fn main() {}",
"fn main() {}
",
),
(
"fn main() {}",
"fn main() {}
",
),
(
"fn main() {}",
"fn main() {}
",
),
];
for (src, should_be) in &inputs {
let got = add_playground_pre(
src,
&Playground {
editable: true,
..Playground::default()
},
Some(RustEdition::E2021),
);
assert_eq!(&*got, *should_be);
}
}
#[test]
fn hide_lines_language_rust() {
let inputs = [
(
"\n# #![allow(unused)]\n# fn main() {\nx()\n# }
",
"\n#![allow(unused)]\nfn main() {\nx()\n}
",
),
// # must be followed by a space for a line to be hidden
(
"\n#fn main() {\nx()\n#}
",
"\n#fn main() {\nx()\n#}
",
),
(
"fn main() {}
",
"fn main() {}
",
),
(
"let s = \"foo\n # bar\n\";
",
"let s = \"foo\n bar\n\";
",
),
(
"let s = \"foo\n ## bar\n\";
",
"let s = \"foo\n # bar\n\";
",
),
(
"let s = \"foo\n # bar\n#\n\";
",
"let s = \"foo\n bar\n\n\";
",
),
(
"let s = \"foo\n # bar\n\";",
"let s = \"foo\n bar\n\";",
),
(
"#![no_std]\nlet s = \"foo\";\n #[some_attr]
",
"#![no_std]\nlet s = \"foo\";\n #[some_attr]
",
),
];
for (src, should_be) in &inputs {
let got = hide_lines(src, &Code::default());
assert_eq!(&*got, *should_be);
}
}
#[test]
fn hide_lines_language_other() {
let inputs = [
(
"~hidden()\nnothidden():\n~ hidden()\n ~hidden()\n nothidden()",
"hidden()\nnothidden():\n hidden()\n hidden()\n nothidden()\n",
),
(
"!!!hidden()\nnothidden():\n!!! hidden()\n !!!hidden()\n nothidden()",
"hidden()\nnothidden():\n hidden()\n hidden()\n nothidden()\n",
),
];
for (src, should_be) in &inputs {
let got = hide_lines(
src,
&Code {
hidelines: {
let mut map = HashMap::new();
map.insert("python".to_string(), "~".to_string());
map
},
},
);
assert_eq!(&*got, *should_be);
}
}
#[test]
fn test_json_direction() {
assert_eq!(json!(TextDirection::RightToLeft), json!("rtl"));
assert_eq!(json!(TextDirection::LeftToRight), json!("ltr"));
}
}