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#"1. Italic code *escape* `escape2`"#,
+ r#"1. Italic code *escape* `escape2`"#,
r#"2. Soft line break"#,
r#"3. <escaped tag>"#,
],