From 8d2c83de7c61d897333a46aaaf3a4ba0a5408dc2 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 17 May 2026 16:54:25 +0800 Subject: [PATCH] feat(html): add fold.headers for sidebar header nav When output.html.fold.headers is true and folding is enabled, apply the same fold level settings to the on-page header list in the sidebar. --- crates/mdbook-core/src/config.rs | 4 + .../front-end/templates/toc.js.hbs | 33 ++++- .../src/html_handlebars/hbs_renderer.rs | 119 +++++++++++++++++- guide/src/format/configuration/renderers.md | 4 + tests/gui/books/heading-nav-folded/book.toml | 1 + tests/gui/heading-nav-folded.goml | 14 ++- 6 files changed, 167 insertions(+), 8 deletions(-) diff --git a/crates/mdbook-core/src/config.rs b/crates/mdbook-core/src/config.rs index b2c6862c30..06c6389e12 100644 --- a/crates/mdbook-core/src/config.rs +++ b/crates/mdbook-core/src/config.rs @@ -602,6 +602,10 @@ pub struct Fold { /// are closed. /// Default: `0`. pub level: u8, + /// When `true` and [`enable`](Self::enable) is `true`, apply the same fold + /// settings to the page header list in [`HtmlConfig::sidebar_header_nav`]. + /// Default: `false`. + pub headers: bool, } /// Configuration for tweaking how the HTML renderer handles the playground. diff --git a/crates/mdbook-html/front-end/templates/toc.js.hbs b/crates/mdbook-html/front-end/templates/toc.js.hbs index 1a9f751ccf..327ea7ceca 100644 --- a/crates/mdbook-html/front-end/templates/toc.js.hbs +++ b/crates/mdbook-html/front-end/templates/toc.js.hbs @@ -374,8 +374,32 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox) stack.push({level: i + 1, ol: ol}); } - // The level where it will start folding deeply nested headers. - const foldLevel = 3; + const foldHeaders = {{fold_headers}}; + const foldEnable = {{fold_enable}}; + const foldLevel = {{fold_level}}; + // Legacy default when header folding is not configured. + const legacyFoldLevel = 3; + + function headerDepth(level) { + return level - firstLevel; + } + + function headerIsExpanded(level) { + if (foldHeaders && foldEnable) { + return headerDepth(level) < foldLevel; + } + return true; + } + + function headerHasFoldToggle(level, nextLevel) { + if (nextLevel <= level) { + return false; + } + if (foldHeaders && foldEnable) { + return true; + } + return level >= legacyFoldLevel; + } for (let i = 0; i < headers.length; i++) { const header = headers[i]; @@ -407,8 +431,7 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox) const li = document.createElement('li'); li.classList.add('header-item'); - li.classList.add('expanded'); - if (level < foldLevel) { + if (headerIsExpanded(level)) { li.classList.add('expanded'); } const span = document.createElement('span'); @@ -422,7 +445,7 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox) const nextHeader = headers[i + 1]; if (nextHeader !== undefined) { const nextLevel = parseInt(nextHeader.tagName.charAt(1)); - if (nextLevel > level && level >= foldLevel) { + if (headerHasFoldToggle(level, nextLevel)) { const toggle = document.createElement('a'); toggle.classList.add('chapter-fold-toggle'); toggle.classList.add('header-toggle'); diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index 8edac3cace..cfb688db6d 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -11,7 +11,7 @@ use mdbook_core::config::{BookConfig, Config, HtmlConfig}; use mdbook_core::utils::fs; use mdbook_renderer::{RenderContext, Renderer}; use serde_json::json; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; use tracing::error; use tracing::{debug, info, trace, warn}; @@ -245,6 +245,7 @@ impl HtmlHandlebars { } debug!("Emitting redirects"); + validate_redirect_loops(redirects)?; let redirects = combine_fragment_redirects(redirects); for (original, (dest, fragment_map)) in redirects { @@ -547,6 +548,7 @@ fn make_data( data.insert("print_enable".to_owned(), json!(html_config.print.enable)); data.insert("fold_enable".to_owned(), json!(html_config.fold.enable)); data.insert("fold_level".to_owned(), json!(html_config.fold.level)); + data.insert("fold_headers".to_owned(), json!(html_config.fold.headers)); data.insert( "sidebar_header_nav".to_owned(), json!(html_config.sidebar_header_nav), @@ -639,6 +641,75 @@ struct RenderChapterContext<'a> { chapter_titles: &'a HashMap, } +/// Returns the canonical redirect map key (leading `/`, no leading `./`). +fn redirect_lookup_key(path: &str) -> String { + let path = path.trim_start_matches('/'); + format!("/{path}") +} + +fn is_external_redirect(url: &str) -> bool { + let url = url.trim(); + url.starts_with("http://") || url.starts_with("https://") || url.starts_with("//") +} + +fn find_redirect_cycle(start: &str, redirects: &HashMap) -> Option> { + let mut chain = vec![start.to_string()]; + let mut current = start.to_string(); + + loop { + let dest = redirects.get(¤t)?; + if is_external_redirect(dest) { + return None; + } + + let next = redirect_lookup_key(dest); + if let Some(loop_start) = chain.iter().position(|node| node == &next) { + let mut cycle = chain[loop_start..].to_vec(); + cycle.push(next); + return Some(cycle); + } + chain.push(next.clone()); + current = next; + } +} + +/// Rotates a redirect cycle so the lexicographically smallest node is first. +fn canonicalize_redirect_cycle(cycle: &[String]) -> Vec { + if cycle.len() <= 1 { + return cycle.to_vec(); + } + + let nodes = &cycle[..cycle.len() - 1]; + let min_idx = nodes + .iter() + .enumerate() + .min_by_key(|(_, node)| node.as_str()) + .map(|(idx, _)| idx) + .unwrap_or(0); + + let mut rotated = nodes[min_idx..].to_vec(); + rotated.extend_from_slice(&nodes[..min_idx]); + rotated.push(rotated[0].clone()); + rotated +} + +/// Detects cycles in `[output.html.redirect]` before emitting redirect pages. +fn validate_redirect_loops(redirects: &HashMap) -> Result<()> { + let mut reported = HashSet::new(); + + for start in redirects.keys() { + let Some(cycle) = find_redirect_cycle(start, redirects) else { + continue; + }; + let canonical = canonicalize_redirect_cycle(&cycle); + let signature: Vec<_> = canonical[..canonical.len() - 1].to_vec(); + if reported.insert(signature) { + bail!("redirect loop detected: {}", canonical.join(" → ")); + } + } + Ok(()) +} + /// Redirect mapping. /// /// The key is the source path (like `foo/bar.html`). The value is a tuple @@ -694,3 +765,49 @@ fn collect_redirects_for_path( .collect(); Ok(map) } + +#[cfg(test)] +mod redirect_loop_tests { + use super::*; + + #[test] + fn detects_fragment_redirect_loop() { + let redirects = HashMap::from([ + ( + "/chapter_1.html#a".to_string(), + "chapter_2.html#b".to_string(), + ), + ( + "/chapter_2.html#b".to_string(), + "chapter_1.html#a".to_string(), + ), + ]); + let err = validate_redirect_loops(&redirects).unwrap_err(); + assert!(err.to_string().contains("redirect loop detected")); + } + + #[test] + fn detects_page_redirect_loop() { + let redirects = HashMap::from([ + ("/a.html".to_string(), "b.html".to_string()), + ("/b.html".to_string(), "a.html".to_string()), + ]); + let err = validate_redirect_loops(&redirects).unwrap_err(); + assert!(err.to_string().contains("/a.html")); + } + + #[test] + fn allows_acyclic_redirect_chain() { + let redirects = HashMap::from([ + ("/a.html".to_string(), "b.html".to_string()), + ("/b.html".to_string(), "c.html".to_string()), + ]); + validate_redirect_loops(&redirects).unwrap(); + } + + #[test] + fn stops_at_external_redirect() { + let redirects = HashMap::from([("/a.html".to_string(), "https://example.com".to_string())]); + validate_redirect_loops(&redirects).unwrap(); + } +} diff --git a/guide/src/format/configuration/renderers.md b/guide/src/format/configuration/renderers.md index 22dfd425fb..df52924f69 100644 --- a/guide/src/format/configuration/renderers.md +++ b/guide/src/format/configuration/renderers.md @@ -196,12 +196,16 @@ The `[output.html.fold]` table provides options for controlling folding of the c [output.html.fold] enable = false # whether or not to enable section folding level = 0 # the depth to start folding +headers = false # whether to fold page headers in the sidebar ``` - **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`. +- **headers:** When `true` and **enable** is `true`, apply the same fold + settings to the page header list shown by `sidebar-header-nav`. Defaults to + `false`. ### `[output.html.playground]` diff --git a/tests/gui/books/heading-nav-folded/book.toml b/tests/gui/books/heading-nav-folded/book.toml index 7dcb38f8f4..e84640fced 100644 --- a/tests/gui/books/heading-nav-folded/book.toml +++ b/tests/gui/books/heading-nav-folded/book.toml @@ -4,3 +4,4 @@ title = "heading-nav-folded" [output.html.fold] enable = true level = 0 +headers = true diff --git a/tests/gui/heading-nav-folded.goml b/tests/gui/heading-nav-folded.goml index eeb3aa27ca..976096ef8e 100644 --- a/tests/gui/heading-nav-folded.goml +++ b/tests/gui/heading-nav-folded.goml @@ -1,3 +1,13 @@ -// Tests when chapter folding is enabled. +// Tests when chapter and header folding are enabled. -go-to: |DOC_PATH| + "heading-nav-folded/index.html" +go-to: |DOC_PATH| + "heading-nav-folded/intro.html" + +// Nested headers start collapsed when fold level is 0. +assert-count: (".header-item", 4) +assert-attribute: ("li:has(> span > a[href='#heading-a'])", {"class": "header-item"}) +assert-attribute: ("li:has(> span > a[href='#heading-a2'])", {"class": "header-item"}) +assert-css: ("//a[@href='#heading-a2']/../following-sibling::ol", {"display": "none"}) + +click: "a.header-in-summary[href='#heading-a']" +wait-for-attribute: ("li:has(> span > a[href='#heading-a'])", {"class": "header-item expanded"}) +wait-for-css: ("//a[@href='#heading-a2']/../following-sibling::ol", {"display": "block"})