diff --git a/Cargo.lock b/Cargo.lock index b0858f6b..57fb15bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1907,6 +1907,7 @@ dependencies = [ "color-eyre", "grass", "html-escape", + "indexmap 2.13.0", "log", "ndg-commonmark", "ndg-config", diff --git a/Cargo.toml b/Cargo.toml index c65a71ec..20db412a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ walkdir = "2.5.0" html-escape = "0.2.13" html5ever = "0.38.0" +indexmap = "2.13.0" kuchikikiki = "0.9.2" lightningcss = { version = "1.0.0-alpha.71", default-features = false } markup5ever = "0.38.0" diff --git a/crates/ndg-config/src/config.rs b/crates/ndg-config/src/config.rs index e8169c69..ff7b125d 100644 --- a/crates/ndg-config/src/config.rs +++ b/crates/ndg-config/src/config.rs @@ -2,7 +2,6 @@ use std::{ collections::HashMap, fs, path::{Path, PathBuf}, - sync::OnceLock, }; use ndg_macros::Configurable; @@ -449,22 +448,18 @@ impl Config { "The 'opengraph' config field is deprecated. Use 'meta.opengraph' \ instead." ); - if let Some(ref mut og) = self.opengraph { - og.extend(other_og); - } else { - self.opengraph = Some(other_og); - } + + // Later value wins + self.opengraph = Some(other_og); } if let Some(other_meta) = other.meta_tags.take() { log::warn!( "The 'meta_tags' config field is deprecated. Use 'meta.tags' \ instead." ); - if let Some(ref mut meta) = self.meta_tags { - meta.extend(other_meta); - } else { - self.meta_tags = Some(other_meta); - } + + // Later value wins + self.meta_tags = Some(other_meta); } } @@ -530,52 +525,51 @@ impl Config { None } - /// Search for config files in common locations + /// Search for config files in common locations. + /// + /// This performs a fresh filesystem lookup on every call, and the result is + /// not cached so that tests calling from different working directories each + /// see the correct config for their directory. #[must_use] pub fn find_config_file() -> Option { - static RESULT: OnceLock> = OnceLock::new(); - RESULT - .get_or_init(|| { - let config_filenames = [ - "ndg.toml", - "ndg.json", - ".ndg.toml", - ".ndg.json", - ".config/ndg.toml", - ".config/ndg.json", - ]; - - let current_dir = std::env::current_dir().ok()?; - for filename in &config_filenames { - let config_path = current_dir.join(filename); - if config_path.exists() { - return Some(config_path); - } - } + let config_filenames = [ + "ndg.toml", + "ndg.json", + ".ndg.toml", + ".ndg.json", + ".config/ndg.toml", + ".config/ndg.json", + ]; + + let current_dir = std::env::current_dir().ok()?; + for filename in &config_filenames { + let config_path = current_dir.join(filename); + if config_path.exists() { + return Some(config_path); + } + } - if let Ok(xdg_config_home) = std::env::var("XDG_CONFIG_HOME") { - let xdg_config_dir = PathBuf::from(xdg_config_home); - for filename in &["ndg.toml", "ndg.json"] { - let config_path = xdg_config_dir.join(filename); - if config_path.exists() { - return Some(config_path); - } - } + if let Ok(xdg_config_home) = std::env::var("XDG_CONFIG_HOME") { + let xdg_config_dir = PathBuf::from(xdg_config_home).join("ndg"); + for filename in &["config.toml", "config.json"] { + let config_path = xdg_config_dir.join(filename); + if config_path.exists() { + return Some(config_path); } + } + } - if let Ok(home) = std::env::var("HOME") { - let home_config_dir = PathBuf::from(home).join(".config").join("ndg"); - for filename in &["config.toml", "config.json"] { - let config_path = home_config_dir.join(filename); - if config_path.exists() { - return Some(config_path); - } - } + if let Ok(home) = std::env::var("HOME") { + let home_config_dir = PathBuf::from(home).join(".config").join("ndg"); + for filename in &["config.toml", "config.json"] { + let config_path = home_config_dir.join(filename); + if config_path.exists() { + return Some(config_path); } + } + } - None - }) - .clone() + None } /// Validate all paths specified in the configuration diff --git a/crates/ndg-config/src/sidebar.rs b/crates/ndg-config/src/sidebar.rs index 5304f461..347b1166 100644 --- a/crates/ndg-config/src/sidebar.rs +++ b/crates/ndg-config/src/sidebar.rs @@ -384,6 +384,11 @@ impl SidebarMatch { /// always be validated/compiled via `SidebarConfig::validate()`. #[must_use] pub fn matches(&self, path_str: &str, title_str: &str) -> bool { + // A rule with no conditions matches nothing; require at least one filter. + if self.path.is_none() && self.title.is_none() { + return false; + } + // Check path matching if let Some(ref path_match) = self.path { // Check exact path match @@ -627,6 +632,11 @@ impl OptionsMatch { /// always be validated/compiled via `OptionsConfig::validate()`. #[must_use] pub fn matches(&self, option_name: &str) -> bool { + // A rule with no conditions matches nothing. + if self.name.is_none() { + return false; + } + // Check name matching if let Some(ref name_match) = self.name { // Check exact name match @@ -673,10 +683,7 @@ impl OptionsMatch { /// Check if this option should be hidden from the TOC. #[must_use] pub const fn is_hidden(&self) -> bool { - match self.hidden { - Some(hidden) => hidden, - None => false, - } + matches!(self.hidden, Some(true)) } } diff --git a/crates/ndg-config/src/templates.rs b/crates/ndg-config/src/templates.rs index b9b14c7b..801e3850 100644 --- a/crates/ndg-config/src/templates.rs +++ b/crates/ndg-config/src/templates.rs @@ -68,14 +68,6 @@ footer_text = "Generated with ndg" # Whether to generate anchors for headings generate_anchors = true -# Search configuration -[search] -# Whether to generate a search index -enable = true - -# Maximum heading level to index -max_heading_level = 3 - # Depth of parent categories in options TOC options_toc_depth = 2 @@ -94,6 +86,14 @@ revision = "main" # Additional meta tags to inject into the HTML head (example: { description = "Docs", keywords = "nix,docs" }) # meta_tags = { description = "Documentation for My Project", keywords = "nix,docs,example" } +# Search configuration +[search] +# Whether to generate a search index +enable = true + +# Maximum heading level to index +max_heading_level = 3 + # Sidebar configuration # [sidebar] # Enable numbering for sidebar items diff --git a/crates/ndg-html/Cargo.toml b/crates/ndg-html/Cargo.toml index ab55d0bc..89558edd 100644 --- a/crates/ndg-html/Cargo.toml +++ b/crates/ndg-html/Cargo.toml @@ -25,6 +25,7 @@ ndg-templates.workspace = true ndg-utils.workspace = true color-eyre.workspace = true +indexmap.workspace = true grass.workspace = true html-escape.workspace = true log.workspace = true diff --git a/crates/ndg-html/src/options.rs b/crates/ndg-html/src/options.rs index d79932e2..a63f689f 100644 --- a/crates/ndg-html/src/options.rs +++ b/crates/ndg-html/src/options.rs @@ -5,6 +5,7 @@ use std::{ }; use color_eyre::eyre::{Context, Result}; +use indexmap::IndexMap; use log::debug; use ndg_config::Config; use ndg_manpage::types::NixOption; @@ -168,43 +169,26 @@ pub fn process_options(config: &Config, options_path: &Path) -> Result<()> { } } - // Sort options by priority (enable > package > other) - let mut sorted_options: Vec<_> = options.into_iter().collect(); - sorted_options.sort_by(|(name_a, _), (name_b, _)| { - // Calculate priority for name_a - let priority_a = if name_a.starts_with("enable") { - 0 - } else if name_a.starts_with("package") { - 1 - } else { - 2 - }; - - // Calculate priority for name_b - let priority_b = if name_b.starts_with("enable") { - 0 - } else if name_b.starts_with("package") { - 1 - } else { - 2 + // Sort options by priority (enable > package > other), with secondary + // alphabetical sort within each priority group. + let mut sorted: Vec<_> = options.into_iter().collect(); + sorted.sort_by(|(name_a, _), (name_b, _)| { + let priority = |name: &str| { + if name.starts_with("enable") { + 0 + } else if name.starts_with("package") { + 1 + } else { + 2 + } }; - - // Compare by priority first, then by name - priority_a.cmp(&priority_b).then_with(|| name_a.cmp(name_b)) + priority(name_a) + .cmp(&priority(name_b)) + .then_with(|| name_a.cmp(name_b)) }); - // Convert back to HashMap preserving the new order - let customized_options = sorted_options - .iter() - .map(|(key, opt)| { - let option_clone = opt.clone(); - (key.clone(), option_clone) - }) - .map(|(key, mut opt)| { - opt.name = html_escape::encode_text(&opt.name).to_string(); - (key, opt) - }) - .collect(); + let customized_options: IndexMap = + sorted.into_iter().collect(); // Render options page let html = template::render_options(config, &customized_options)?; @@ -247,9 +231,7 @@ fn format_location( let path_str = path.as_str(); if path_str.starts_with('/') { - // Local filesystem path handling let url = format!("file://{path_str}"); - if path_str.contains("nixops") && path_str.contains("/nix/") { let suffix_index = path_str.find("/nix/").map_or(0, |i| i + 5); let suffix = &path_str[suffix_index..]; diff --git a/crates/ndg-html/src/search.rs b/crates/ndg-html/src/search.rs index 37c1bef0..25d69571 100644 --- a/crates/ndg-html/src/search.rs +++ b/crates/ndg-html/src/search.rs @@ -1,5 +1,4 @@ use std::{ - clone::Clone, collections::{HashMap, HashSet}, fs, path::PathBuf, diff --git a/crates/ndg-html/src/template.rs b/crates/ndg-html/src/template.rs index 3e440227..81e9d164 100644 --- a/crates/ndg-html/src/template.rs +++ b/crates/ndg-html/src/template.rs @@ -3,12 +3,12 @@ use std::{ fmt::Write, fs, path::Path, - string::String, sync::{LazyLock, RwLock}, }; use color_eyre::eyre::{Context, Result, bail}; use html_escape::encode_text; +use indexmap::IndexMap; use ndg_commonmark::Header; use ndg_config::{Config, sidebar::SidebarOrdering}; use ndg_manpage::types::NixOption; @@ -102,14 +102,16 @@ fn setup_tera_templates( main_template_name: &str, main_template_content: &str, ) -> Result { - // Create a cache key based on config template path and main template - let cache_key = format!( - "{}:{}", - config - .get_template_path() - .map_or_else(|| "default".to_string(), |p| p.display().to_string()), - main_template_name - ); + // Create a cache key based on template configuration and main template. + // Use both template_dir and template_path to ensure uniqueness even when + // only template_path (a file) is set - get_template_path() returns None + // for file-only templates, causing cache collisions. + let template_key = config + .template_dir + .as_ref() + .or(config.template_path.as_ref()) + .map_or_else(|| "default".to_string(), |p| p.display().to_string()); + let cache_key = format!("{}:{}", template_key, main_template_name); // Check cache first { @@ -466,7 +468,7 @@ fn resolve_doc_template( )] pub fn render_options( config: &Config, - options: &HashMap, + options: &IndexMap, ) -> Result { // Load templates with caching let options_template = @@ -598,7 +600,7 @@ pub fn render_lib( /// Generate specialized TOC for options page fn generate_options_toc( - options: &HashMap, + options: &IndexMap, config: &Config, tera: &Tera, ) -> Result { @@ -679,8 +681,19 @@ fn generate_options_toc( let option_value = tera::to_value({ let mut map = tera::Map::new(); - map.insert("name".to_string(), tera::to_value(&option.name)?); - map.insert("display_name".to_string(), tera::to_value(display_name)?); + // HTML-escape display strings: Tera::default() has no auto-escaping. + map.insert( + "name".to_string(), + tera::to_value(encode_text(&option.name).as_ref())?, + ); + map.insert( + "id".to_string(), + tera::to_value(sanitize_option_id(&option.name))?, + ); + map.insert( + "display_name".to_string(), + tera::to_value(encode_text(display_name).as_ref())?, + ); map.insert("internal".to_string(), tera::to_value(option.internal)?); map.insert("read_only".to_string(), tera::to_value(option.read_only)?); @@ -703,10 +716,13 @@ fn generate_options_toc( .map(String::as_str) .unwrap_or(parent); - category.insert("name".to_string(), tera::to_value(parent)?); + category.insert( + "name".to_string(), + tera::to_value(encode_text(parent).as_ref())?, + ); category.insert( "display_name".to_string(), - tera::to_value(category_display_name)?, + tera::to_value(encode_text(category_display_name).as_ref())?, ); category.insert("count".to_string(), tera::to_value(opts.len())?); @@ -714,7 +730,14 @@ fn generate_options_toc( if let Some(parent_option) = direct_parent_options.get(parent) { let parent_option_value = tera::to_value({ let mut map = tera::Map::new(); - map.insert("name".to_string(), tera::to_value(&parent_option.name)?); + map.insert( + "name".to_string(), + tera::to_value(encode_text(&parent_option.name).as_ref())?, + ); + map.insert( + "id".to_string(), + tera::to_value(sanitize_option_id(&parent_option.name))?, + ); map.insert( "internal".to_string(), tera::to_value(parent_option.internal)?, @@ -757,8 +780,18 @@ fn generate_options_toc( let child_value = tera::to_value({ let mut map = tera::Map::new(); - map.insert("name".to_string(), tera::to_value(&option.name)?); - map.insert("display_name".to_string(), tera::to_value(display_name)?); + map.insert( + "name".to_string(), + tera::to_value(encode_text(&option.name).as_ref())?, + ); + map.insert( + "id".to_string(), + tera::to_value(sanitize_option_id(&option.name))?, + ); + map.insert( + "display_name".to_string(), + tera::to_value(encode_text(display_name).as_ref())?, + ); map.insert("internal".to_string(), tera::to_value(option.internal)?); map .insert("read_only".to_string(), tera::to_value(option.read_only)?); @@ -921,9 +954,14 @@ fn get_template_content( template_name: &str, fallback: &str, ) -> Result { - // Create cache key that includes template path to handle different configs + // Create cache key that includes template path to handle different configs. + // Use both template_dir and template_path to ensure uniqueness even when + // only template_path (a file) is set - get_template_path() returns None + // for file-only templates, causing cache collisions. let template_path_key = config - .get_template_path() + .template_dir + .as_ref() + .or(config.template_path.as_ref()) .map_or_else(|| "default".to_string(), |p| p.display().to_string()); let cache_key = format!("{template_path_key}:{template_name}"); @@ -1006,46 +1044,6 @@ struct NavItem { number: Option, } -/// Extract page title from markdown file. -/// -/// First attempts to read the file and extract the title from the first H1 -/// heading. Falls back to using the file stem as the title if reading fails or -/// no H1 is found. -fn extract_page_title(path: &Path, html_path: &Path) -> String { - std::fs::read_to_string(path).map_or_else( - |_| { - html_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }, - |content| { - content.lines().next().map_or_else( - || { - html_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }, - |first_line| { - first_line.strip_prefix("# ").map_or_else( - || { - html_path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }, - ndg_commonmark::utils::clean_anchor_patterns, - ) - }, - ) - }, - ) -} - /// Generate the document navigation HTML fn generate_doc_nav(config: &Config, current_file_rel_path: &Path) -> String { let mut doc_nav = String::new(); @@ -1105,7 +1103,8 @@ fn generate_doc_nav(config: &Config, current_file_rel_path: &Path) -> String { format!("{}{}", root_prefix, html_path.to_string_lossy()); // Extract page title - let page_title = extract_page_title(path, &html_path); + let page_title = + ndg_utils::markdown::extract_page_title(path, &html_path); // Apply sidebar configuration if available let (display_title, position) = @@ -1181,7 +1180,8 @@ fn generate_doc_nav(config: &Config, current_file_rel_path: &Path) -> String { let target_path = format!("{}{}", root_prefix, html_path.to_string_lossy()); - let page_title = extract_page_title(path, &html_path); + let page_title = + ndg_utils::markdown::extract_page_title(path, &html_path); // Apply sidebar configuration to special files if available let display_title = if let Some(sidebar_config) = &config.sidebar { @@ -1395,20 +1395,35 @@ fn generate_toc(headers: &[Header]) -> String { toc } -/// Generate the options HTML content -fn generate_options_html(options: &HashMap) -> String { - let mut options_html = String::with_capacity(options.len() * 500); // FIXME: Rough estimate for capacity +/// Produce a safe HTML `id` / URL fragment from an option name. +/// +/// Dots are the canonical separator in NixOS option names, but names may also +/// contain `` placeholders (angle brackets) and other characters that +/// are invalid or dangerous in HTML `id` attributes and URL fragments. Replace +/// every character that is not ASCII alphanumeric, `-`, or `_` with `-`. +fn sanitize_option_id(name: &str) -> String { + let sanitized: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + format!("option-{sanitized}") +} - // Sort options by name - let mut option_keys: Vec<_> = options.keys().collect(); - option_keys.sort(); +/// Generate the options HTML content +fn generate_options_html(options: &IndexMap) -> String { + let mut options_html = String::with_capacity(options.len() * 500); // rough capacity estimate, average ~500 bytes per option - for key in option_keys { - let option = &options[key]; - let option_id = format!("option-{}", option.name.replace('.', "-")); + // Iterate in the order established by process_options, i.e., priority sort. + for option in options.values() { + let option_id = sanitize_option_id(&option.name); - // Open option container with ID for direct linking - // Writing to String is infallible + // Open option container with ID for direct linking. let _ = writeln!(options_html, "
"); // Option name with anchor link and copy button @@ -1418,7 +1433,8 @@ fn generate_options_html(options: &HashMap) -> String { class=\"option-anchor\">{}\n \n Link copied!\n \n", - option_id, option.name + option_id, + encode_text(&option.name) ); // Option metadata (internal/readOnly) @@ -1443,7 +1459,7 @@ fn generate_options_html(options: &HashMap) -> String { let _ = writeln!( options_html, "
Type: {}
", - option.type_name + encode_text(&option.type_name) ); // Option description @@ -1463,10 +1479,12 @@ fn generate_options_html(options: &HashMap) -> String { if let Some(declared_in) = &option.declared_in { // Writing to String is infallible if let Some(url) = &option.declared_in_url { + let safe_url = html_escape::encode_double_quoted_attribute(url); let _ = writeln!( options_html, "
Declared in: {declared_in}
" + href=\"{safe_url}\" \ + target=\"_blank\">{declared_in}
" ); } else { let _ = writeln!( @@ -1515,71 +1533,44 @@ fn add_default_value(html: &mut String, option: &NixOption) { /// Add example value to options HTML fn add_example_value(html: &mut String, option: &NixOption) { if let Some(example_text) = &option.example_text { - // Process the example text to preserve code formatting - if example_text.contains('\n') { - // Multi-line examples - preserve formatting with pre/code - // Process special characters to ensure valid HTML - let safe_example = example_text.replace('<', "<").replace('>', ">"); - - // Remove backticks if they're surrounding the entire content (from - // literalExpression) - let trimmed_example = if safe_example.starts_with('`') - && safe_example.ends_with('`') - && safe_example.len() > 2 - { - &safe_example[1..safe_example.len() - 1] - } else { - &safe_example - }; + // Strip surrounding backticks (literalExpression wrapper) before escaping, + // so the backtick positions are checked on the original text. + let inner: &str = if example_text.starts_with('`') + && example_text.ends_with('`') + && example_text.len() > 2 + { + &example_text[1..example_text.len() - 1] + } else { + example_text + }; - // Writing to String is infallible + let safe = encode_text(inner); + + if inner.contains('\n') { let _ = writeln!( html, "
Example: \ -
{trimmed_example}
" +
{safe}
" ); } else { - // Check if this is already a code block (surrounded by backticks) - if example_text.starts_with('`') - && example_text.ends_with('`') - && example_text.len() > 2 - { - // This is inline code - extract the content and properly escape it - let code_content = &example_text[1..example_text.len() - 1]; - let safe_content = - code_content.replace('<', "<").replace('>', ">"); - let _ = writeln!( - html, - "
Example: \ - {safe_content}
" - ); - } else { - // Regular inline example - still needs escaping - let safe_example = - example_text.replace('<', "<").replace('>', ">"); - let _ = writeln!( - html, - "
Example: \ - {safe_example}
" - ); - } + let _ = writeln!( + html, + "
Example: {safe}
" + ); } } else if let Some(example_val) = &option.example { let example_str = example_val.to_string(); - let safe_example = example_str.replace('<', "<").replace('>', ">"); + let safe = encode_text(&example_str); if example_str.contains('\n') { - // Multi-line JSON examples need special handling let _ = writeln!( html, "
Example: \ -
{safe_example}
" +
{safe}
" ); } else { - // Single-line JSON examples let _ = writeln!( html, - "
Example: \ - {safe_example}
" + "
Example: {safe}
" ); } } diff --git a/crates/ndg-html/tests/html_template.rs b/crates/ndg-html/tests/html_template.rs index b9df5e4d..31d436d7 100644 --- a/crates/ndg-html/tests/html_template.rs +++ b/crates/ndg-html/tests/html_template.rs @@ -1,6 +1,7 @@ #![allow(clippy::expect_used, reason = "Fine in tests")] use std::{collections::HashMap, fs, path::Path}; +use indexmap::IndexMap; use ndg_commonmark::{Header, MarkdownOptions, MarkdownProcessor}; use ndg_config::{Config, search::SearchConfig, sidebar::SidebarConfig}; use ndg_html::template; @@ -69,7 +70,7 @@ fn render_basic_page_renders_html() { fn render_options_page_includes_options() { let mut config = minimal_config(); config.module_options = Some("dummy.json".into()); - let mut options = HashMap::new(); + let mut options = IndexMap::new(); options.insert( "foo.bar".to_string(), create_basic_option("foo.bar", "desc"), @@ -84,7 +85,7 @@ fn render_options_page_includes_options() { fn render_options_page_renders_description() { let mut config = minimal_config(); config.module_options = Some("dummy.json".into()); - let mut options = HashMap::new(); + let mut options = IndexMap::new(); options.insert( "foo.bar".to_string(), create_detailed_option( @@ -173,7 +174,7 @@ fn render_page_with_headers_toc() { fn render_options_page_with_multiple_options() { let mut config = minimal_config(); config.module_options = Some("dummy.json".into()); - let mut options = HashMap::new(); + let mut options = IndexMap::new(); options.insert( "foo.bar".to_string(), create_basic_option("foo.bar", "desc1"), @@ -249,7 +250,7 @@ fn render_page_contains_footer_html() { fn render_options_page_contains_navbar() { let mut config = minimal_config(); config.module_options = Some("dummy.json".into()); - let mut options = HashMap::new(); + let mut options = IndexMap::new(); options.insert( "test.option".to_string(), create_basic_option("test.option", "Test option"), @@ -267,7 +268,7 @@ fn render_options_page_contains_footer() { let mut config = minimal_config(); config.module_options = Some("dummy.json".into()); config.footer_text = "Custom Footer Text".to_string(); - let mut options = HashMap::new(); + let mut options = IndexMap::new(); options.insert( "test.option".to_string(), create_basic_option("test.option", "Test option"), @@ -905,3 +906,141 @@ fn render_page_builtin_vars_take_precedence_over_user_vars() { "user var must not shadow built-in" ); } + +// NixOS option names commonly contain `` as a placeholder component, +// e.g. `services.nginx.virtualHosts..serverName`. The `<` and `>` must +// not appear raw inside an HTML `id` attribute or `href` fragment. +#[test] +fn render_options_id_attribute_is_safe_for_angle_bracket_names() { + let mut config = minimal_config(); + config.module_options = Some("dummy.json".into()); + let name = "services.nginx.virtualHosts..serverName"; + let mut options = IndexMap::new(); + options.insert(name.to_string(), create_basic_option(name, "desc")); + + let html = template::render_options(&config, &options).expect("render"); + + // Raw `` must never appear inside an id="..." attribute value. + // If it did the browser would parse `` as an HTML tag, breaking the + // page structure. + assert!( + !html.contains("id=\"option-services-nginx-virtualHosts-"), + "raw '<' must not appear inside an id attribute" + ); + assert!( + !html.contains("href=\"#option-services-nginx-virtualHosts-"), + "raw '<' must not appear inside an href fragment" + ); + + // The id and matching href must both be present so the anchor still works. + // The sanitized form replaces all non-alphanumeric chars except `-`/`_` + // with `-`, so `.` and `<`/`>` all become `-`. + let expected_id = "option-services-nginx-virtualHosts--name--serverName"; + assert!( + html.contains(&format!("id=\"{expected_id}\"")), + "sanitized id must be present: {expected_id}" + ); + assert!( + html.contains(&format!("href=\"#{expected_id}\"")), + "TOC href must match the sanitized id: #{expected_id}" + ); + + // The display text must still show the real name, properly HTML-escaped. + assert!( + html.contains("<name>"), + "display text must HTML-escape angle brackets" + ); + assert!( + !html.contains(""), + "raw unescaped tag must not appear anywhere in output" + ); +} + +// `type_name` is rendered inside a `` element. Values like +// `null or (submodule)` are benign, but the field is free-form and must be +// escaped to prevent injection. +#[test] +fn render_options_type_name_is_html_escaped() { + let mut config = minimal_config(); + config.module_options = Some("dummy.json".into()); + let mut options = IndexMap::new(); + options.insert("foo.bar".to_string(), NixOption { + name: "foo.bar".to_string(), + description: "desc".to_string(), + type_name: "null or ".to_string(), + ..Default::default() + }); + + let html = template::render_options(&config, &options).expect("render"); + + assert!( + !html.contains("`, so angle brackets and +// ampersands must be preserved as-is. +#[test] +fn render_options_declared_in_preserves_angle_brackets() { + let mut config = minimal_config(); + config.module_options = Some("dummy.json".into()); + let mut options = IndexMap::new(); + options.insert("foo.bar".to_string(), NixOption { + name: "foo.bar".to_string(), + description: "desc".to_string(), + declared_in: Some("/foo/bar.nix".to_string()), + ..Default::default() + }); + + let html = template::render_options(&config, &options).expect("render"); + + assert!( + html.contains(""), + "raw '' must appear in declared_in output" + ); + assert!( + !html.contains("<modules>"), + "angle brackets in declared_in must not be escaped" + ); +} + +// When `declared_in_url` contains an ampersand (valid in URLs, but must be +// `&` inside an HTML attribute), the href must be properly escaped. +#[test] +fn render_options_declared_in_url_ampersand_is_escaped_in_attribute() { + let mut config = minimal_config(); + config.module_options = Some("dummy.json".into()); + let mut options = IndexMap::new(); + options.insert("foo.bar".to_string(), NixOption { + name: "foo.bar".to_string(), + description: "desc".to_string(), + declared_in: Some("foo/bar.nix".to_string()), + declared_in_url: Some( + "https://example.com/source?file=foo.nix&line=42".to_string(), + ), + ..Default::default() + }); + + let html = template::render_options(&config, &options).expect("render"); + + // Raw `&line=` inside an href attribute is invalid HTML; must be + // `&line=`. + assert!( + !html.contains("href=\"https://example.com/source?file=foo.nix&line="), + "raw '&' inside href attribute must not appear" + ); + assert!( + html.contains("&line=42"), + "ampersand in href must be escaped to &" + ); +} diff --git a/crates/ndg-html/tests/search_path_resolution.rs b/crates/ndg-html/tests/search_path_resolution.rs index ecd47a90..f52b6894 100644 --- a/crates/ndg-html/tests/search_path_resolution.rs +++ b/crates/ndg-html/tests/search_path_resolution.rs @@ -15,7 +15,7 @@ use ndg_html::{ search::{SearchData, generate_search_index}, template::render, }; -use ndg_utils::{collect_included_files, markdown::create_processor}; +use ndg_utils::{markdown::create_processor, process_markdown_files}; use serde_json::json; use tempfile::TempDir; @@ -199,8 +199,12 @@ This file should be transitively included in `main.html` }; let processor = Some(create_processor(&config, None)); - config.included_files = collect_included_files(&config, processor.as_ref()) - .expect("Failed to collect include files"); + + // Populate config.included_files as a side effect of processing markdown + // files + let _processed_files = + process_markdown_files(&mut config, processor.as_ref()) + .expect("Failed to process markdown files"); let all_markdown_files = collect_markdown_files(&input_dir); diff --git a/crates/ndg-macros/src/lib.rs b/crates/ndg-macros/src/lib.rs index 10c186f1..d4dfa44a 100644 --- a/crates/ndg-macros/src/lib.rs +++ b/crates/ndg-macros/src/lib.rs @@ -1,9 +1,8 @@ //! Proc-macros for NDG configuration system. //! //! Provides derive macros for automatic configuration handling. - use proc_macro::TokenStream; -use quote::{ToTokens, quote}; +use quote::quote; use syn::{Attribute, Data, DeriveInput, Fields, Type, parse_macro_input}; /// Attribute configuration for a field. @@ -20,9 +19,6 @@ struct FieldConfig { /// Allow empty values (set to None). allow_empty: bool, - - /// Pending replacement value (set before deprecated). - pending_replacement: Option, } impl FieldConfig { @@ -34,6 +30,13 @@ impl FieldConfig { continue; } + // Collect deprecated_version and replacement independently so + // that `#[config(deprecated = "v", replacement = "k")]` and + // `#[config(replacement = "k", deprecated = "v")]` produce identical + // results regardless of attribute order. + let mut deprecated_version: Option = None; + let mut pending_replacement: Option = None; + let _ = attr.parse_nested_meta(|meta| { if meta.path.is_ident("key") { let value = meta.value()?; @@ -42,12 +45,11 @@ impl FieldConfig { } else if meta.path.is_ident("deprecated") { let value = meta.value()?; let lit: syn::LitStr = value.parse()?; - config.deprecated = - Some((lit.value(), config.pending_replacement.take())); + deprecated_version = Some(lit.value()); } else if meta.path.is_ident("replacement") { let value = meta.value()?; let lit: syn::LitStr = value.parse()?; - config.pending_replacement = Some(lit.value()); + pending_replacement = Some(lit.value()); } else if meta.path.is_ident("allow_empty") { config.allow_empty = true; } else if meta.path.is_ident("nested") { @@ -55,12 +57,46 @@ impl FieldConfig { } Ok(()) }); + + if let Some(version) = deprecated_version { + config.deprecated = Some((version, pending_replacement)); + } } config } } +/// Get the last path segment of a type, if it is a simple path type. +fn last_type_segment(ty: &syn::Type) -> Option<&syn::Ident> { + if let syn::Type::Path(tp) = ty { + tp.path.segments.last().map(|s| &s.ident) + } else { + None + } +} + +/// Returns true if the type's last path segment matches `name`. Works for +/// `Option`, `Vec`, `HashMap`, etc. +fn type_is(ty: &syn::Type, name: &str) -> bool { + last_type_segment(ty).is_some_and(|ident| ident == name) +} + +/// Get the first generic type argument of a type (e.g., T from `Option`). +fn first_type_arg(ty: &syn::Type) -> Option<&syn::Type> { + if let syn::Type::Path(tp) = ty + && let Some(last) = tp.path.segments.last() + && let syn::PathArguments::AngleBracketed(args) = &last.arguments + { + for arg in &args.args { + if let syn::GenericArgument::Type(inner) = arg { + return Some(inner); + } + } + } + None +} + /// Derive macro for configuration structs. #[proc_macro_derive(Configurable, attributes(config))] pub fn derive_configurable(input: TokenStream) -> TokenStream { @@ -102,86 +138,87 @@ pub fn derive_configurable(input: TokenStream) -> TokenStream { ); let expanded = quote! { - impl #impl_generics #name #ty_generics #where_clause { - /// Apply a configuration override by key. - pub fn apply_override( - &mut self, - key: &str, - value: &str, - ) -> std::result::Result<(), crate::error::ConfigError> { - use crate::error::ConfigError; - - #(#field_handlers)* - - // Generate "did you mean" suggestions - let valid_keys: &[&str] = &[#(#valid_keys_ref),*]; - let suggestions = #find_similar_fn(key, valid_keys, 3, 0.5); - - let error_msg = if suggestions.is_empty() { - format!("Unknown configuration key: '{key}'. See documentation for supported keys.") - } else { - let suggestions_str = suggestions.join("', '"); - format!("Unknown configuration key: '{key}'. Did you mean: '{suggestions_str}'?") - }; + const _: () = { + impl #impl_generics #name #ty_generics #where_clause { + /// Apply a configuration override by key. + pub fn apply_override( + &mut self, + key: &str, + value: &str, + ) -> std::result::Result<(), crate::error::ConfigError> { + use crate::error::ConfigError; + + #(#field_handlers)* + + // Generate "did you mean" suggestions + let valid_keys: &[&str] = &[#(#valid_keys_ref),*]; + let suggestions = #find_similar_fn(key, valid_keys, 3, 0.5); + + let error_msg = if suggestions.is_empty() { + format!("Unknown configuration key: '{key}'. See documentation for supported keys.") + } else { + let suggestions_str = suggestions.join("', '"); + format!("Unknown configuration key: '{key}'. Did you mean: '{suggestions_str}'?") + }; + + Err(ConfigError::Config(error_msg)) + } - Err(ConfigError::Config(error_msg)) + /// Merge another config into this one. + pub fn merge_fields(&mut self, other: Self) { + #(#merge_handlers)* + } } - /// Merge another config into this one. - pub fn merge_fields(&mut self, other: Self) { - #(#merge_handlers)* + fn #find_similar_fn( + unknown: &str, + candidates: &[&str], + max_suggestions: usize, + threshold: f64, + ) -> Vec { + let mut scored: Vec<(String, f64)> = candidates + .iter() + .map(|&candidate| (candidate.to_string(), #calc_similarity_fn(unknown, candidate))) + .filter(|(_, score)| *score >= threshold) + .collect(); + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(max_suggestions); + scored.into_iter().map(|(key, _)| key).collect() } - } - // Include the helper functions in the generated code with unique names - fn #find_similar_fn( - unknown: &str, - candidates: &[&str], - max_suggestions: usize, - threshold: f64, - ) -> Vec { - let mut scored: Vec<(String, f64)> = candidates - .iter() - .map(|&candidate| (candidate.to_string(), #calc_similarity_fn(unknown, candidate))) - .filter(|(_, score)| *score >= threshold) - .collect(); - - scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - scored.truncate(max_suggestions); - scored.into_iter().map(|(key, _)| key).collect() - } - - fn #calc_similarity_fn(a: &str, b: &str) -> f64 { - let a_chars: Vec = a.chars().collect(); - let b_chars: Vec = b.chars().collect(); - let a_len = a_chars.len(); - let b_len = b_chars.len(); + fn #calc_similarity_fn(a: &str, b: &str) -> f64 { + let a_chars: Vec = a.chars().collect(); + let b_chars: Vec = b.chars().collect(); + let a_len = a_chars.len(); + let b_len = b_chars.len(); - if a_len == 0 && b_len == 0 { - return 1.0; - } - if a_len == 0 || b_len == 0 { - return 0.0; - } + if a_len == 0 && b_len == 0 { + return 1.0; + } + if a_len == 0 || b_len == 0 { + return 0.0; + } - let mut prev_row: Vec = (0..=b_len).collect(); - let mut curr_row = vec![0; b_len + 1]; + let mut prev_row: Vec = (0..=b_len).collect(); + let mut curr_row = vec![0; b_len + 1]; - for i in 1..=a_len { - curr_row[0] = i; - for j in 1..=b_len { - let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 }; - curr_row[j] = (prev_row[j] + 1) - .min(curr_row[j - 1] + 1) - .min(prev_row[j - 1] + cost); + for i in 1..=a_len { + curr_row[0] = i; + for j in 1..=b_len { + let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 }; + curr_row[j] = (prev_row[j] + 1) + .min(curr_row[j - 1] + 1) + .min(prev_row[j - 1] + cost); + } + std::mem::swap(&mut prev_row, &mut curr_row); } - std::mem::swap(&mut prev_row, &mut curr_row); - } - let distance = prev_row[b_len] as f64; - let max_len = a_len.max(b_len) as f64; - 1.0 - (distance / max_len) - } + let distance = prev_row[b_len] as f64; + let max_len = a_len.max(b_len) as f64; + 1.0 - (distance / max_len) + } + }; }; TokenStream::from(expanded) @@ -229,28 +266,29 @@ fn generate_field_handler( } // Skip Vec and HashMap fields, they can't be directly overridden - let type_str = field_type.to_token_stream().to_string(); - if type_str.starts_with("Vec<") || type_str.contains("HashMap<") { + if type_is(field_type, "Vec") || type_is(field_type, "HashMap") { return quote! {}; } // Handle deprecation - let deprecation_check = - if let Some((version, replacement)) = &config.deprecated { - let msg = if let Some(replacement) = replacement { + let deprecation_check = if let Some((version, replacement)) = + &config.deprecated + { + let msg = replacement.as_ref().map_or_else( + || format!("The '{field_key}' config key is deprecated since {version}."), + |replacement| { format!( "The '{field_key}' config key is deprecated since {version}. Use \ '{replacement}' instead." ) - } else { - format!("The '{field_key}' config key is deprecated since {version}.") - }; - quote! { - log::warn!(#msg); - } - } else { - quote! {} - }; + }, + ); + quote! { + log::warn!(#msg); + } + } else { + quote! {} + }; // Generate type-specific parsing let value_assignment = @@ -301,11 +339,74 @@ fn generate_value_assignment( field_type: &Type, config: &FieldConfig, ) -> proc_macro2::TokenStream { - let type_str = field_type.to_token_stream().to_string(); + // Option handling, must come before general Option check + if type_is(field_type, "Option") + && first_type_arg(field_type) + .is_some_and(|inner| type_is(inner, "SidebarOrdering")) + { + return quote! { + self.#field_name = if value.is_empty() { + None + } else { + Some(value.parse().map_err(|e: String| ConfigError::Config(format!( + "Invalid value for '{}': '{}' - {}", + stringify!(#field_name), value, e + )))?) + }; + }; + } + + // Option handling, must come before general Option check + if type_is(field_type, "Option") + && first_type_arg(field_type).is_some_and(|inner| type_is(inner, "usize")) + { + return quote! { + self.#field_name = if value.is_empty() { + None + } else { + Some(value.parse().map_err(|_| ConfigError::Config(format!( + "Invalid value for '{}': '{}'. Expected a positive integer", + stringify!(#field_name), value + )))?) + }; + }; + } + + // Option handling, must come before general Option check + if type_is(field_type, "Option") + && first_type_arg(field_type).is_some_and(|inner| type_is(inner, "u8")) + { + return quote! { + self.#field_name = if value.is_empty() { + None + } else { + Some(value.parse().map_err(|_| ConfigError::Config(format!( + "Invalid value for '{}': '{}'. Expected a number between 0-255", + stringify!(#field_name), value + )))?) + }; + }; + } + + // Option handling, must come before general Option check + if type_is(field_type, "Option") + && first_type_arg(field_type).is_some_and(|inner| type_is(inner, "f32")) + { + return quote! { + self.#field_name = if value.is_empty() { + None + } else { + Some(value.parse().map_err(|_| ConfigError::Config(format!( + "Invalid value for '{}': '{}'. Expected a number", + stringify!(#field_name), value + )))?) + }; + }; + } - // Option handling - if type_str.starts_with("Option ") || type_str.starts_with("Option<") { - if config.allow_empty { + // General Option handling + if type_is(field_type, "Option") { + return if config.allow_empty { quote! { self.#field_name = if value.is_empty() { None @@ -321,23 +422,26 @@ fn generate_value_assignment( format!("Invalid value for '{}': '{}'", stringify!(#field_name), value) ))?); } - } + }; } + // PathBuf handling - else if type_str.contains("PathBuf") { - quote! { + if type_is(field_type, "PathBuf") { + return quote! { self.#field_name = std::path::PathBuf::from(value); - } + }; } + // String handling - else if type_str == "String" { - quote! { + if type_is(field_type, "String") { + return quote! { self.#field_name = value.to_string(); - } + }; } + // Bool handling - else if type_str == "bool" { - quote! { + if type_is(field_type, "bool") { + return quote! { self.#field_name = match value.to_lowercase().as_str() { "true" | "yes" | "1" => true, "false" | "no" | "0" => false, @@ -348,106 +452,55 @@ fn generate_value_assignment( ))); } }; - } + }; } + // SidebarOrdering handling - else if type_str == "SidebarOrdering" { - quote! { + if type_is(field_type, "SidebarOrdering") { + return quote! { self.#field_name = value.parse().map_err(|e: String| ConfigError::Config(format!( "Invalid value for '{}': '{}' - {}", stringify!(#field_name), value, e )))?; - } - } - // Option handling - else if type_str.contains("Option") - || type_str.contains("Option < SidebarOrdering >") - { - quote! { - self.#field_name = if value.is_empty() { - None - } else { - Some(value.parse().map_err(|e: String| ConfigError::Config(format!( - "Invalid value for '{}': '{}' - {}", - stringify!(#field_name), value, e - )))?) - }; - } + }; } + // usize handling - else if type_str == "usize" { - quote! { + if type_is(field_type, "usize") { + return quote! { self.#field_name = value.parse().map_err(|_| ConfigError::Config(format!( "Invalid value for '{}': '{}'. Expected a positive integer", stringify!(#field_name), value )))?; - } - } - // Option handling - else if type_str.contains("Option") { - quote! { - self.#field_name = if value.is_empty() { - None - } else { - Some(value.parse().map_err(|_| ConfigError::Config(format!( - "Invalid value for '{}': '{}'. Expected a positive integer", - stringify!(#field_name), value - )))?) - }; - } + }; } + // u8 handling - else if type_str == "u8" { - quote! { + if type_is(field_type, "u8") { + return quote! { self.#field_name = value.parse().map_err(|_| ConfigError::Config(format!( "Invalid value for '{}': '{}'. Expected a number between 0-255", stringify!(#field_name), value )))?; - } - } - // Option handling - else if type_str.contains("Option") { - quote! { - self.#field_name = if value.is_empty() { - None - } else { - Some(value.parse().map_err(|_| ConfigError::Config(format!( - "Invalid value for '{}': '{}'. Expected a number between 0-255", - stringify!(#field_name), value - )))?) - }; - } + }; } + // f32 handling - else if type_str == "f32" { - quote! { + if type_is(field_type, "f32") { + return quote! { self.#field_name = value.parse().map_err(|_| ConfigError::Config(format!( "Invalid value for '{}': '{}'. Expected a number", stringify!(#field_name), value )))?; - } - } - // Option handling - else if type_str.contains("Option") { - quote! { - self.#field_name = if value.is_empty() { - None - } else { - Some(value.parse().map_err(|_| ConfigError::Config(format!( - "Invalid value for '{}': '{}'. Expected a number", - stringify!(#field_name), value - )))?) - }; - } + }; } + // Default: try to parse - else { - quote! { - self.#field_name = value.parse().map_err(|_| ConfigError::Config(format!( - "Invalid value for '{}': '{}'", - stringify!(#field_name), value - )))?; - } + quote! { + self.#field_name = value.parse().map_err(|_| ConfigError::Config(format!( + "Invalid value for '{}': '{}'", + stringify!(#field_name), value + )))?; } } @@ -458,7 +511,6 @@ fn generate_merge_handlers(fields: &Fields) -> Vec { let field_config = FieldConfig::from_attrs(&field.attrs); let field_name = field.ident.as_ref().expect("Named field required"); let field_type = &field.ty; - let type_str = field_type.to_token_stream().to_string(); let handler = if field_config.nested { // For nested configs, replace if other has Some @@ -467,8 +519,9 @@ fn generate_merge_handlers(fields: &Fields) -> Vec { self.#field_name = other.#field_name; } } - } else if type_str.starts_with("Option fields: extend if both Some, otherwise replace only if // other is Some @@ -483,21 +536,19 @@ fn generate_merge_handlers(fields: &Fields) -> Vec { _ => {} } } - } else if type_str.starts_with("Option<") - || type_str.starts_with("Option <") - { + } else if type_is(field_type, "Option") { // Option fields: replace if other has Some quote! { if other.#field_name.is_some() { self.#field_name = other.#field_name; } } - } else if type_str.starts_with("Vec<") || type_str.starts_with("Vec <") { + } else if type_is(field_type, "Vec") { // Vec fields: extend quote! { self.#field_name.extend(other.#field_name); } - } else if type_str.contains("HashMap") { + } else if type_is(field_type, "HashMap") { // HashMap fields: extend (other takes precedence) quote! { self.#field_name.extend(other.#field_name); diff --git a/crates/ndg-manpage/src/options.rs b/crates/ndg-manpage/src/options.rs index 09d5d6d3..82ffae08 100644 --- a/crates/ndg-manpage/src/options.rs +++ b/crates/ndg-manpage/src/options.rs @@ -389,7 +389,9 @@ fn parse_option( } // File where option is declared - if let Some(Value::String(file)) = option_data.get("declarations") { + if let Some(Value::Array(files)) = option_data.get("declarations") + && let Some(Value::String(file)) = files.first() + { option.declared_in = Some(file.clone()); } @@ -447,7 +449,8 @@ fn process_description(text: &str) -> String { let escaped = escape_non_macro_lines(&with_admonitions); // Preserve explicit paragraph breaks so list items do not merge - escaped.replace("\n\n", "\n.br\n") + let result = escaped.replace("\n\n", "\n.br\n"); + restore_formatting(&result) } /// Preserve existing troff formatting codes so they don't get double-escaped @@ -564,7 +567,7 @@ fn selective_man_escape(text: &str) -> String { i += 2; // If it's a special escape, process accordingly - if chars[i - 1] == '(' && i + 2 <= chars.len() { + if chars[i - 1] == '(' && i + 1 < chars.len() { result.push(chars[i]); result.push(chars[i + 1]); i += 2; diff --git a/crates/ndg-manpage/tests/manpage_output.rs b/crates/ndg-manpage/tests/manpage_output.rs index f48e645f..fdd9e345 100644 --- a/crates/ndg-manpage/tests/manpage_output.rs +++ b/crates/ndg-manpage/tests/manpage_output.rs @@ -12,17 +12,18 @@ fn test_manpage_declared_by_formatting() { let options_path = temp_dir.path().join("options.json"); let output_path = temp_dir.path().join("out.5"); + // NixOS options JSON always uses an array for `declarations` let options = json!({ "services.test": { "type": "string", "description": "Test option", - "declarations": "modules/test/module.nix", + "declarations": ["modules/test/module.nix"], "declarationURL": "https://example.com/test" }, "services.no_url": { "type": "string", "description": "Another option", - "declarations": "modules/other/module.nix" + "declarations": ["modules/other/module.nix"] } }); diff --git a/crates/ndg-nixdoc/src/error.rs b/crates/ndg-nixdoc/src/error.rs index 0c8c12c9..16582abe 100644 --- a/crates/ndg-nixdoc/src/error.rs +++ b/crates/ndg-nixdoc/src/error.rs @@ -12,12 +12,4 @@ pub enum NixdocError { #[source] source: std::io::Error, }, - - /// The Nix source could not be parsed. - /// - /// Unlike typical parse errors, `rnix` is error-tolerant and always - /// produces a tree. This variant is reserved for cases where the parser - /// signals hard parse errors that make the file unusable. - #[error("failed to parse `{path}` as Nix: {message}")] - ParseNix { path: PathBuf, message: String }, } diff --git a/crates/ndg-nixdoc/src/extractor.rs b/crates/ndg-nixdoc/src/extractor.rs index 75fee7d6..9446416c 100644 --- a/crates/ndg-nixdoc/src/extractor.rs +++ b/crates/ndg-nixdoc/src/extractor.rs @@ -238,30 +238,24 @@ fn preceding_doc_comment(node: &rnix::SyntaxNode) -> Option { // Walk backwards through siblings/tokens from just before this node. // `.skip(1)` skips the node itself (the first element of the iterator). - let mut cursor = node.siblings_with_tokens(Direction::Prev).skip(1); - - let mut found: Option = None; - - for sibling in cursor.by_ref() { - match sibling.kind() { + for token in node.siblings_with_tokens(Direction::Prev).skip(1) { + match token.kind() { TOKEN_WHITESPACE => {}, TOKEN_COMMENT => { - let text = sibling.to_string(); + let text = token.to_string(); if is_doc_comment(&text) { - // Take only the innermost (closest) doc comment. - if found.is_none() { - found = Some(text); - } + return Some(text); } - // Stop after any comment (doc or plain): a plain `# ...` or `/* */` - // separates this binding from any doc comment further up. - break; + + // A plain `# ...` or `/* */` comment separates this binding from any + // doc comment further up; stop searching. + return None; }, - _ => break, + _ => return None, } } - found + None } /// Return `true` when `text` is a Nixdoc block comment (`/** ... */`). diff --git a/crates/ndg-templates/src/lib.rs b/crates/ndg-templates/src/lib.rs index 036906ff..f267dab1 100644 --- a/crates/ndg-templates/src/lib.rs +++ b/crates/ndg-templates/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::OnceLock}; pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/default.html"); pub const OPTIONS_TEMPLATE: &str = include_str!("../templates/options.html"); @@ -17,17 +17,22 @@ pub const MAIN_JS: &str = include_str!("../templates/main.js"); #[must_use] pub fn all_templates() -> HashMap<&'static str, &'static str> { - let mut templates = HashMap::new(); - templates.insert("default.html", DEFAULT_TEMPLATE); - templates.insert("options.html", OPTIONS_TEMPLATE); - templates.insert("search.html", SEARCH_TEMPLATE); - templates.insert("options_toc.html", OPTIONS_TOC_TEMPLATE); - templates.insert("navbar.html", NAVBAR_TEMPLATE); - templates.insert("footer.html", FOOTER_TEMPLATE); - templates.insert("lib.html", LIB_TEMPLATE); - templates.insert("default.css", DEFAULT_CSS); - templates.insert("search.js", SEARCH_JS); - templates.insert("search-worker.js", SEARCH_WORKER_JS); - templates.insert("main.js", MAIN_JS); - templates + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + let mut templates = HashMap::new(); + templates.insert("default.html", DEFAULT_TEMPLATE); + templates.insert("options.html", OPTIONS_TEMPLATE); + templates.insert("search.html", SEARCH_TEMPLATE); + templates.insert("options_toc.html", OPTIONS_TOC_TEMPLATE); + templates.insert("navbar.html", NAVBAR_TEMPLATE); + templates.insert("footer.html", FOOTER_TEMPLATE); + templates.insert("lib.html", LIB_TEMPLATE); + templates.insert("default.css", DEFAULT_CSS); + templates.insert("search.js", SEARCH_JS); + templates.insert("search-worker.js", SEARCH_WORKER_JS); + templates.insert("main.js", MAIN_JS); + templates + }) + .clone() } diff --git a/crates/ndg-templates/templates/options_toc.html b/crates/ndg-templates/templates/options_toc.html index 7fac6102..fd3eca14 100644 --- a/crates/ndg-templates/templates/options_toc.html +++ b/crates/ndg-templates/templates/options_toc.html @@ -2,7 +2,7 @@ {# Single options first #} {% for option in single_options %}
  • - + {{ option.name }} {% if option.internal %}internal{% endif %} {% if option.read_only %}read-only{% endif %} @@ -21,7 +21,7 @@