diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 080b12da..a591e86f 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -528,6 +528,9 @@ impl Renderer for HtmlHandlebars { debug!("Register the header handlebars template"); handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?; + debug!("Register the toc handlebars template"); + handlebars.register_template_string("toc", String::from_utf8(theme.toc.clone())?)?; + debug!("Register handlebars helpers"); self.register_hbs_helpers(&mut handlebars, &html_config); @@ -583,6 +586,13 @@ impl Renderer for HtmlHandlebars { debug!("Creating print.html ✓"); } + debug!("Render toc.js"); + { + let rendered_toc = handlebars.render("toc", &data)?; + utils::fs::write_file(destination, "toc.js", rendered_toc.as_bytes())?; + debug!("Creating toc.js ✓"); + } + debug!("Copy static files"); self.copy_static_files(destination, &theme, &html_config) .with_context(|| "Unable to copy across static files")?; diff --git a/src/renderer/html_handlebars/helpers/toc.rs b/src/renderer/html_handlebars/helpers/toc.rs index aabea028..783161ce 100644 --- a/src/renderer/html_handlebars/helpers/toc.rs +++ b/src/renderer/html_handlebars/helpers/toc.rs @@ -1,8 +1,7 @@ use std::path::Path; use std::{cmp::Ordering, collections::BTreeMap}; -use crate::utils; -use crate::utils::bracket_escape; +use crate::utils::special_escape; use handlebars::{ Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason, @@ -32,21 +31,6 @@ impl HelperDef for RenderToc { RenderErrorReason::Other("Could not decode the JSON data".to_owned()).into() }) })?; - let current_path = rc - .evaluate(ctx, "@root/path")? - .as_json() - .as_str() - .ok_or_else(|| { - RenderErrorReason::Other("Type error for `path`, string expected".to_owned()) - })? - .replace('\"', ""); - - let current_section = rc - .evaluate(ctx, "@root/section")? - .as_json() - .as_str() - .map(str::to_owned) - .unwrap_or_default(); let fold_enable = rc .evaluate(ctx, "@root/fold_enable")? @@ -67,28 +51,17 @@ impl HelperDef for RenderToc { out.write("
    ")?; let mut current_level = 1; - // The "index" page, which has this attribute set, is supposed to alias the first chapter in - // the book, i.e. the first link. There seems to be no easy way to determine which chapter - // the "index" is aliasing from within the renderer, so this is used instead to force the - // first link to be active. See further below. - let mut is_first_chapter = ctx.data().get("is_index").is_some(); for item in chapters { - let (section, level) = if let Some(s) = item.get("section") { + let (_section, level) = if let Some(s) = item.get("section") { (s.as_str(), s.matches('.').count()) } else { ("", 1) }; - let is_expanded = - if !fold_enable || (!section.is_empty() && current_section.starts_with(section)) { - // Expand if folding is disabled, or if the section is an - // ancestor or the current section itself. - true - } else { - // Levels that are larger than this would be folded. - level - 1 < fold_level as usize - }; + // Expand if folding is disabled, or if levels that are larger than this would not + // be folded. + let is_expanded = !fold_enable || level - 1 < (fold_level as usize); match level.cmp(¤t_level) { Ordering::Greater => { @@ -121,7 +94,7 @@ impl HelperDef for RenderToc { // Part title if let Some(title) = item.get("part") { out.write("
  1. ")?; - out.write(&bracket_escape(title))?; + out.write(&special_escape(title))?; out.write("
  2. ")?; continue; } @@ -139,16 +112,8 @@ impl HelperDef for RenderToc { .replace('\\', "/"); // Add link - out.write(&utils::fs::path_to_root(¤t_path))?; out.write(&tmp)?; - out.write("\"")?; - - if path == ¤t_path || is_first_chapter { - is_first_chapter = false; - out.write(" class=\"active\"")?; - } - - out.write(">")?; + out.write("\">")?; path_exists = true; } _ => { @@ -167,7 +132,7 @@ impl HelperDef for RenderToc { } if let Some(name) = item.get("name") { - out.write(&bracket_escape(name))? + out.write(&special_escape(name))? } if path_exists { diff --git a/src/theme/index.hbs b/src/theme/index.hbs index 080b7851..fb6c10b2 100644 --- a/src/theme/index.hbs +++ b/src/theme/index.hbs @@ -109,35 +109,14 @@ - - +
    diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 1c108d62..bbaeaa44 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -17,6 +17,7 @@ pub static INDEX: &[u8] = include_bytes!("index.hbs"); pub static HEAD: &[u8] = include_bytes!("head.hbs"); pub static REDIRECT: &[u8] = include_bytes!("redirect.hbs"); pub static HEADER: &[u8] = include_bytes!("header.hbs"); +pub static TOC: &[u8] = include_bytes!("toc.js.hbs"); pub static CHROME_CSS: &[u8] = include_bytes!("css/chrome.css"); pub static GENERAL_CSS: &[u8] = include_bytes!("css/general.css"); pub static PRINT_CSS: &[u8] = include_bytes!("css/print.css"); @@ -50,6 +51,7 @@ pub struct Theme { pub head: Vec, pub redirect: Vec, pub header: Vec, + pub toc: Vec, pub chrome_css: Vec, pub general_css: Vec, pub print_css: Vec, @@ -85,6 +87,7 @@ impl Theme { (theme_dir.join("head.hbs"), &mut theme.head), (theme_dir.join("redirect.hbs"), &mut theme.redirect), (theme_dir.join("header.hbs"), &mut theme.header), + (theme_dir.join("toc.js.hbs"), &mut theme.toc), (theme_dir.join("book.js"), &mut theme.js), (theme_dir.join("css/chrome.css"), &mut theme.chrome_css), (theme_dir.join("css/general.css"), &mut theme.general_css), @@ -174,6 +177,7 @@ impl Default for Theme { head: HEAD.to_owned(), redirect: REDIRECT.to_owned(), header: HEADER.to_owned(), + toc: TOC.to_owned(), chrome_css: CHROME_CSS.to_owned(), general_css: GENERAL_CSS.to_owned(), print_css: PRINT_CSS.to_owned(), @@ -232,6 +236,7 @@ mod tests { "head.hbs", "redirect.hbs", "header.hbs", + "toc.js.hbs", "favicon.png", "favicon.svg", "css/chrome.css", @@ -263,6 +268,7 @@ mod tests { head: Vec::new(), redirect: Vec::new(), header: Vec::new(), + toc: Vec::new(), chrome_css: Vec::new(), general_css: Vec::new(), print_css: Vec::new(), diff --git a/src/theme/toc.js.hbs b/src/theme/toc.js.hbs new file mode 100644 index 00000000..eb48c8ba --- /dev/null +++ b/src/theme/toc.js.hbs @@ -0,0 +1,54 @@ +// Populate the sidebar +// +// This is a script, and not included directly in the page, to control the total size of the book. +// The TOC contains an entry for each page, so if each page includes a copy of the TOC, +// the total size of the page becomes O(n**2). +var sidebarScrollbox = document.querySelector("#sidebar .sidebar-scrollbox"); +sidebarScrollbox.innerHTML = '{{#toc}}{{/toc}}'; +(function() { + let current_page = document.location.href.toString(); + if (current_page.endsWith("/")) { + current_page += "index.html"; + } + var links = sidebarScrollbox.querySelectorAll("a"); + var l = links.length; + for (var i = 0; i < l; ++i) { + var link = links[i]; + var href = link.getAttribute("href"); + if (href && !href.startsWith("#") && !/^(?:[a-z+]+:)?\/\//.test(href)) { + link.href = path_to_root + href; + } + // The "index" page is supposed to alias the first chapter in the book. + if (link.href === current_page || (i === 0 && path_to_root === "" && current_page.endsWith("/index.html"))) { + link.classList.add("active"); + var parent = link.parentElement; + while (parent) { + if (parent.tagName === "LI" && parent.previousElementSibling) { + if (parent.previousElementSibling.classList.contains("chapter-item")) { + parent.previousElementSibling.classList.add("expanded"); + } + } + parent = parent.parentElement; + } + } + } +})(); + +// Track and set sidebar scroll position +sidebarScrollbox.addEventListener('click', function(e) { + if (e.target.tagName === 'A') { + sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop); + } +}, { passive: true }); +var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll'); +sessionStorage.removeItem('sidebar-scroll'); +if (sidebarScrollTop) { + // preserve sidebar scroll position when navigating via links within sidebar + sidebarScrollbox.scrollTop = sidebarScrollTop; +} else { + // scroll sidebar to current active section when navigating via "next/previous chapter" buttons + var activeSection = document.querySelector('#sidebar .active'); + if (activeSection) { + activeSection.scrollIntoView({ block: 'center' }); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 2b17cc7d..f8215fed 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -265,6 +265,25 @@ pub fn log_backtrace(e: &Error) { } } +pub(crate) fn special_escape(mut s: &str) -> String { + let mut escaped = String::with_capacity(s.len()); + let needs_escape: &[char] = &['<', '>', '\'', '\\', '&']; + while let Some(next) = s.find(needs_escape) { + escaped.push_str(&s[..next]); + match s.as_bytes()[next] { + b'<' => escaped.push_str("<"), + b'>' => escaped.push_str(">"), + b'\'' => escaped.push_str("'"), + b'\\' => escaped.push_str("\"), + b'&' => escaped.push_str("&"), + _ => unreachable!(), + } + s = &s[next + 1..]; + } + escaped.push_str(s); + escaped +} + pub(crate) fn bracket_escape(mut s: &str) -> String { let mut escaped = String::with_capacity(s.len()); let needs_escape: &[char] = &['<', '>']; @@ -283,7 +302,7 @@ pub(crate) fn bracket_escape(mut s: &str) -> String { #[cfg(test)] mod tests { - use super::bracket_escape; + use super::{bracket_escape, special_escape}; mod render_markdown { use super::super::render_markdown; @@ -506,5 +525,20 @@ more text with spaces assert_eq!(bracket_escape("<>"), "<>"); assert_eq!(bracket_escape(""), "<test>"); assert_eq!(bracket_escape("ab"), "a<test>b"); + assert_eq!(bracket_escape("'"), "'"); + assert_eq!(bracket_escape("\\"), "\\"); + } + + #[test] + fn escaped_special() { + assert_eq!(special_escape(""), ""); + assert_eq!(special_escape("<"), "<"); + assert_eq!(special_escape(">"), ">"); + assert_eq!(special_escape("<>"), "<>"); + assert_eq!(special_escape(""), "<test>"); + assert_eq!(special_escape("ab"), "a<test>b"); + assert_eq!(special_escape("'"), "'"); + assert_eq!(special_escape("\\"), "\"); + assert_eq!(special_escape("&"), "&"); } } diff --git a/tests/rendered_output.rs b/tests/rendered_output.rs index a01ce5f4..3756af11 100644 --- a/tests/rendered_output.rs +++ b/tests/rendered_output.rs @@ -61,28 +61,6 @@ fn by_default_mdbook_generates_rendered_content_in_the_book_directory() { assert!(index_file.exists()); } -#[test] -fn make_sure_bottom_level_files_contain_links_to_chapters() { - let temp = DummyBook::new().build().unwrap(); - let md = MDBook::load(temp.path()).unwrap(); - md.build().unwrap(); - - let dest = temp.path().join("book"); - let links = vec![ - r#"href="intro.html""#, - r#"href="first/index.html""#, - r#"href="first/nested.html""#, - r#"href="second.html""#, - r#"href="conclusion.html""#, - ]; - - let files_in_bottom_dir = vec!["index.html", "intro.html", "second.html", "conclusion.html"]; - - for filename in files_in_bottom_dir { - assert_contains_strings(dest.join(filename), &links); - } -} - #[test] fn check_correct_cross_links_in_nested_dir() { let temp = DummyBook::new().build().unwrap(); @@ -90,19 +68,6 @@ fn check_correct_cross_links_in_nested_dir() { md.build().unwrap(); let first = temp.path().join("book").join("first"); - let links = vec![ - r#"href="../intro.html""#, - r#"href="../first/index.html""#, - r#"href="../first/nested.html""#, - r#"href="../second.html""#, - r#"href="../conclusion.html""#, - ]; - - let files_in_nested_dir = vec!["index.html", "nested.html"]; - - for filename in files_in_nested_dir { - assert_contains_strings(first.join(filename), &links); - } assert_contains_strings( first.join("index.html"), @@ -265,9 +230,9 @@ fn entry_ends_with(entry: &DirEntry, ending: &str) -> bool { entry.file_name().to_string_lossy().ends_with(ending) } -/// Read the main page (`book/index.html`) and expose it as a DOM which we +/// Read the TOC (`book/toc.js`) nested HTML and expose it as a DOM which we /// can search with the `select` crate -fn root_index_html() -> Result { +fn toc_html() -> Result { let temp = DummyBook::new() .build() .with_context(|| "Couldn't create the dummy book")?; @@ -275,15 +240,21 @@ fn root_index_html() -> Result { .build() .with_context(|| "Book building failed")?; - let index_page = temp.path().join("book").join("index.html"); - let html = fs::read_to_string(index_page).with_context(|| "Unable to read index.html")?; - - Ok(Document::from(html.as_str())) + let toc_path = temp.path().join("book").join("toc.js"); + let html = fs::read_to_string(toc_path).with_context(|| "Unable to read index.html")?; + for line in html.lines() { + if let Some(left) = line.strip_prefix("sidebarScrollbox.innerHTML = '") { + if let Some(html) = left.strip_suffix("';") { + return Ok(Document::from(html)); + } + } + } + panic!("cannot find toc in file") } #[test] fn check_second_toc_level() { - let doc = root_index_html().unwrap(); + let doc = toc_html().unwrap(); let mut should_be = Vec::from(TOC_SECOND_LEVEL); should_be.sort_unstable(); @@ -305,7 +276,7 @@ fn check_second_toc_level() { #[test] fn check_first_toc_level() { - let doc = root_index_html().unwrap(); + let doc = toc_html().unwrap(); let mut should_be = Vec::from(TOC_TOP_LEVEL); should_be.extend(TOC_SECOND_LEVEL); @@ -328,7 +299,7 @@ fn check_first_toc_level() { #[test] fn check_spacers() { - let doc = root_index_html().unwrap(); + let doc = toc_html().unwrap(); let should_be = 2; let num_spacers = doc @@ -449,18 +420,15 @@ fn by_default_mdbook_use_index_preprocessor_to_convert_readme_to_index() { let md = MDBook::load_with_config(temp.path(), cfg).unwrap(); md.build().unwrap(); - let first_index = temp.path().join("book").join("first").join("index.html"); + let first_index = temp.path().join("book").join("toc.js"); let expected_strings = vec![ - r#"href="../first/index.html""#, - r#"href="../second/index.html""#, - "First README", + r#"href="first/index.html""#, + r#"href="second/index.html""#, + "1st README", + "2nd README", ]; assert_contains_strings(&first_index, &expected_strings); - assert_doesnt_contain_strings(&first_index, &["README.html"]); - - let second_index = temp.path().join("book").join("second").join("index.html"); - let unexpected_strings = vec!["Second README"]; - assert_doesnt_contain_strings(second_index, &unexpected_strings); + assert_doesnt_contain_strings(&first_index, &["README.html", "Second README"]); } #[test] @@ -639,11 +607,11 @@ fn summary_with_markdown_formatting() { let md = MDBook::load_with_config(temp.path(), cfg).unwrap(); md.build().unwrap(); - let rendered_path = temp.path().join("book/formatted-summary.html"); + let rendered_path = temp.path().join("book/toc.js"); assert_contains_strings( rendered_path, &[ - r#" Italic code *escape* `escape2`"#, + r#" Italic code *escape* `escape2`"#, r#" Soft line break"#, r#" <escaped tag>"#, ],