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*(n<sup>2</sup>) text output.
This commit is contained in:
Michael Howell 2024-07-15 18:38:50 -07:00
parent ec996d3509
commit 2cb5b85ab2
7 changed files with 139 additions and 123 deletions

View file

@ -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")?;

View file

@ -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("<ol class=\"chapter\">")?;
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(&current_level) {
Ordering::Greater => {
@ -121,7 +94,7 @@ impl HelperDef for RenderToc {
// Part title
if let Some(title) = item.get("part") {
out.write("<li class=\"part-title\">")?;
out.write(&bracket_escape(title))?;
out.write(&special_escape(title))?;
out.write("</li>")?;
continue;
}
@ -139,16 +112,8 @@ impl HelperDef for RenderToc {
.replace('\\', "/");
// Add link
out.write(&utils::fs::path_to_root(&current_path))?;
out.write(&tmp)?;
out.write("\"")?;
if path == &current_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 {

View file

@ -109,35 +109,14 @@
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<div class="sidebar-scrollbox">
{{#toc}}{{/toc}}
</div>
<!-- populated by js -->
<div class="sidebar-scrollbox"></div>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<!-- Track and set sidebar scroll position -->
<script>
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
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' });
}
}
</script>
<script async src="{{ path_to_root }}toc.js"></script>
<div id="page-wrapper" class="page-wrapper">

View file

@ -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<u8>,
pub redirect: Vec<u8>,
pub header: Vec<u8>,
pub toc: Vec<u8>,
pub chrome_css: Vec<u8>,
pub general_css: Vec<u8>,
pub print_css: Vec<u8>,
@ -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(),

54
src/theme/toc.js.hbs Normal file
View file

@ -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' });
}
}

View file

@ -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("&lt;"),
b'>' => escaped.push_str("&gt;"),
b'\'' => escaped.push_str("&#39;"),
b'\\' => escaped.push_str("&#92;"),
b'&' => escaped.push_str("&amp;"),
_ => 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("<>"), "&lt;&gt;");
assert_eq!(bracket_escape("<test>"), "&lt;test&gt;");
assert_eq!(bracket_escape("a<test>b"), "a&lt;test&gt;b");
assert_eq!(bracket_escape("'"), "'");
assert_eq!(bracket_escape("\\"), "\\");
}
#[test]
fn escaped_special() {
assert_eq!(special_escape(""), "");
assert_eq!(special_escape("<"), "&lt;");
assert_eq!(special_escape(">"), "&gt;");
assert_eq!(special_escape("<>"), "&lt;&gt;");
assert_eq!(special_escape("<test>"), "&lt;test&gt;");
assert_eq!(special_escape("a<test>b"), "a&lt;test&gt;b");
assert_eq!(special_escape("'"), "&#39;");
assert_eq!(special_escape("\\"), "&#92;");
assert_eq!(special_escape("&"), "&amp;");
}
}

View file

@ -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<Document> {
fn toc_html() -> Result<Document> {
let temp = DummyBook::new()
.build()
.with_context(|| "Couldn't create the dummy book")?;
@ -275,15 +240,21 @@ fn root_index_html() -> Result<Document> {
.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#"<a href="formatted-summary.html" class="active"><strong aria-hidden="true">1.</strong> Italic code *escape* `escape2`</a>"#,
r#"<a href="formatted-summary.html"><strong aria-hidden="true">1.</strong> Italic code *escape* `escape2`</a>"#,
r#"<a href="soft.html"><strong aria-hidden="true">2.</strong> Soft line break</a>"#,
r#"<a href="escaped-tag.html"><strong aria-hidden="true">3.</strong> &lt;escaped tag&gt;</a>"#,
],