Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ changes.

## [Unreleased]

### Added

- Document sidebars now support explicit sub-chapters through `parent` in
`[[sidebar.matches]]`. Parents remain clickable, integer positions order
siblings, and numbering follows the hierarchy, such as `4.1` and `4.1.1`.
Missing parents, self-parenting, cycles, and combining parents with
`sidebar.group_by_dir` report errors
([#290](https://github.com/feel-co/ndg/issues/290)).

### Fixed

- An anchor link that holds only an element, such as
Expand Down
32 changes: 29 additions & 3 deletions crates/ndg-config/src/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,24 @@ const fn default_true() -> bool {
}

impl SidebarConfig {
/// Validate and compile all regex patterns in the sidebar configuration.
/// Validate sidebar settings and compile all regex patterns.
///
/// This pre-compiles all regex patterns to ensure they're valid,
/// failing fast at config load time rather than during rendering.
///
/// # Errors
///
/// Returns an error if any regex pattern is invalid.
/// Returns an error if any regex pattern is invalid or explicit parents are
/// combined with directory grouping.
pub fn validate(&mut self) -> Result<(), String> {
for (idx, m) in self.matches.iter_mut().enumerate() {
if self.group_by_dir && m.parent.is_some() {
return Err(format!(
"Sidebar match #{}: parent cannot be combined with \
sidebar.group_by_dir",
idx + 1
));
}
m.compile_regexes()
.map_err(|e| format!("Sidebar match #{}: {}", idx + 1, e))?;
}
Expand Down Expand Up @@ -357,9 +365,14 @@ pub struct SidebarMatch {
#[serde(skip_serializing_if = "Option::is_none")]
pub new_title: Option<String>,

/// Custom position in sidebar.
/// Custom integer position among siblings in the sidebar.
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<usize>,

/// Parent Markdown source path relative to `input_dir`, not an output URL
/// or title. When omitted, the matched page remains a root sidebar item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}

impl SidebarMatch {
Expand Down Expand Up @@ -692,6 +705,7 @@ mod tests {
title: None,
new_title: None,
position: Some(1),
parent: None,
};

assert!(m.matches("getting-started.md", "Any Title"));
Expand All @@ -705,6 +719,7 @@ mod tests {
title: None,
new_title: None,
position: Some(50),
parent: None,
};

m.compile_regexes().expect("regex should compile");
Expand All @@ -721,6 +736,7 @@ mod tests {
title: Some(TitleMatch::exact("Release Notes".to_string())),
new_title: Some("What's New".to_string()),
position: Some(999),
parent: None,
};

assert!(m.matches("any/path.md", "Release Notes"));
Expand All @@ -734,6 +750,7 @@ mod tests {
title: Some(TitleMatch::regex(r"^Release.*".to_string())),
new_title: Some("What's New".to_string()),
position: Some(999),
parent: None,
};

m.compile_regexes().expect("regex should compile");
Expand All @@ -750,6 +767,7 @@ mod tests {
title: Some(TitleMatch::regex(r"^API.*".to_string())),
new_title: None,
position: Some(50),
parent: None,
};

m.compile_regexes().expect("regexes should compile");
Expand All @@ -767,6 +785,7 @@ mod tests {
title: None,
new_title: None,
position: Some(42),
parent: None,
};

assert_eq!(m.get_position(), Some(42));
Expand All @@ -779,6 +798,7 @@ mod tests {
title: None,
new_title: Some("Custom Title".to_string()),
position: None,
parent: None,
};

assert_eq!(m.get_title(), Some("Custom Title"));
Expand All @@ -800,12 +820,14 @@ mod tests {
title: None,
new_title: None,
position: Some(1),
parent: None,
},
SidebarMatch {
path: Some(PathMatch::regex(r"^api/.*\.md$".to_string())),
title: None,
new_title: None,
position: Some(50),
parent: None,
},
],
};
Expand Down Expand Up @@ -839,12 +861,14 @@ mod tests {
title: None,
new_title: Some("First".to_string()),
position: Some(1),
parent: None,
},
SidebarMatch {
path: Some(PathMatch::exact("test.md".to_string())),
title: None,
new_title: Some("Second".to_string()),
position: Some(2),
parent: None,
},
],
};
Expand All @@ -871,6 +895,7 @@ mod tests {
title: None,
new_title: None,
position: Some(42),
parent: None,
}],
};

Expand Down Expand Up @@ -898,6 +923,7 @@ mod tests {
title: None,
new_title: Some("Custom".to_string()),
position: None,
parent: None,
}],
};

Expand Down
8 changes: 8 additions & 0 deletions crates/ndg-config/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ max_heading_level = 3
# path = "getting-started.md"
# position = 1

# Explicit sub-chapter: parent is a Markdown source path relative to input_dir.
# Position orders this page among its siblings.
# [[sidebar.matches]]
# path = "guides/installation.md"
# parent = "getting-started.md"
# position = 1

# Exact title match with override (shorthand syntax)
# [[sidebar.matches]]
# title = "Release Notes"
Expand Down Expand Up @@ -288,6 +295,7 @@ pub const DEFAULT_JSON_TEMPLATE: &str = r#"{
"matches": [
{
"path": "getting-started.md",
"parent": null,
"position": 1
},
{
Expand Down
Loading
Loading