use super::helpers; use super::static_files::StaticFiles; use crate::theme::Theme; use crate::utils::ToUrlPath; use anyhow::{Context, Result, bail}; use handlebars::Handlebars; use mdbook_core::book::{Book, BookItem, Chapter}; use mdbook_core::config::{BookConfig, Code, Config, HtmlConfig, Playground, RustEdition}; use mdbook_core::utils::fs::get_404_output_file; use mdbook_core::{static_regex, utils}; use mdbook_markdown::render_markdown; use mdbook_renderer::{RenderContext, Renderer}; use regex::Captures; use serde_json::json; use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::HashMap; use std::fs::{self, File}; use std::path::{Path, PathBuf}; use tracing::error; use tracing::{debug, info, trace, warn}; /// The HTML renderer for mdBook. #[derive(Default)] #[non_exhaustive] pub struct HtmlHandlebars; impl HtmlHandlebars { /// Returns a new instance of [`HtmlHandlebars`]. pub fn new() -> Self { HtmlHandlebars } fn render_chapter( &self, ch: &Chapter, prev_ch: Option<&Chapter>, next_ch: Option<&Chapter>, mut ctx: RenderChapterContext<'_>, print_content: &mut String, ) -> Result<()> { // FIXME: This should be made DRY-er and rely less on mutable state let path = ch.path.as_ref().unwrap(); 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 mut options = crate::html_render_options_from_config(path, &ctx.html_config); let content = render_markdown(&ch.content, &options); options.for_print = true; let fixed_content = render_markdown(&ch.content, &options); if prev_ch.is_some() && 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)?), ); } let mut nav = |name: &str, ch: Option<&Chapter>| { let Some(ch) = ch else { return }; let path = ch .path .as_ref() .unwrap() .with_extension("html") .to_url_path(); let obj = json!( { "title": ch.name, "link": path, }); ctx.data.insert(name.to_string(), obj); }; nav("previous", prev_ch); nav("next", next_ch); // 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 prev_ch.is_none() { 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="([^"]+)"([^>]*)>"#);
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_regex!(
CODE_BLOCK_RE,
r#"((?s)]?class="([^"]+)".*?>(.*?))"#
);
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",
Some(_) => panic!("edition {edition:?} not covered"),
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_rust_source(code);
let newline = if code.is_empty() || code.ends_with('\n') {
""
} else {
"\n"
};
format!(
"# #![allow(unused)]\n{attrs}# fn main() {{\n{code}{newline}# }}"
)
.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_regex!(LANGUAGE_REGEX, r"\blanguage-(\w+)\b");
static_regex!(HIDELINES_REGEX, r"\bhidelines=(\S+)");
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_regex!(BORING_LINES_REGEX, r"^(\s*)#(.?)(.*)$");
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
}
/// Splits Rust inner attributes from the given source string.
///
/// Returns `(inner_attrs, rest_of_code)`.
fn partition_rust_source(s: &str) -> (&str, &str) {
static_regex!(
HEADER_RE,
r"^(?mx)
(
(?:
^[ \t]*\#!\[.* (?:\r?\n)?
|
^\s* (?:\r?\n)?
)*
)"
);
let split_idx = match HEADER_RE.captures(s) {
Some(caps) => caps[1].len(),
None => 0,
};
s.split_at(split_idx)
}
struct RenderChapterContext<'a> {
handlebars: &'a Handlebars<'a>,
destination: PathBuf,
data: serde_json::Map,
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()) {
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.to_url_path());
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 mut p = Playground::default();
p.editable = true;
let got = add_playground_pre(src, &p, 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 mut p = Playground::default();
p.editable = true;
let got = add_playground_pre(src, &p, 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 mut p = Playground::default();
p.editable = true;
let got = add_playground_pre(src, &p, 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 mut p = Playground::default();
p.editable = true;
let got = add_playground_pre(src, &p, 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",
),
];
let mut code = Code::default();
code.hidelines.insert("python".to_string(), "~".to_string());
for (src, should_be) in &inputs {
let got = hide_lines(src, &code);
assert_eq!(&*got, *should_be);
}
}
#[test]
fn test_json_direction() {
assert_eq!(json!(TextDirection::RightToLeft), json!("rtl"));
assert_eq!(json!(TextDirection::LeftToRight), json!("ltr"));
}
#[test]
fn it_partitions_rust_source() {
assert_eq!(partition_rust_source(""), ("", ""));
assert_eq!(partition_rust_source("let x = 1;"), ("", "let x = 1;"));
assert_eq!(
partition_rust_source("fn main()\n{ let x = 1; }\n"),
("", "fn main()\n{ let x = 1; }\n")
);
assert_eq!(
partition_rust_source("#![allow(foo)]"),
("#![allow(foo)]", "")
);
assert_eq!(
partition_rust_source("#![allow(foo)]\n"),
("#![allow(foo)]\n", "")
);
assert_eq!(
partition_rust_source("#![allow(foo)]\nlet x = 1;"),
("#![allow(foo)]\n", "let x = 1;")
);
assert_eq!(
partition_rust_source(
"\n\
#![allow(foo)]\n\
\n\
#![allow(bar)]\n\
\n\
let x = 1;"
),
("\n#![allow(foo)]\n\n#![allow(bar)]\n\n", "let x = 1;")
);
}
}