Skip to content

Commit 2cb5b85

Browse files
committed
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.
1 parent ec996d3 commit 2cb5b85

File tree

7 files changed

+139
-123
lines changed

7 files changed

+139
-123
lines changed

src/renderer/html_handlebars/hbs_renderer.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,9 @@ impl Renderer for HtmlHandlebars {
528528
debug!("Register the header handlebars template");
529529
handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?;
530530

531+
debug!("Register the toc handlebars template");
532+
handlebars.register_template_string("toc", String::from_utf8(theme.toc.clone())?)?;
533+
531534
debug!("Register handlebars helpers");
532535
self.register_hbs_helpers(&mut handlebars, &html_config);
533536

@@ -583,6 +586,13 @@ impl Renderer for HtmlHandlebars {
583586
debug!("Creating print.html ✓");
584587
}
585588

589+
debug!("Render toc.js");
590+
{
591+
let rendered_toc = handlebars.render("toc", &data)?;
592+
utils::fs::write_file(destination, "toc.js", rendered_toc.as_bytes())?;
593+
debug!("Creating toc.js ✓");
594+
}
595+
586596
debug!("Copy static files");
587597
self.copy_static_files(destination, &theme, &html_config)
588598
.with_context(|| "Unable to copy across static files")?;

src/renderer/html_handlebars/helpers/toc.rs

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
use std::path::Path;
22
use std::{cmp::Ordering, collections::BTreeMap};
33

4-
use crate::utils;
5-
use crate::utils::bracket_escape;
4+
use crate::utils::special_escape;
65

76
use handlebars::{
87
Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason,
@@ -32,21 +31,6 @@ impl HelperDef for RenderToc {
3231
RenderErrorReason::Other("Could not decode the JSON data".to_owned()).into()
3332
})
3433
})?;
35-
let current_path = rc
36-
.evaluate(ctx, "@root/path")?
37-
.as_json()
38-
.as_str()
39-
.ok_or_else(|| {
40-
RenderErrorReason::Other("Type error for `path`, string expected".to_owned())
41-
})?
42-
.replace('\"', "");
43-
44-
let current_section = rc
45-
.evaluate(ctx, "@root/section")?
46-
.as_json()
47-
.as_str()
48-
.map(str::to_owned)
49-
.unwrap_or_default();
5034

5135
let fold_enable = rc
5236
.evaluate(ctx, "@root/fold_enable")?
@@ -67,28 +51,17 @@ impl HelperDef for RenderToc {
6751
out.write("<ol class=\"chapter\">")?;
6852

6953
let mut current_level = 1;
70-
// The "index" page, which has this attribute set, is supposed to alias the first chapter in
71-
// the book, i.e. the first link. There seems to be no easy way to determine which chapter
72-
// the "index" is aliasing from within the renderer, so this is used instead to force the
73-
// first link to be active. See further below.
74-
let mut is_first_chapter = ctx.data().get("is_index").is_some();
7554

7655
for item in chapters {
77-
let (section, level) = if let Some(s) = item.get("section") {
56+
let (_section, level) = if let Some(s) = item.get("section") {
7857
(s.as_str(), s.matches('.').count())
7958
} else {
8059
("", 1)
8160
};
8261

83-
let is_expanded =
84-
if !fold_enable || (!section.is_empty() && current_section.starts_with(section)) {
85-
// Expand if folding is disabled, or if the section is an
86-
// ancestor or the current section itself.
87-
true
88-
} else {
89-
// Levels that are larger than this would be folded.
90-
level - 1 < fold_level as usize
91-
};
62+
// Expand if folding is disabled, or if levels that are larger than this would not
63+
// be folded.
64+
let is_expanded = !fold_enable || level - 1 < (fold_level as usize);
9265

9366
match level.cmp(&current_level) {
9467
Ordering::Greater => {
@@ -121,7 +94,7 @@ impl HelperDef for RenderToc {
12194
// Part title
12295
if let Some(title) = item.get("part") {
12396
out.write("<li class=\"part-title\">")?;
124-
out.write(&bracket_escape(title))?;
97+
out.write(&special_escape(title))?;
12598
out.write("</li>")?;
12699
continue;
127100
}
@@ -139,16 +112,8 @@ impl HelperDef for RenderToc {
139112
.replace('\\', "/");
140113

141114
// Add link
142-
out.write(&utils::fs::path_to_root(&current_path))?;
143115
out.write(&tmp)?;
144-
out.write("\"")?;
145-
146-
if path == &current_path || is_first_chapter {
147-
is_first_chapter = false;
148-
out.write(" class=\"active\"")?;
149-
}
150-
151-
out.write(">")?;
116+
out.write("\">")?;
152117
path_exists = true;
153118
}
154119
_ => {
@@ -167,7 +132,7 @@ impl HelperDef for RenderToc {
167132
}
168133

169134
if let Some(name) = item.get("name") {
170-
out.write(&bracket_escape(name))?
135+
out.write(&special_escape(name))?
171136
}
172137

173138
if path_exists {

src/theme/index.hbs

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -109,35 +109,14 @@
109109
</script>
110110

111111
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
112-
<div class="sidebar-scrollbox">
113-
{{#toc}}{{/toc}}
114-
</div>
112+
<!-- populated by js -->
113+
<div class="sidebar-scrollbox"></div>
115114
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
116115
<div class="sidebar-resize-indicator"></div>
117116
</div>
118117
</nav>
119118

120-
<!-- Track and set sidebar scroll position -->
121-
<script>
122-
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
123-
sidebarScrollbox.addEventListener('click', function(e) {
124-
if (e.target.tagName === 'A') {
125-
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
126-
}
127-
}, { passive: true });
128-
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
129-
sessionStorage.removeItem('sidebar-scroll');
130-
if (sidebarScrollTop) {
131-
// preserve sidebar scroll position when navigating via links within sidebar
132-
sidebarScrollbox.scrollTop = sidebarScrollTop;
133-
} else {
134-
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
135-
var activeSection = document.querySelector('#sidebar .active');
136-
if (activeSection) {
137-
activeSection.scrollIntoView({ block: 'center' });
138-
}
139-
}
140-
</script>
119+
<script async src="{{ path_to_root }}toc.js"></script>
141120

142121
<div id="page-wrapper" class="page-wrapper">
143122

src/theme/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub static INDEX: &[u8] = include_bytes!("index.hbs");
1717
pub static HEAD: &[u8] = include_bytes!("head.hbs");
1818
pub static REDIRECT: &[u8] = include_bytes!("redirect.hbs");
1919
pub static HEADER: &[u8] = include_bytes!("header.hbs");
20+
pub static TOC: &[u8] = include_bytes!("toc.js.hbs");
2021
pub static CHROME_CSS: &[u8] = include_bytes!("css/chrome.css");
2122
pub static GENERAL_CSS: &[u8] = include_bytes!("css/general.css");
2223
pub static PRINT_CSS: &[u8] = include_bytes!("css/print.css");
@@ -50,6 +51,7 @@ pub struct Theme {
5051
pub head: Vec<u8>,
5152
pub redirect: Vec<u8>,
5253
pub header: Vec<u8>,
54+
pub toc: Vec<u8>,
5355
pub chrome_css: Vec<u8>,
5456
pub general_css: Vec<u8>,
5557
pub print_css: Vec<u8>,
@@ -85,6 +87,7 @@ impl Theme {
8587
(theme_dir.join("head.hbs"), &mut theme.head),
8688
(theme_dir.join("redirect.hbs"), &mut theme.redirect),
8789
(theme_dir.join("header.hbs"), &mut theme.header),
90+
(theme_dir.join("toc.js.hbs"), &mut theme.toc),
8891
(theme_dir.join("book.js"), &mut theme.js),
8992
(theme_dir.join("css/chrome.css"), &mut theme.chrome_css),
9093
(theme_dir.join("css/general.css"), &mut theme.general_css),
@@ -174,6 +177,7 @@ impl Default for Theme {
174177
head: HEAD.to_owned(),
175178
redirect: REDIRECT.to_owned(),
176179
header: HEADER.to_owned(),
180+
toc: TOC.to_owned(),
177181
chrome_css: CHROME_CSS.to_owned(),
178182
general_css: GENERAL_CSS.to_owned(),
179183
print_css: PRINT_CSS.to_owned(),
@@ -232,6 +236,7 @@ mod tests {
232236
"head.hbs",
233237
"redirect.hbs",
234238
"header.hbs",
239+
"toc.js.hbs",
235240
"favicon.png",
236241
"favicon.svg",
237242
"css/chrome.css",
@@ -263,6 +268,7 @@ mod tests {
263268
head: Vec::new(),
264269
redirect: Vec::new(),
265270
header: Vec::new(),
271+
toc: Vec::new(),
266272
chrome_css: Vec::new(),
267273
general_css: Vec::new(),
268274
print_css: Vec::new(),

src/theme/toc.js.hbs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Populate the sidebar
2+
//
3+
// This is a script, and not included directly in the page, to control the total size of the book.
4+
// The TOC contains an entry for each page, so if each page includes a copy of the TOC,
5+
// the total size of the page becomes O(n**2).
6+
var sidebarScrollbox = document.querySelector("#sidebar .sidebar-scrollbox");
7+
sidebarScrollbox.innerHTML = '{{#toc}}{{/toc}}';
8+
(function() {
9+
let current_page = document.location.href.toString();
10+
if (current_page.endsWith("/")) {
11+
current_page += "index.html";
12+
}
13+
var links = sidebarScrollbox.querySelectorAll("a");
14+
var l = links.length;
15+
for (var i = 0; i < l; ++i) {
16+
var link = links[i];
17+
var href = link.getAttribute("href");
18+
if (href && !href.startsWith("#") && !/^(?:[a-z+]+:)?\/\//.test(href)) {
19+
link.href = path_to_root + href;
20+
}
21+
// The "index" page is supposed to alias the first chapter in the book.
22+
if (link.href === current_page || (i === 0 && path_to_root === "" && current_page.endsWith("/index.html"))) {
23+
link.classList.add("active");
24+
var parent = link.parentElement;
25+
while (parent) {
26+
if (parent.tagName === "LI" && parent.previousElementSibling) {
27+
if (parent.previousElementSibling.classList.contains("chapter-item")) {
28+
parent.previousElementSibling.classList.add("expanded");
29+
}
30+
}
31+
parent = parent.parentElement;
32+
}
33+
}
34+
}
35+
})();
36+
37+
// Track and set sidebar scroll position
38+
sidebarScrollbox.addEventListener('click', function(e) {
39+
if (e.target.tagName === 'A') {
40+
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
41+
}
42+
}, { passive: true });
43+
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
44+
sessionStorage.removeItem('sidebar-scroll');
45+
if (sidebarScrollTop) {
46+
// preserve sidebar scroll position when navigating via links within sidebar
47+
sidebarScrollbox.scrollTop = sidebarScrollTop;
48+
} else {
49+
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
50+
var activeSection = document.querySelector('#sidebar .active');
51+
if (activeSection) {
52+
activeSection.scrollIntoView({ block: 'center' });
53+
}
54+
}

src/utils/mod.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,25 @@ pub fn log_backtrace(e: &Error) {
265265
}
266266
}
267267

268+
pub(crate) fn special_escape(mut s: &str) -> String {
269+
let mut escaped = String::with_capacity(s.len());
270+
let needs_escape: &[char] = &['<', '>', '\'', '\\', '&'];
271+
while let Some(next) = s.find(needs_escape) {
272+
escaped.push_str(&s[..next]);
273+
match s.as_bytes()[next] {
274+
b'<' => escaped.push_str("&lt;"),
275+
b'>' => escaped.push_str("&gt;"),
276+
b'\'' => escaped.push_str("&#39;"),
277+
b'\\' => escaped.push_str("&#92;"),
278+
b'&' => escaped.push_str("&amp;"),
279+
_ => unreachable!(),
280+
}
281+
s = &s[next + 1..];
282+
}
283+
escaped.push_str(s);
284+
escaped
285+
}
286+
268287
pub(crate) fn bracket_escape(mut s: &str) -> String {
269288
let mut escaped = String::with_capacity(s.len());
270289
let needs_escape: &[char] = &['<', '>'];
@@ -283,7 +302,7 @@ pub(crate) fn bracket_escape(mut s: &str) -> String {
283302

284303
#[cfg(test)]
285304
mod tests {
286-
use super::bracket_escape;
305+
use super::{bracket_escape, special_escape};
287306

288307
mod render_markdown {
289308
use super::super::render_markdown;
@@ -506,5 +525,20 @@ more text with spaces
506525
assert_eq!(bracket_escape("<>"), "&lt;&gt;");
507526
assert_eq!(bracket_escape("<test>"), "&lt;test&gt;");
508527
assert_eq!(bracket_escape("a<test>b"), "a&lt;test&gt;b");
528+
assert_eq!(bracket_escape("'"), "'");
529+
assert_eq!(bracket_escape("\\"), "\\");
530+
}
531+
532+
#[test]
533+
fn escaped_special() {
534+
assert_eq!(special_escape(""), "");
535+
assert_eq!(special_escape("<"), "&lt;");
536+
assert_eq!(special_escape(">"), "&gt;");
537+
assert_eq!(special_escape("<>"), "&lt;&gt;");
538+
assert_eq!(special_escape("<test>"), "&lt;test&gt;");
539+
assert_eq!(special_escape("a<test>b"), "a&lt;test&gt;b");
540+
assert_eq!(special_escape("'"), "&#39;");
541+
assert_eq!(special_escape("\\"), "&#92;");
542+
assert_eq!(special_escape("&"), "&amp;");
509543
}
510544
}

0 commit comments

Comments
 (0)