Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/mdbook-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 28 additions & 5 deletions crates/mdbook-html/front-end/templates/toc.js.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
119 changes: 118 additions & 1 deletion crates/mdbook-html/src/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -639,6 +641,75 @@ struct RenderChapterContext<'a> {
chapter_titles: &'a HashMap<PathBuf, String>,
}

/// 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<String, String>) -> Option<Vec<String>> {
let mut chain = vec![start.to_string()];
let mut current = start.to_string();

loop {
let dest = redirects.get(&current)?;
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<String> {
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<String, String>) -> 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
Expand Down Expand Up @@ -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();
}
}
4 changes: 4 additions & 0 deletions guide/src/format/configuration/renderers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`

Expand Down
1 change: 1 addition & 0 deletions tests/gui/books/heading-nav-folded/book.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ title = "heading-nav-folded"
[output.html.fold]
enable = true
level = 0
headers = true
14 changes: 12 additions & 2 deletions tests/gui/heading-nav-folded.goml
Original file line number Diff line number Diff line change
@@ -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"})
Loading