Skip to content

Commit 8d2c83d

Browse files
author
wuyangfan
committed
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.
1 parent 1e04de7 commit 8d2c83d

6 files changed

Lines changed: 167 additions & 8 deletions

File tree

crates/mdbook-core/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,10 @@ pub struct Fold {
602602
/// are closed.
603603
/// Default: `0`.
604604
pub level: u8,
605+
/// When `true` and [`enable`](Self::enable) is `true`, apply the same fold
606+
/// settings to the page header list in [`HtmlConfig::sidebar_header_nav`].
607+
/// Default: `false`.
608+
pub headers: bool,
605609
}
606610

607611
/// Configuration for tweaking how the HTML renderer handles the playground.

crates/mdbook-html/front-end/templates/toc.js.hbs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,8 +374,32 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox)
374374
stack.push({level: i + 1, ol: ol});
375375
}
376376
377-
// The level where it will start folding deeply nested headers.
378-
const foldLevel = 3;
377+
const foldHeaders = {{fold_headers}};
378+
const foldEnable = {{fold_enable}};
379+
const foldLevel = {{fold_level}};
380+
// Legacy default when header folding is not configured.
381+
const legacyFoldLevel = 3;
382+
383+
function headerDepth(level) {
384+
return level - firstLevel;
385+
}
386+
387+
function headerIsExpanded(level) {
388+
if (foldHeaders && foldEnable) {
389+
return headerDepth(level) < foldLevel;
390+
}
391+
return true;
392+
}
393+
394+
function headerHasFoldToggle(level, nextLevel) {
395+
if (nextLevel <= level) {
396+
return false;
397+
}
398+
if (foldHeaders && foldEnable) {
399+
return true;
400+
}
401+
return level >= legacyFoldLevel;
402+
}
379403
380404
for (let i = 0; i < headers.length; i++) {
381405
const header = headers[i];
@@ -407,8 +431,7 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox)
407431
408432
const li = document.createElement('li');
409433
li.classList.add('header-item');
410-
li.classList.add('expanded');
411-
if (level < foldLevel) {
434+
if (headerIsExpanded(level)) {
412435
li.classList.add('expanded');
413436
}
414437
const span = document.createElement('span');
@@ -422,7 +445,7 @@ window.customElements.define('mdbook-sidebar-scrollbox', MDBookSidebarScrollbox)
422445
const nextHeader = headers[i + 1];
423446
if (nextHeader !== undefined) {
424447
const nextLevel = parseInt(nextHeader.tagName.charAt(1));
425-
if (nextLevel > level && level >= foldLevel) {
448+
if (headerHasFoldToggle(level, nextLevel)) {
426449
const toggle = document.createElement('a');
427450
toggle.classList.add('chapter-fold-toggle');
428451
toggle.classList.add('header-toggle');

crates/mdbook-html/src/html_handlebars/hbs_renderer.rs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use mdbook_core::config::{BookConfig, Config, HtmlConfig};
1111
use mdbook_core::utils::fs;
1212
use mdbook_renderer::{RenderContext, Renderer};
1313
use serde_json::json;
14-
use std::collections::{BTreeMap, HashMap};
14+
use std::collections::{BTreeMap, HashMap, HashSet};
1515
use std::path::{Path, PathBuf};
1616
use tracing::error;
1717
use tracing::{debug, info, trace, warn};
@@ -245,6 +245,7 @@ impl HtmlHandlebars {
245245
}
246246

247247
debug!("Emitting redirects");
248+
validate_redirect_loops(redirects)?;
248249
let redirects = combine_fragment_redirects(redirects);
249250

250251
for (original, (dest, fragment_map)) in redirects {
@@ -547,6 +548,7 @@ fn make_data(
547548
data.insert("print_enable".to_owned(), json!(html_config.print.enable));
548549
data.insert("fold_enable".to_owned(), json!(html_config.fold.enable));
549550
data.insert("fold_level".to_owned(), json!(html_config.fold.level));
551+
data.insert("fold_headers".to_owned(), json!(html_config.fold.headers));
550552
data.insert(
551553
"sidebar_header_nav".to_owned(),
552554
json!(html_config.sidebar_header_nav),
@@ -639,6 +641,75 @@ struct RenderChapterContext<'a> {
639641
chapter_titles: &'a HashMap<PathBuf, String>,
640642
}
641643

644+
/// Returns the canonical redirect map key (leading `/`, no leading `./`).
645+
fn redirect_lookup_key(path: &str) -> String {
646+
let path = path.trim_start_matches('/');
647+
format!("/{path}")
648+
}
649+
650+
fn is_external_redirect(url: &str) -> bool {
651+
let url = url.trim();
652+
url.starts_with("http://") || url.starts_with("https://") || url.starts_with("//")
653+
}
654+
655+
fn find_redirect_cycle(start: &str, redirects: &HashMap<String, String>) -> Option<Vec<String>> {
656+
let mut chain = vec![start.to_string()];
657+
let mut current = start.to_string();
658+
659+
loop {
660+
let dest = redirects.get(&current)?;
661+
if is_external_redirect(dest) {
662+
return None;
663+
}
664+
665+
let next = redirect_lookup_key(dest);
666+
if let Some(loop_start) = chain.iter().position(|node| node == &next) {
667+
let mut cycle = chain[loop_start..].to_vec();
668+
cycle.push(next);
669+
return Some(cycle);
670+
}
671+
chain.push(next.clone());
672+
current = next;
673+
}
674+
}
675+
676+
/// Rotates a redirect cycle so the lexicographically smallest node is first.
677+
fn canonicalize_redirect_cycle(cycle: &[String]) -> Vec<String> {
678+
if cycle.len() <= 1 {
679+
return cycle.to_vec();
680+
}
681+
682+
let nodes = &cycle[..cycle.len() - 1];
683+
let min_idx = nodes
684+
.iter()
685+
.enumerate()
686+
.min_by_key(|(_, node)| node.as_str())
687+
.map(|(idx, _)| idx)
688+
.unwrap_or(0);
689+
690+
let mut rotated = nodes[min_idx..].to_vec();
691+
rotated.extend_from_slice(&nodes[..min_idx]);
692+
rotated.push(rotated[0].clone());
693+
rotated
694+
}
695+
696+
/// Detects cycles in `[output.html.redirect]` before emitting redirect pages.
697+
fn validate_redirect_loops(redirects: &HashMap<String, String>) -> Result<()> {
698+
let mut reported = HashSet::new();
699+
700+
for start in redirects.keys() {
701+
let Some(cycle) = find_redirect_cycle(start, redirects) else {
702+
continue;
703+
};
704+
let canonical = canonicalize_redirect_cycle(&cycle);
705+
let signature: Vec<_> = canonical[..canonical.len() - 1].to_vec();
706+
if reported.insert(signature) {
707+
bail!("redirect loop detected: {}", canonical.join(" → "));
708+
}
709+
}
710+
Ok(())
711+
}
712+
642713
/// Redirect mapping.
643714
///
644715
/// The key is the source path (like `foo/bar.html`). The value is a tuple
@@ -694,3 +765,49 @@ fn collect_redirects_for_path(
694765
.collect();
695766
Ok(map)
696767
}
768+
769+
#[cfg(test)]
770+
mod redirect_loop_tests {
771+
use super::*;
772+
773+
#[test]
774+
fn detects_fragment_redirect_loop() {
775+
let redirects = HashMap::from([
776+
(
777+
"/chapter_1.html#a".to_string(),
778+
"chapter_2.html#b".to_string(),
779+
),
780+
(
781+
"/chapter_2.html#b".to_string(),
782+
"chapter_1.html#a".to_string(),
783+
),
784+
]);
785+
let err = validate_redirect_loops(&redirects).unwrap_err();
786+
assert!(err.to_string().contains("redirect loop detected"));
787+
}
788+
789+
#[test]
790+
fn detects_page_redirect_loop() {
791+
let redirects = HashMap::from([
792+
("/a.html".to_string(), "b.html".to_string()),
793+
("/b.html".to_string(), "a.html".to_string()),
794+
]);
795+
let err = validate_redirect_loops(&redirects).unwrap_err();
796+
assert!(err.to_string().contains("/a.html"));
797+
}
798+
799+
#[test]
800+
fn allows_acyclic_redirect_chain() {
801+
let redirects = HashMap::from([
802+
("/a.html".to_string(), "b.html".to_string()),
803+
("/b.html".to_string(), "c.html".to_string()),
804+
]);
805+
validate_redirect_loops(&redirects).unwrap();
806+
}
807+
808+
#[test]
809+
fn stops_at_external_redirect() {
810+
let redirects = HashMap::from([("/a.html".to_string(), "https://example.com".to_string())]);
811+
validate_redirect_loops(&redirects).unwrap();
812+
}
813+
}

guide/src/format/configuration/renderers.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,12 +196,16 @@ The `[output.html.fold]` table provides options for controlling folding of the c
196196
[output.html.fold]
197197
enable = false # whether or not to enable section folding
198198
level = 0 # the depth to start folding
199+
headers = false # whether to fold page headers in the sidebar
199200
```
200201

201202
- **enable:** Enable section-folding. When off, all folds are open.
202203
Defaults to `false`.
203204
- **level:** The higher the more folded regions are open. When level is 0, all
204205
folds are closed. Defaults to `0`.
206+
- **headers:** When `true` and **enable** is `true`, apply the same fold
207+
settings to the page header list shown by `sidebar-header-nav`. Defaults to
208+
`false`.
205209

206210
### `[output.html.playground]`
207211

tests/gui/books/heading-nav-folded/book.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ title = "heading-nav-folded"
44
[output.html.fold]
55
enable = true
66
level = 0
7+
headers = true

tests/gui/heading-nav-folded.goml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1-
// Tests when chapter folding is enabled.
1+
// Tests when chapter and header folding are enabled.
22

3-
go-to: |DOC_PATH| + "heading-nav-folded/index.html"
3+
go-to: |DOC_PATH| + "heading-nav-folded/intro.html"
4+
5+
// Nested headers start collapsed when fold level is 0.
6+
assert-count: (".header-item", 4)
7+
assert-attribute: ("li:has(> span > a[href='#heading-a'])", {"class": "header-item"})
8+
assert-attribute: ("li:has(> span > a[href='#heading-a2'])", {"class": "header-item"})
9+
assert-css: ("//a[@href='#heading-a2']/../following-sibling::ol", {"display": "none"})
10+
11+
click: "a.header-in-summary[href='#heading-a']"
12+
wait-for-attribute: ("li:has(> span > a[href='#heading-a'])", {"class": "header-item expanded"})
13+
wait-for-css: ("//a[@href='#heading-a2']/../following-sibling::ol", {"display": "block"})

0 commit comments

Comments
 (0)