From 8ed2f788535dc15b0da6ae75a738edd15be82f6f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 3 Oct 2026 14:55:16 +0300 Subject: [PATCH 1/3] ndg-config: add explicit sidebar parent rules Signed-off-by: NotAShelf Change-Id: I7069005481bb4d2b55c1b256c3bf3e036a6a6964 --- crates/ndg-config/src/sidebar.rs | 32 +++++++++++++++++++++++++++--- crates/ndg-config/src/templates.rs | 8 ++++++++ ndg/tests/integration.rs | 4 ++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/ndg-config/src/sidebar.rs b/crates/ndg-config/src/sidebar.rs index b868712d7..ffa6d8157 100644 --- a/crates/ndg-config/src/sidebar.rs +++ b/crates/ndg-config/src/sidebar.rs @@ -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))?; } @@ -357,9 +365,14 @@ pub struct SidebarMatch { #[serde(skip_serializing_if = "Option::is_none")] pub new_title: Option, - /// Custom position in sidebar. + /// Custom integer position among siblings in the sidebar. #[serde(skip_serializing_if = "Option::is_none")] pub position: Option, + + /// 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, } impl SidebarMatch { @@ -692,6 +705,7 @@ mod tests { title: None, new_title: None, position: Some(1), + parent: None, }; assert!(m.matches("getting-started.md", "Any Title")); @@ -705,6 +719,7 @@ mod tests { title: None, new_title: None, position: Some(50), + parent: None, }; m.compile_regexes().expect("regex should compile"); @@ -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")); @@ -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"); @@ -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"); @@ -767,6 +785,7 @@ mod tests { title: None, new_title: None, position: Some(42), + parent: None, }; assert_eq!(m.get_position(), Some(42)); @@ -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")); @@ -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, }, ], }; @@ -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, }, ], }; @@ -871,6 +895,7 @@ mod tests { title: None, new_title: None, position: Some(42), + parent: None, }], }; @@ -898,6 +923,7 @@ mod tests { title: None, new_title: Some("Custom".to_string()), position: None, + parent: None, }], }; diff --git a/crates/ndg-config/src/templates.rs b/crates/ndg-config/src/templates.rs index 4a11e72a7..92255a5f7 100644 --- a/crates/ndg-config/src/templates.rs +++ b/crates/ndg-config/src/templates.rs @@ -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" @@ -288,6 +295,7 @@ pub const DEFAULT_JSON_TEMPLATE: &str = r#"{ "matches": [ { "path": "getting-started.md", + "parent": null, "position": 1 }, { diff --git a/ndg/tests/integration.rs b/ndg/tests/integration.rs index e1291b76a..2d61f04ba 100644 --- a/ndg/tests/integration.rs +++ b/ndg/tests/integration.rs @@ -263,6 +263,7 @@ fn test_sidebar_integration() { title: None, new_title: Some("Setup Guide".to_string()), position: Some(1), + parent: None, }, SidebarMatch { path: Some(PathMatch { @@ -273,6 +274,7 @@ fn test_sidebar_integration() { title: None, new_title: None, position: Some(3), + parent: None, }, SidebarMatch { path: Some(PathMatch { @@ -283,6 +285,7 @@ fn test_sidebar_integration() { title: None, new_title: None, position: Some(2), + parent: None, }, ], toc: SidebarTocConfig::default(), @@ -332,6 +335,7 @@ fn test_sidebar_integration() { }), new_title: Some("📖 Guide".to_string()), position: None, + parent: None, }], toc: SidebarTocConfig::default(), options: None, From 3a8891bd66adf68a185dea71b7e281b377c265d6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 3 Oct 2026 18:33:18 +0300 Subject: [PATCH 2/3] ndg-html: render nested sidebar chapters Signed-off-by: NotAShelf Change-Id: I115bf0f92cbcc6c885c0abf0d7e1bd3c6a6a6964 --- crates/ndg-html/src/template.rs | 622 ++++++++++----------- crates/ndg-html/tests/html_template.rs | 84 +++ crates/ndg-templates/templates/default.css | 5 + 3 files changed, 400 insertions(+), 311 deletions(-) diff --git a/crates/ndg-html/src/template.rs b/crates/ndg-html/src/template.rs index 2348665e9..587d7580e 100644 --- a/crates/ndg-html/src/template.rs +++ b/crates/ndg-html/src/template.rs @@ -1,7 +1,9 @@ use std::{ + borrow::Cow, + cmp::Ordering, fmt::Write, fs, - path::Path, + path::{Path, PathBuf}, sync::{Arc, LazyLock, RwLock}, }; @@ -495,7 +497,7 @@ pub fn render( String::new() }; - let doc_nav = generate_doc_nav(config, rel_path); + let doc_nav = generate_doc_nav(config, rel_path)?; // Check if options are available let has_options = if config.module_options.is_some() { @@ -677,7 +679,7 @@ pub(crate) fn render_options_body( let tera = setup_tera_templates(config, "options", &options_template)?; // Generate document navigation and paths - let doc_nav = generate_doc_nav(config, root_path); + let doc_nav = generate_doc_nav(config, root_path)?; let custom_scripts = generate_custom_scripts(config, root_path)?; let asset_paths = generate_asset_paths(root_path); let root_prefix = calculate_root_relative_path(root_path); @@ -748,7 +750,7 @@ pub fn render_lib( // Generate navigation and paths let root_path = Path::new("lib.html"); - let doc_nav = generate_doc_nav(config, root_path); + let doc_nav = generate_doc_nav(config, root_path)?; let custom_scripts = generate_custom_scripts(config, root_path)?; let asset_paths = generate_asset_paths(root_path); let root_prefix = calculate_root_relative_path(root_path); @@ -1058,7 +1060,7 @@ pub fn render_search( // Generate navigation and paths (search page is always at root) let root_path = Path::new("search.html"); - let doc_nav = generate_doc_nav(config, root_path); + let doc_nav = generate_doc_nav(config, root_path)?; let custom_scripts = generate_custom_scripts(config, root_path)?; // Check if options are available @@ -1194,25 +1196,81 @@ fn load_template_content( Ok(fallback.to_string()) } -/// Represents a navigation item with its metadata for rendering +/// Represents a discovered source page and its navigation metadata. struct NavItem { + source: PathBuf, path: String, title: String, - /// Parent directory relative to `input_dir` (). - rel_dir: String, // empty string for root items + /// Output directory used by the existing directory grouping mode. + rel_dir: String, position: Option, number: Option, + special: bool, } -/// Render a single navigation item as a `
  • ` element, with optional -/// numbering. -fn render_nav_item(output: &mut String, item: &NavItem) { +fn render_nav_link(output: &mut String, item: &NavItem, prefix: &str) { let path = encode_double_quoted_attribute(&item.path); let title = encode_text(&item.title); if let Some(num) = item.number { - let _ = writeln!(output, "
  • {num}. {title}
  • "); + let _ = write!(output, "
  • {prefix}{num}. {title}"); } else { - let _ = writeln!(output, "
  • {title}
  • "); + let _ = write!(output, "
  • {title}"); + } +} + +fn render_nav_item(output: &mut String, item: &NavItem) { + render_nav_link(output, item, ""); + let _ = writeln!(output, "
  • "); +} + +/// Render with an explicit stack and one reusable ancestor-number buffer. +fn render_hierarchy( + output: &mut String, + items: &[NavItem], + roots: &[usize], + children: &[Vec], +) { + struct Frame<'a> { + siblings: &'a [usize], + next: usize, + prefix_len: usize, + } + + let mut stack = vec![Frame { + siblings: roots, + next: 0, + prefix_len: 0, + }]; + let mut prefix = String::new(); + while let Some(frame) = stack.last_mut() { + if frame.next == frame.siblings.len() { + let prefix_len = frame.prefix_len; + stack.pop(); + prefix.truncate(prefix_len); + if !stack.is_empty() { + let _ = writeln!(output, ""); + } + continue; + } + + let index = frame.siblings[frame.next]; + frame.next += 1; + let item = &items[index]; + render_nav_link(output, item, &prefix); + if children[index].is_empty() { + let _ = writeln!(output, ""); + } else { + let prefix_len = prefix.len(); + if let Some(number) = item.number { + let _ = write!(prefix, "{number}."); + } + let _ = writeln!(output, "\n
      "); + stack.push(Frame { + siblings: &children[index], + next: 0, + prefix_len, + }); + } } } @@ -1221,42 +1279,40 @@ fn render_nav_item(output: &mut String, item: &NavItem) { /// Items at the root (empty `rel_dir`) are rendered flat. Items in a /// subdirectory are wrapped in a collapsible `
      ` element with a /// `` label derived from the directory name. -fn render_grouped(output: &mut String, items: Vec, show_counts: bool) { +fn render_grouped( + output: &mut String, + items: &[NavItem], + indices: &[usize], + show_counts: bool, +) { use std::collections::BTreeMap; - // Separate root items from directory-grouped items. - let mut root_items: Vec = Vec::new(); - // BTreeMap preserves directory insertion order (sorted by dir name). - let mut dir_groups: BTreeMap> = BTreeMap::new(); + // BTreeMap preserves alphabetical directory ordering. + let mut dir_groups: BTreeMap<&str, Vec> = BTreeMap::new(); - for item in items { + for &index in indices { + let item = &items[index]; if item.rel_dir.is_empty() { - root_items.push(item); + render_nav_item(output, item); } else { // Use only the first path component as the group label so that // `features/foo/bar.md` is still grouped under `features`. - let top_dir = std::path::Path::new(&item.rel_dir) + let top_dir = Path::new(&item.rel_dir) .components() .next() .and_then(|c| c.as_os_str().to_str()) - .unwrap_or(&item.rel_dir) - .to_string(); - dir_groups.entry(top_dir).or_default().push(item); + .unwrap_or(&item.rel_dir); + dir_groups.entry(top_dir).or_default().push(index); } } - // Render root items flat. - for item in root_items { - render_nav_item(output, &item); - } - // Render each directory group as a collapsible block. for (dir_name, group_items) in dir_groups { // Capitalise the first letter of the directory name for the label. let label = { let mut chars = dir_name.chars(); chars.next().map_or_else( - || dir_name.clone(), + || dir_name.to_owned(), |first| first.to_uppercase().collect::() + chars.as_str(), ) }; @@ -1273,317 +1329,261 @@ fn render_grouped(output: &mut String, items: Vec, show_counts: bool) { class=\"sidebar-dir-label\">{label}{count_badge}
        " ); - for item in group_items { - render_nav_item(output, &item); + for index in group_items { + render_nav_item(output, &items[index]); } let _ = writeln!(output, "
      "); } } -struct DocumentNavigation { - special_items: Vec, - regular_items: Vec, - number_special: bool, - show_group_counts: bool, -} - -trait DocumentNavigationGenerator { - fn generate(&self, output: &mut String, navigation: DocumentNavigation); -} - -struct FlatDocumentNavigationGenerator; - -impl DocumentNavigationGenerator for FlatDocumentNavigationGenerator { - fn generate(&self, output: &mut String, navigation: DocumentNavigation) { - let DocumentNavigation { - mut special_items, - regular_items, - number_special, - .. - } = navigation; - if number_special { - special_items.extend(regular_items); - for (index, item) in special_items.iter_mut().enumerate() { - item.number = Some(index + 1); +/// Sort one sibling set, retaining the legacy special-first root order. +fn order_nav_siblings( + items: &mut [NavItem], + siblings: &mut [usize], + ordering: SidebarOrdering, + numbered: bool, + number_special: bool, + roots: bool, +) { + if roots || !matches!(ordering, SidebarOrdering::Filesystem) { + siblings.sort_by(|&a, &b| { + let a = &items[a]; + let b = &items[b]; + if roots { + let special_order = b.special.cmp(&a.special); + if !special_order.is_eq() { + return special_order; + } + } + match ordering { + SidebarOrdering::Alphabetical => a.title.cmp(&b.title), + SidebarOrdering::Custom => { + match (a.position, b.position) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a.title.cmp(&b.title), + } + }, + SidebarOrdering::Filesystem => Ordering::Equal, } - for item in special_items { - render_nav_item(output, &item); + }); + } + + if numbered { + let mut number = 0; + for &index in siblings.iter() { + let item = &mut items[index]; + if !item.special || number_special { + number += 1; + item.number = Some(number); } - return; - } - for item in special_items.into_iter().chain(regular_items) { - render_nav_item(output, &item); } } } -struct GroupedDocumentNavigationGenerator; - -impl DocumentNavigationGenerator for GroupedDocumentNavigationGenerator { - fn generate(&self, output: &mut String, navigation: DocumentNavigation) { - let DocumentNavigation { - mut special_items, - regular_items, - number_special, - show_group_counts, - } = navigation; - if number_special { - special_items.extend(regular_items); - for (index, item) in special_items.iter_mut().enumerate() { - item.number = Some(index + 1); +/// Each parent edge is visited once; no recursive ancestor walking is needed. +fn validate_nav_parents( + items: &[NavItem], + parents: &[Option], +) -> Result<()> { + let mut states = vec![0_u8; items.len()]; + let mut chain = Vec::with_capacity(items.len()); + for start in 0..items.len() { + if states[start] != 0 { + continue; + } + let mut current = Some(start); + while let Some(index) = current { + match states[index] { + 0 => { + states[index] = 1; + chain.push(index); + current = parents[index]; + }, + 1 => { + let mut cycle = String::new(); + for node in chain.iter().copied().skip_while(|&node| node != index) { + let _ = write!(cycle, "`{}` -> ", items[node].source.display()); + } + let _ = write!(cycle, "`{}`", items[index].source.display()); + bail!("sidebar parent cycle: {cycle}"); + }, + _ => break, } - render_grouped(output, special_items, show_group_counts); - return; } - for item in special_items { - render_nav_item(output, &item); + for &index in &chain { + states[index] = 2; } - render_grouped(output, regular_items, show_group_counts); + chain.clear(); } + Ok(()) } -/// Generate the document navigation HTML -fn generate_doc_nav(config: &Config, current_file_rel_path: &Path) -> String { +/// Generate document navigation using source paths for parent identity and +/// final output paths for links. +fn generate_doc_nav( + config: &Config, + current_file_rel_path: &Path, +) -> Result { + let sidebar = config.sidebar.as_ref(); + let group_by_dir = sidebar.is_some_and(|s| s.group_by_dir); + if group_by_dir + && sidebar.is_some_and(|s| s.matches.iter().any(|m| m.parent.is_some())) + { + bail!("sidebar group_by_dir cannot be combined with parent matches"); + } + let mut doc_nav = String::new(); let root_prefix = calculate_root_relative_path(current_file_rel_path); - - // Only process markdown files if input_dir is provided if let Some(input_dir) = &config.input_dir { - let entries: Vec<_> = walkdir::WalkDir::new(input_dir) - .follow_links(true) - .into_iter() - .filter_map(std::result::Result::ok) - .filter(|e| { - if !e.path().is_file() - || e.path().extension().is_none_or(|ext| ext != "md") - { - return false; - } - - // Filter out included fragments unless they produce their own page. - e.path().strip_prefix(input_dir).is_ok_and(|rel_path| { - !config.included_files.contains_key(rel_path) - || config.included_output_files.contains_key(rel_path) - }) - }) - .collect(); - - if !entries.is_empty() { - // Partition entries into special and regular - let mut special_entries = Vec::new(); - let mut regular_entries = Vec::new(); - - for entry in &entries { - let path = entry.path(); - if let Ok(rel_doc_path) = path.strip_prefix(input_dir) { - let file_name = rel_doc_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - if file_name == "index.md" || file_name == "readme.md" { - special_entries.push(entry); - } else { - regular_entries.push(entry); - } - } + let mut items = Vec::new(); + let mut requested_parents = Vec::new(); + for entry in walkdir::WalkDir::new(input_dir).follow_links(true) { + let entry = entry.wrap_err_with(|| { + format!( + "failed to discover sidebar pages in {}", + input_dir.display() + ) + })?; + let path = entry.path(); + if !path.is_file() || path.extension().is_none_or(|ext| ext != "md") { + continue; + } + let source = path.strip_prefix(input_dir).wrap_err_with(|| { + format!( + "sidebar source {} is outside {}", + path.display(), + input_dir.display() + ) + })?; + // Included fragments only become visible when they generate a page. + if config.included_files.contains_key(source) + && !config.included_output_files.contains_key(source) + { + continue; } - // Process regular entries with sidebar configuration - let mut nav_items: Vec = regular_entries - .iter() - .filter_map(|entry| { - let path = entry.path(); - let rel_doc_path = path.strip_prefix(input_dir).ok()?; - let html_path = config - .included_output_files - .get(rel_doc_path) - .cloned() - .unwrap_or_else(|| { - let mut html_path = rel_doc_path.to_path_buf(); - html_path.set_extension("html"); - html_path - }); - - let target_path = - format!("{}{}", root_prefix, html_path.to_string_lossy()); - - // Extract page title - let page_title = - ndg_utils::markdown::extract_page_title(path, &html_path); - - // Apply sidebar configuration if available - let (display_title, position) = - if let Some(sidebar_config) = &config.sidebar { - let path_str = rel_doc_path.to_string_lossy(); - if let Some(matched_rule) = - sidebar_config.find_match(&path_str, &page_title) - { - let title = matched_rule - .get_title() - .map_or_else(|| page_title.clone(), String::from); - let pos = matched_rule.get_position(); - (title, pos) - } else { - (page_title, None) - } - } else { - (page_title, None) - }; - - Some(NavItem { - path: target_path, - title: display_title, - rel_dir: html_path - .parent() - .and_then(|p| p.to_str()) - .unwrap_or("") - .to_string(), - position, - number: None, - }) - }) - .collect(); - - // Sort items based on sidebar ordering configuration - if let Some(sidebar_config) = &config.sidebar { - use ndg_config::sidebar::SidebarOrdering; - match sidebar_config.ordering { - SidebarOrdering::Alphabetical => { - nav_items.sort_by(|a, b| a.title.cmp(&b.title)); - }, - SidebarOrdering::Custom => { - // Sort by position first, then alphabetically - nav_items.sort_by(|a, b| { - match (a.position, b.position) { - (Some(pos_a), Some(pos_b)) => pos_a.cmp(&pos_b), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.title.cmp(&b.title), - } - }); - }, - SidebarOrdering::Filesystem => { - // Keep filesystem order (already in order thanks to walkdir) - }, - } + let html_path = config.included_output_files.get(source).map_or_else( + || Cow::Owned(source.with_extension("html")), + |output| Cow::Borrowed(output.as_path()), + ); + let page_title = + ndg_utils::markdown::extract_page_title(path, &html_path); + let matched_rule = sidebar + .and_then(|s| s.find_match(&source.to_string_lossy(), &page_title)); + let title = matched_rule + .and_then(|m| m.get_title()) + .map_or(page_title, String::from); + let special = source + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.eq_ignore_ascii_case("index.md") + || name.eq_ignore_ascii_case("readme.md") + }); + items.push(NavItem { + source: source.to_path_buf(), + path: format!("{root_prefix}{}", html_path.to_string_lossy()), + title, + rel_dir: if group_by_dir { + html_path + .parent() + .and_then(Path::to_str) + .unwrap_or("") + .to_owned() + } else { + String::new() + }, + position: matched_rule.and_then(|m| m.position), + number: None, + special, + }); + requested_parents.push(matched_rule.and_then(|m| m.parent.as_deref())); + } - // Add numbering if enabled - if sidebar_config.numbered { - for (idx, item) in nav_items.iter_mut().enumerate() { - item.number = Some(idx + 1); - } + let source_indices: FxHashMap<&Path, usize> = items + .iter() + .enumerate() + .map(|(index, item)| (item.source.as_path(), index)) + .collect(); + let mut parents = Vec::with_capacity(items.len()); + for (index, requested) in requested_parents.into_iter().enumerate() { + let parent = if let Some(source) = requested { + let Some(&parent) = source_indices.get(Path::new(source)) else { + bail!( + "sidebar source `{}` has parent `{source}`, which is not a \ + visible Markdown source page", + items[index].source.display() + ); + }; + if parent == index { + bail!( + "sidebar source `{}` cannot be its own parent", + items[index].source.display() + ); } + Some(parent) } else { - // Default: alphabetical sorting - nav_items.sort_by(|a, b| a.title.cmp(&b.title)); - } - - // Process special entries - let mut special_nav_items: Vec = special_entries - .iter() - .filter_map(|entry| { - let path = entry.path(); - let rel_doc_path = path.strip_prefix(input_dir).ok()?; - let html_path = config - .included_output_files - .get(rel_doc_path) - .cloned() - .unwrap_or_else(|| { - let mut html_path = rel_doc_path.to_path_buf(); - html_path.set_extension("html"); - html_path - }); - - let target_path = - format!("{}{}", root_prefix, html_path.to_string_lossy()); - - let page_title = - ndg_utils::markdown::extract_page_title(path, &html_path); - - // Apply sidebar configuration to special files if available - let (display_title, position) = - if let Some(sidebar_config) = &config.sidebar { - let path_str = rel_doc_path.to_string_lossy(); - if let Some(matched_rule) = - sidebar_config.find_match(&path_str, &page_title) - { - let title = matched_rule - .get_title() - .map_or_else(|| page_title.clone(), String::from); - let position = matched_rule.get_position(); - (title, position) - } else { - (page_title, None) - } - } else { - (page_title, None) - }; - - Some(NavItem { - path: target_path, - title: display_title, - rel_dir: html_path - .parent() - .and_then(|p| p.to_str()) - .unwrap_or("") - .to_string(), - position, - number: None, - }) - }) - .collect(); - - // Sort special entries according to sidebar ordering configuration - if let Some(sidebar_config) = &config.sidebar { - match sidebar_config.ordering { - SidebarOrdering::Alphabetical => { - special_nav_items.sort_by(|a, b| a.title.cmp(&b.title)); - }, - SidebarOrdering::Custom => { - // Sort by position first, then alphabetically. - special_nav_items.sort_by(|a, b| { - match (a.position, b.position) { - (Some(pos_a), Some(pos_b)) => pos_a.cmp(&pos_b), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.title.cmp(&b.title), - } - }); - }, - SidebarOrdering::Filesystem => { - // Filesystem ordering keeps the order from directory iteration - }, - } + None + }; + parents.push(parent); + } + drop(source_indices); + validate_nav_parents(&items, &parents)?; + + let mut roots = Vec::new(); + let mut children = vec![Vec::new(); items.len()]; + for (index, parent) in parents.into_iter().enumerate() { + if let Some(parent) = parent { + children[parent].push(index); } else { - // Default: alphabetical sorting - special_nav_items.sort_by(|a, b| a.title.cmp(&b.title)); + roots.push(index); } + } + let ordering = + sidebar.map_or(SidebarOrdering::Alphabetical, |s| s.ordering); + let numbered = sidebar.is_some_and(|s| s.numbered); + let number_special = sidebar.is_some_and(|s| s.number_special_files); + order_nav_siblings( + &mut items, + &mut roots, + ordering, + numbered, + number_special, + true, + ); + for siblings in &mut children { + order_nav_siblings( + &mut items, + siblings, + ordering, + numbered, + number_special, + false, + ); + } - // Determine if we should number special files - let should_number_special = config - .sidebar - .as_ref() - .is_some_and(|s| s.numbered && s.number_special_files); - - let group_by_dir = - config.sidebar.as_ref().is_some_and(|s| s.group_by_dir); - - let show_group_counts = - config.sidebar.as_ref().is_some_and(|s| s.show_group_counts); - - let navigation = DocumentNavigation { - special_items: special_nav_items, - regular_items: nav_items, - number_special: should_number_special, - show_group_counts, - }; - if group_by_dir { - GroupedDocumentNavigationGenerator.generate(&mut doc_nav, navigation); + if group_by_dir { + let show_counts = sidebar.is_some_and(|s| s.show_group_counts); + if numbered && number_special { + render_grouped(&mut doc_nav, &items, &roots, show_counts); } else { - FlatDocumentNavigationGenerator.generate(&mut doc_nav, navigation); + let regular_start = + roots.partition_point(|&index| items[index].special); + for &index in &roots[..regular_start] { + render_nav_item(&mut doc_nav, &items[index]); + } + render_grouped( + &mut doc_nav, + &items, + &roots[regular_start..], + show_counts, + ); } + } else { + render_hierarchy(&mut doc_nav, &items, &roots, &children); } } @@ -1611,7 +1611,7 @@ fn generate_doc_nav(config: &Config, current_file_rel_path: &Path) -> String { ); } - doc_nav + Ok(doc_nav) } /// Generate custom scripts HTML diff --git a/crates/ndg-html/tests/html_template.rs b/crates/ndg-html/tests/html_template.rs index 82c1e8cd4..0d6fb5ea8 100644 --- a/crates/ndg-html/tests/html_template.rs +++ b/crates/ndg-html/tests/html_template.rs @@ -1306,6 +1306,90 @@ fn sidebar_navigation_escapes_titles_and_urls() { assert!(!html.contains(">")); } +#[test] +fn test_sidebar_subchapters_order_number_and_link_by_source_path() { + let temp = TempDir::new().expect("create temp dir"); + for (path, title) in [ + ("index.md", "Introduction"), + ("configuration.md", "Configuration"), + ("session.md", "Session Management"), + ("alpha.md", "Alpha"), + ("advanced.md", "Advanced"), + ] { + fs::write(temp.path().join(path), format!("# {title}\n")) + .expect("write chapter"); + } + let mut config = minimal_config(); + config.input_dir = Some(temp.path().to_path_buf()); + config.sidebar = Some(serde_json::from_value(json!({ + "numbered": true, + "ordering": "custom", + "matches": [ + {"path": {"exact": "configuration.md"}, "position": 4}, + {"path": {"exact": "session.md"}, "parent": "configuration.md", "position": 1}, + {"path": {"exact": "alpha.md"}, "parent": "configuration.md", "position": 2}, + {"path": {"exact": "advanced.md"}, "parent": "session.md"} + ] + })).expect("parse chapter rules")); + config.included_output_files.insert( + PathBuf::from("session.md"), + PathBuf::from("topics/session.html"), + ); + let html = template::render( + &config, + "

      Body

      ", + "Advanced", + &[], + Path::new("topics/advanced.html"), + None, + ) + .expect("render chapters"); + let hierarchy = regex::Regex::new(concat!( + r#"
    • 1\. Configuration\s*"#, + r#"\s*
    • "#, + )) + .expect("compile hierarchy assertion"); + assert!(hierarchy.is_match(&html), "incorrect chapter hierarchy"); + assert!(html.contains("href=\"../index.html\">Introduction")); +} + +#[test] +fn test_sidebar_subchapters_reject_parent_cycles() { + let temp = TempDir::new().expect("create temp dir"); + fs::write(temp.path().join("a.md"), "# A\n").expect("write A"); + fs::write(temp.path().join("b.md"), "# B\n").expect("write B"); + let mut config = minimal_config(); + config.input_dir = Some(temp.path().to_path_buf()); + config.sidebar = Some( + serde_json::from_value(json!({ + "matches": [ + {"path": {"exact": "a.md"}, "parent": "b.md"}, + {"path": {"exact": "b.md"}, "parent": "a.md"} + ] + })) + .expect("parse cyclic rules"), + ); + let error = template::render( + &config, + "

      Body

      ", + "A", + &[], + Path::new("a.html"), + None, + ) + .expect_err("reject parent cycle") + .to_string(); + assert!(error.contains("cycle"), "{error}"); + assert!(error.contains("a.md"), "{error}"); + assert!(error.contains("b.md"), "{error}"); +} + // Regression test for bug where included files appeared in sidebar navigation. // Included files should never appear as standalone entries in the sidebar // because they don't have their own HTML pages generated diff --git a/crates/ndg-templates/templates/default.css b/crates/ndg-templates/templates/default.css index b1f25c26c..b19f6e502 100644 --- a/crates/ndg-templates/templates/default.css +++ b/crates/ndg-templates/templates/default.css @@ -397,6 +397,11 @@ header { margin: var(--space-3) 0; } + ul.sidebar-subchapters { + padding-inline-start: var(--space-4); + margin: var(--space-1) 0; + } + li { margin-bottom: var(--space-1); } From ba4df9dd4e913e1d60b7ff0126e5c9f4ec26f92d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 3 Oct 2026 18:33:30 +0300 Subject: [PATCH 3/3] docs: document sidebar sub-chapters Signed-off-by: NotAShelf Change-Id: I53a4cbec522761df84872859143ee1e86a6a6964 --- CHANGELOG.md | 9 ++++ ndg/README.md | 23 ++++++++-- ndg/docs/SIDEBAR.md | 107 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f643cec3..afe438bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ndg/README.md b/ndg/README.md index 685a843f8..3e833969f 100644 --- a/ndg/README.md +++ b/ndg/README.md @@ -915,8 +915,8 @@ templating. If your use-case is not supported, feel free to request a feature! ### Sidebar Customization NDG allows you to customize the sidebar navigation through flexible -configuration options. You can control item ordering, add numbering, and -customize titles using pattern-based matching: +configuration options. You can control item ordering, add numbering, define +sub-chapters, and customize titles using pattern-based matching: ```toml [sidebar] @@ -932,6 +932,11 @@ position = 1 path = "installation.md" new_title = "📦 Installation" position = 2 + +[[sidebar.matches]] +path = "installation/requirements.md" +parent = "installation.md" +position = 1 ``` This enables features like: @@ -940,6 +945,17 @@ This enables features like: - **Custom ordering** (alphabetical, custom position-based, or filesystem) - **Pattern-based title customization** using exact or regex matching - **Flexible positioning** for specific documentation items +- **Sub-chapters** with clickable parents, indentation, and hierarchical numbers + such as `2.1` and `2.1.1` + +`parent` is a Markdown source path relative to `input_dir`, not a title or HTML +URL. Integer `position` values order siblings; use `parent`, not decimal +positions, to define the hierarchy. The first matching rule controls all of a +page's sidebar settings. Disabling numbering keeps the nested structure. + +> [!NOTE] +> Parents must be visible standalone pages. Missing parents, self-parenting, and +> cycles are errors. Explicit parents cannot be combined with `group_by_dir`. For documentation generated from Nix module options, you can also customize the options sidebar to control visibility, naming, ordering, and depth of option @@ -1389,7 +1405,8 @@ regular documentation pages. a fallback index listing all documents 2. **Sidebar rendering** (both `index.md` and `README.md`): - - Always appear **first** in the sidebar navigation + - Appear **first** among root items; a special page with `parent` stays under + that parent - **Excluded from numbering by default** when `numbered = true` in sidebar config - Can be included in numbering by setting `number_special_files = true` diff --git a/ndg/docs/SIDEBAR.md b/ndg/docs/SIDEBAR.md index cc95ac4ec..416b810a8 100644 --- a/ndg/docs/SIDEBAR.md +++ b/ndg/docs/SIDEBAR.md @@ -31,14 +31,16 @@ ordering = "alphabetical" ### `numbered` -Controls whether sidebar items are numbered sequentially. +Controls whether sidebar items are numbered within each sibling group. - **Type**: Boolean - **Default**: `false` When enabled, items display as "1. Title", "2. Title", etc. Special files (`index.md`, `README.md`) are excluded from numbering by default unless -`number_special_files` is enabled. +`number_special_files` is enabled. Sub-chapters use hierarchical numbers such as +"2.1. Title" and "2.1.1. Title". Setting `numbered = false` removes the numbers +without changing the hierarchy. **Example:** @@ -82,7 +84,7 @@ Getting Started # index.md - not numbered ### `ordering` -Determines how sidebar items are sorted. +Determines how sidebar items are sorted within each sibling group. - **Type**: String - **Default**: `"alphabetical"` @@ -145,10 +147,11 @@ Each `[[sidebar.matches]]` entry supports: #### Action Fields -| Field | Type | Description | -| ----------- | ------- | ------------------------------------------------- | -| `new_title` | String | Custom title to display in the sidebar | -| `position` | Integer | Custom position (used when `ordering = "custom"`) | +| Field | Type | Description | +| ----------- | ------- | ------------------------------------------ | +| `new_title` | String | Custom title to display in the sidebar | +| `position` | Integer | Sibling sort key for `ordering = "custom"` | +| `parent` | String | Input-relative parent Markdown source path | ### Matching Logic @@ -208,6 +211,96 @@ path = "api-reference.md" position = 3 ``` +### Sub-chapters + +Set `parent` on a page's matching rule to nest it under another page. Both +`path` and `parent` identify Markdown source paths relative to `input_dir`. They +do not identify page titles or generated HTML URLs, even when an included file +has a custom output path. Pages without a parent remain root items. + +For example, create these source files with the titles shown below: + +```plaintext +docs/index.md # Introduction +docs/getting-started.md # Getting Started +docs/installation.md # Installation +docs/usage.md # Usage +docs/configuration.md # Configuration +docs/configuration/session.md # Session Management +docs/configuration/deep/advanced.md # Advanced +docs/configuration/profiles.md # Profiles +``` + +With `input_dir = "docs"`, configure their relationships as follows: + +```toml +[sidebar] +numbered = true +ordering = "custom" + +[[sidebar.matches]] +path = "getting-started.md" +position = 1 + +[[sidebar.matches]] +path = "installation.md" +position = 2 + +[[sidebar.matches]] +path = "usage.md" +position = 3 + +[[sidebar.matches]] +path = "configuration.md" +position = 4 + +[[sidebar.matches]] +path = "configuration/session.md" +parent = "configuration.md" +position = 1 + +[[sidebar.matches]] +path = "configuration/deep/advanced.md" +parent = "configuration/session.md" +position = 1 + +[[sidebar.matches]] +path = "configuration/profiles.md" +parent = "configuration.md" +position = 2 +``` + +The sidebar renders clickable parent pages with indented sub-chapters: + +```plaintext +Introduction +1. Getting Started +2. Installation +3. Usage +4. Configuration + 4.1. Session Management + 4.1.1. Advanced + 4.2. Profiles +``` + +`position` is an integer sort key, not a displayed chapter number. Each sibling +group has its own numbering sequence. Use `parent` to define the hierarchy; +decimal positions such as `4.1` are invalid. The first matching rule controls +the page's parent, title, and position, so put specific rules before broad +rules. + +Special root pages such as `index.md` still appear first. A special page with a +parent stays under that parent. Unless `number_special_files = true`, special +pages have no number and do not consume a sibling number. Their descendants +inherit the nearest numbered ancestor's prefix, or start at `1` beneath an +unnumbered root. + +The parent must be a page that appears in navigation. Included fragments cannot +be parents unless they produce their own standalone page. NDG reports an error +for missing parents, self-parenting, and parent cycles. Explicit parents cannot +be combined with `sidebar.group_by_dir = true`, which groups pages by directory +instead of page relationships. + ### Custom Titles ```toml