diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index d443911f88..dab6c3fe79 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -166,6 +166,7 @@ The following configuration options are available: - **no-section-label:** mdBook by defaults adds section label in table of contents column. For example, "1.", "2.1". Set this option to true to disable those labels. Defaults to `false`. +- **fold:** A subtable for configuring sidebar section-folding behavior. - **playpen:** A subtable for configuring various playpen settings. - **search:** A subtable for configuring the in-browser search functionality. mdBook must be compiled with the `search` feature enabled (on by default). @@ -173,6 +174,13 @@ The following configuration options are available: an icon link will be output in the menu bar of the book. - **git-repository-icon:** The FontAwesome icon class to use for the git repository link. Defaults to `fa-github`. + +Available configuration options for the `[output.html.fold]` table: + +- **enable:** Enable section-folding. When off, all folds are open. + Defaults to `false`. +- **level:** The higher the more folded regions are open. When level is 0, all + folds are closed. Defaults to `0`. Available configuration options for the `[output.html.playpen]` table: @@ -225,6 +233,10 @@ no-section-label = false git-repository-url = "https://github.com/rust-lang-nursery/mdBook" git-repository-icon = "fa-github" +[output.html.fold] +enable = false +level = 0 + [output.html.playpen] editable = false copy-js = true diff --git a/src/config.rs b/src/config.rs index 17c941d255..b99159ef19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -431,16 +431,10 @@ pub struct HtmlConfig { /// Additional JS scripts to include at the bottom of the rendered page's /// ``. pub additional_js: Vec, + /// Fold settings. + pub fold: Fold, /// Playpen settings. pub playpen: Playpen, - /// This is used as a bit of a workaround for the `mdbook serve` command. - /// Basically, because you set the websocket port from the command line, the - /// `mdbook serve` command needs a way to let the HTML renderer know where - /// to point livereloading at, if it has been enabled. - /// - /// This config item *should not be edited* by the end user. - #[doc(hidden)] - pub livereload_url: Option, /// Don't render section labels. pub no_section_label: bool, /// Search settings. If `None`, the default will be used. @@ -450,6 +444,14 @@ pub struct HtmlConfig { /// FontAwesome icon class to use for the Git repository link. /// Defaults to `fa-github` if `None`. pub git_repository_icon: Option, + /// This is used as a bit of a workaround for the `mdbook serve` command. + /// Basically, because you set the websocket port from the command line, the + /// `mdbook serve` command needs a way to let the HTML renderer know where + /// to point livereloading at, if it has been enabled. + /// + /// This config item *should not be edited* by the end user. + #[doc(hidden)] + pub livereload_url: Option, } impl HtmlConfig { @@ -463,6 +465,18 @@ impl HtmlConfig { } } +/// Configuration for how to fold chapters of sidebar. +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Fold { + /// When off, all folds are open. Default: `false`. + pub enable: bool, + /// The higher the more folded regions are open. When level is 0, all folds + /// are closed. + /// Default: `0`. + pub level: u8, +} + /// Configuration for tweaking how the the HTML renderer handles the playpen. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "kebab-case")] diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 7a7f0ef6d7..8f8e78df50 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -71,6 +71,10 @@ impl HtmlHandlebars { "path_to_root".to_owned(), json!(utils::fs::path_to_root(&ch.path)), ); + if let Some(ref section) = ch.number { + ctx.data + .insert("section".to_owned(), json!(section.to_string())); + } // Render the handlebars template with the data debug!("Render template"); @@ -444,6 +448,9 @@ fn make_data( data.insert("playpen_js".to_owned(), json!(true)); } + data.insert("fold_enable".to_owned(), json!((html_config.fold.enable))); + data.insert("fold_level".to_owned(), json!((html_config.fold.level))); + let search = html_config.search.clone(); if cfg!(feature = "search") { let search = search.unwrap_or_default(); @@ -463,6 +470,7 @@ fn make_data( if let Some(ref git_repository_url) = html_config.git_repository_url { data.insert("git_repository_url".to_owned(), json!(git_repository_url)); } + let git_repository_icon = match html_config.git_repository_icon { Some(ref git_repository_icon) => git_repository_icon, None => "fa-github", @@ -481,6 +489,11 @@ fn make_data( chapter.insert("section".to_owned(), json!(section.to_string())); } + chapter.insert( + "has_sub_items".to_owned(), + json!((!ch.sub_items.is_empty()).to_string()), + ); + chapter.insert("name".to_owned(), json!(ch.name)); let path = ch .path diff --git a/src/renderer/html_handlebars/helpers/toc.rs b/src/renderer/html_handlebars/helpers/toc.rs index 33e7ef843a..c8075945e6 100644 --- a/src/renderer/html_handlebars/helpers/toc.rs +++ b/src/renderer/html_handlebars/helpers/toc.rs @@ -28,13 +28,36 @@ impl HelperDef for RenderToc { serde_json::value::from_value::>>(c.as_json().clone()) .map_err(|_| RenderError::new("Could not decode the JSON data")) })?; - let current = rc + let current_path = rc .evaluate(ctx, "@root/path")? .as_json() .as_str() - .ok_or_else(|| RenderError::new("Type error for `path`, string expected"))? + .ok_or(RenderError::new("Type error for `path`, string expected"))? .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")? + .as_json() + .as_bool() + .ok_or(RenderError::new( + "Type error for `fold_enable`, bool expected", + ))?; + + let fold_level = rc + .evaluate(ctx, "@root/fold_level")? + .as_json() + .as_u64() + .ok_or(RenderError::new( + "Type error for `fold_level`, u64 expected", + ))?; + out.write("
    ")?; let mut current_level = 1; @@ -46,10 +69,23 @@ impl HelperDef for RenderToc { continue; } - let level = if let Some(s) = item.get("section") { - s.matches('.').count() + let (section, level) = if let Some(s) = item.get("section") { + (s.as_str(), s.matches('.').count()) } else { - 1 + ("", 1) + }; + + let is_expanded = { + if !fold_enable { + // Disable fold. Expand all chapters. + true + } else if !section.is_empty() && current_section.starts_with(section) { + // The section is ancestor or the current section itself. + true + } else { + // Levels that are larger than this would be folded. + level - 1 < fold_level as usize + } }; if level > current_level { @@ -58,20 +94,16 @@ impl HelperDef for RenderToc { out.write("
      ")?; current_level += 1; } - out.write("
    1. ")?; + write_li_open_tag(out, is_expanded, false)?; } else if level < current_level { while level < current_level { out.write("
    ")?; out.write("")?; current_level -= 1; } - out.write("
  1. ")?; + write_li_open_tag(out, is_expanded, false)?; } else { - out.write("")?; + write_li_open_tag(out, is_expanded, item.get("section").is_none())?; } // Link @@ -87,11 +119,11 @@ impl HelperDef for RenderToc { .replace("\\", "/"); // Add link - out.write(&utils::fs::path_to_root(¤t))?; + out.write(&utils::fs::path_to_root(¤t_path))?; out.write(&tmp)?; out.write("\"")?; - if path == ¤t { + if path == ¤t_path { out.write(" class=\"active\"")?; } @@ -134,6 +166,13 @@ impl HelperDef for RenderToc { out.write("")?; } + // Render expand/collapse toggle + if let Some(flag) = item.get("has_sub_items") { + let has_sub_items = flag.parse::().unwrap_or_default(); + if fold_enable && has_sub_items { + out.write("
    ")?; + } + } out.write("
  2. ")?; } while current_level > 1 { @@ -146,3 +185,19 @@ impl HelperDef for RenderToc { Ok(()) } } + +fn write_li_open_tag( + out: &mut dyn Output, + is_expanded: bool, + is_affix: bool, +) -> Result<(), std::io::Error> { + let mut li = String::from("
  3. "); + out.write(&li) +} diff --git a/src/theme/book.js b/src/theme/book.js index ca73ee14d0..8a5451ed3a 100644 --- a/src/theme/book.js +++ b/src/theme/book.js @@ -451,6 +451,17 @@ function playpen_text(playpen) { try { localStorage.setItem('mdbook-sidebar', 'visible'); } catch (e) { } } + + var sidebarAnchorToggles = document.querySelectorAll('#sidebar a.toggle'); + + function toggleSection(ev) { + ev.currentTarget.parentElement.classList.toggle('expanded'); + } + + Array.from(sidebarAnchorToggles).forEach(function (el) { + el.addEventListener('click', toggleSection); + }); + function hideSidebar() { html.classList.remove('sidebar-visible') html.classList.add('sidebar-hidden'); diff --git a/src/theme/css/chrome.css b/src/theme/css/chrome.css index 495f9b6a32..35d76d0dd0 100644 --- a/src/theme/css/chrome.css +++ b/src/theme/css/chrome.css @@ -376,7 +376,13 @@ ul#searchresults span.teaser em { padding-left: 0; line-height: 2.2em; } + +.chapter ol { + width: 100%; +} + .chapter li { + display: flex; color: var(--sidebar-non-existant); } .chapter li a { @@ -390,10 +396,32 @@ ul#searchresults span.teaser em { color: var(--sidebar-active); } -.chapter li .active { +.chapter li a.active { color: var(--sidebar-active); } +.chapter li > a.toggle { + cursor: pointer; + display: block; + margin-left: auto; + padding: 0 10px; + user-select: none; + opacity: 0.68; +} + +.chapter li > a.toggle div { + transition: transform 0.5s; +} + +/* collapse the section */ +.chapter li:not(.expanded) + li > ol { + display: none; +} + +.chapter li.expanded > a.toggle div { + transform: rotate(90deg); +} + .spacer { width: 100%; height: 3px; diff --git a/tests/rendered_output.rs b/tests/rendered_output.rs index 51bf4154fe..1da04389fc 100644 --- a/tests/rendered_output.rs +++ b/tests/rendered_output.rs @@ -235,7 +235,12 @@ fn check_second_toc_level() { let mut should_be = Vec::from(TOC_SECOND_LEVEL); should_be.sort(); - let pred = descendants!(Class("chapter"), Name("li"), Name("li"), Name("a")); + let pred = descendants!( + Class("chapter"), + Name("li"), + Name("li"), + Name("a").and(Class("toggle").not()) + ); let mut children_of_children: Vec<_> = doc .find(pred) @@ -254,7 +259,11 @@ fn check_first_toc_level() { should_be.extend(TOC_SECOND_LEVEL); should_be.sort(); - let pred = descendants!(Class("chapter"), Name("li"), Name("a")); + let pred = descendants!( + Class("chapter"), + Name("li"), + Name("a").and(Class("toggle").not()) + ); let mut children: Vec<_> = doc .find(pred)