From 2cb5b85ab2dfa8e7f43653618652eea04d9b53db Mon Sep 17 00:00:00 2001 From: Michael Howell Date: Mon, 15 Jul 2024 18:38:50 -0700 Subject: [PATCH] Load the sidebar toc from a shared JS file Before this change, the Rust `unstable-book` is 88MiB. With this change, it becomes 15MiB. Other pages might not be as extreme, but it's expected to help any book like this. This change is so drastic because, if every chapter has a link to every other chapter, the result is *O*(n2) text output. --- src/renderer/html_handlebars/hbs_renderer.rs | 10 +++ src/renderer/html_handlebars/helpers/toc.rs | 51 ++----------- src/theme/index.hbs | 27 +------ src/theme/mod.rs | 6 ++ src/theme/toc.js.hbs | 54 ++++++++++++++ src/utils/mod.rs | 36 ++++++++- tests/rendered_output.rs | 78 ++++++-------------- 7 files changed, 139 insertions(+), 123 deletions(-) create mode 100644 src/theme/toc.js.hbs 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>"#, ],