From fc2357d2a1dba8da4486bac1ac87467d087df1d7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 3 Oct 2026 18:34:20 +0300 Subject: [PATCH 1/7] ndg-macros: add config template derive Signed-off-by: NotAShelf Change-Id: I634aaece0f200e7143eccfc5f277322b6a6a6964 --- crates/ndg-macros/src/lib.rs | 16 +++ crates/ndg-macros/src/template.rs | 157 ++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 crates/ndg-macros/src/template.rs diff --git a/crates/ndg-macros/src/lib.rs b/crates/ndg-macros/src/lib.rs index e27f117a3..49bfd6661 100644 --- a/crates/ndg-macros/src/lib.rs +++ b/crates/ndg-macros/src/lib.rs @@ -5,6 +5,22 @@ use proc_macro::TokenStream; use quote::quote; use syn::{Attribute, Data, DeriveInput, Fields, Type, parse_macro_input}; +mod template; + +/// Derive annotated TOML output from configuration fields and their defaults. +/// +/// Uses field doc comments and Serde names, skips runtime-only fields, and +/// writes values before nested tables. `#[template(nested)]` marks child tables +/// or arrays of tables; `#[template(example = expression)]` supplies an example +/// for absent optional values or empty arrays of tables. +#[proc_macro_derive(ConfigTemplate, attributes(template))] +pub fn derive_config_template(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + template::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + /// Attribute configuration for a field. #[derive(Default)] struct FieldConfig { diff --git a/crates/ndg-macros/src/template.rs b/crates/ndg-macros/src/template.rs new file mode 100644 index 000000000..d9bd42409 --- /dev/null +++ b/crates/ndg-macros/src/template.rs @@ -0,0 +1,157 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + Data, + DeriveInput, + Expr, + Fields, + LitStr, + Meta, + Token, + punctuated::Punctuated, +}; + +use crate::{first_type_arg, type_is}; + +pub fn expand(input: &DeriveInput) -> syn::Result { + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + input, + "ConfigTemplate requires a struct", + )); + }; + let Fields::Named(fields) = &data.fields else { + return Err(syn::Error::new_spanned( + input, + "ConfigTemplate requires named fields", + )); + }; + let mut values = Vec::new(); + let mut sections = Vec::new(); + + for field in &fields.named { + let Some(name) = &field.ident else { continue }; + let mut key = name.to_string(); + let mut skip = false; + let mut nested = false; + let mut example: Option = None; + let mut docs = Vec::new(); + for attr in &field.attrs { + if attr.path().is_ident("doc") { + if let Meta::NameValue(meta) = &attr.meta + && let Expr::Lit(expr) = &meta.value + && let syn::Lit::Str(text) = &expr.lit + { + docs.push(text.value().trim().to_string()); + } + } else if attr.path().is_ident("template") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("nested") { + nested = true; + } else if meta.path.is_ident("example") { + example = Some(meta.value()?.parse()?); + } else { + return Err(meta.error("expected nested or example")); + } + Ok(()) + })?; + } else if attr.path().is_ident("serde") || attr.path().is_ident("config") + { + let attributes = attr + .parse_args_with(Punctuated::::parse_terminated)?; + for meta in attributes { + if attr.path().is_ident("serde") { + if meta.path().is_ident("skip") + || meta.path().is_ident("skip_serializing") + { + skip = true; + } else if meta.path().is_ident("rename") { + key = renamed_key(&meta)?; + } + } else if meta.path().is_ident("nested") { + nested = true; + } + } + } + } + if skip { + continue; + } + let docs = docs.join("\n"); + let ty = &field.ty; + let optional = type_is(ty, "Option"); + let array = nested && type_is(ty, "Vec"); + let value_ty = if optional || array { + first_type_arg(ty) + .ok_or_else(|| syn::Error::new_spanned(ty, "expected a value type"))? + } else { + ty + }; + let fallback = example.as_ref().map_or_else( + || quote!(<#value_ty as Default>::default()), + |expr| quote!(#expr), + ); + let method = if nested { + quote!(section) + } else { + quote!(field) + }; + let extra = nested.then(|| quote!(, #array)); + let handler = if optional { + quote! { + if let Some(value) = &self.#name { + writer.#method(#key, #docs, value, false #extra)?; + } else { + writer.#method(#key, #docs, &#fallback, true #extra)?; + } + } + } else if array { + quote! { + if self.#name.is_empty() { + writer.section(#key, #docs, &#fallback, true, true)?; + } else { + for value in &self.#name { + writer.section(#key, #docs, value, false, true)?; + } + } + } + } else if !nested && type_is(ty, "Vec") && example.is_some() { + quote! { + if self.#name.is_empty() { + writer.field(#key, #docs, &#fallback, true)?; + } else { + writer.field(#key, #docs, &self.#name, false)?; + } + } + } else { + quote! { writer.#method(#key, #docs, &self.#name, false #extra)?; } + }; + if nested { + sections.push(handler); + } else { + values.push(handler); + } + } + + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = + input.generics.split_for_impl(); + Ok(quote! { + impl #impl_generics crate::templates::ConfigTemplate for #name #ty_generics #where_clause { + fn write_template(&self, writer: &mut crate::templates::TemplateWriter) -> Result<(), crate::templates::TemplateError> { + #(#values)* + #(#sections)* + Ok(()) + } + } + }) +} + +fn renamed_key(meta: &Meta) -> syn::Result { + let Meta::NameValue(rename) = meta else { + return Err(syn::Error::new_spanned(meta, "expected rename = \"key\"")); + }; + let value = &rename.value; + let lit: LitStr = syn::parse2(quote!(#value))?; + Ok(lit.value()) +} From 95a409a012262ba3e10a3893209c10bf828dfa73 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 4 Oct 2026 13:38:43 +0300 Subject: [PATCH 2/7] ndg-config: derive init output from config structs Signed-off-by: NotAShelf Change-Id: If04f3d6b3de27ea44dddac871236f6106a6a6964 --- Cargo.lock | 7 + crates/ndg-config/Cargo.toml | 2 +- crates/ndg-config/src/anchor.rs | 6 +- crates/ndg-config/src/assets.rs | 6 +- crates/ndg-config/src/config.rs | 17 +- crates/ndg-config/src/index.rs | 7 +- crates/ndg-config/src/markdown.rs | 6 +- crates/ndg-config/src/matchers.rs | 5 +- crates/ndg-config/src/meta.rs | 15 +- crates/ndg-config/src/options.rs | 21 +- crates/ndg-config/src/postprocess.rs | 18 +- crates/ndg-config/src/search.rs | 6 +- crates/ndg-config/src/sidebar.rs | 40 +- crates/ndg-config/src/templates.rs | 528 ++++++++++----------------- 14 files changed, 317 insertions(+), 367 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e47390357..bc50ba16a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4962,6 +4962,7 @@ dependencies = [ "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.3", ] @@ -5012,6 +5013,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" diff --git a/crates/ndg-config/Cargo.toml b/crates/ndg-config/Cargo.toml index ed8ce8933..f16de699c 100644 --- a/crates/ndg-config/Cargo.toml +++ b/crates/ndg-config/Cargo.toml @@ -28,7 +28,7 @@ serde.workspace = true serde_json.workspace = true serde_path_to_error.workspace = true thiserror.workspace = true -toml.workspace = true +toml = { workspace = true, features = [ "display" ] } [dev-dependencies] tempfile.workspace = true diff --git a/crates/ndg-config/src/anchor.rs b/crates/ndg-config/src/anchor.rs index 25b4d913b..af84b5e53 100644 --- a/crates/ndg-config/src/anchor.rs +++ b/crates/ndg-config/src/anchor.rs @@ -1,9 +1,11 @@ use std::str::FromStr; -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Default, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct AnchorConfig { #[config(key = "legacy_option_id_format")] diff --git a/crates/ndg-config/src/assets.rs b/crates/ndg-config/src/assets.rs index dbeeb79f8..c250afa85 100644 --- a/crates/ndg-config/src/assets.rs +++ b/crates/ndg-config/src/assets.rs @@ -1,10 +1,12 @@ -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use serde::{Deserialize, Serialize}; /// Configuration for custom assets copying /// /// Controls how custom assets from the assets directory are copied. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct AssetsConfig { /// Follow symbolic links when copying assets diff --git a/crates/ndg-config/src/config.rs b/crates/ndg-config/src/config.rs index 64947df0a..fa68f20e4 100644 --- a/crates/ndg-config/src/config.rs +++ b/crates/ndg-config/src/config.rs @@ -3,7 +3,7 @@ use std::{ path::{Path, PathBuf}, }; -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; @@ -41,7 +41,9 @@ pub const DEFAULT_TAB_STYLE: &str = "none"; /// generation, including input/output directories, template customization, /// search, syntax highlighting, and more. Fields are typically loaded from a /// TOML or JSON config file, but can also be set via CLI arguments. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct Config { /// Input directory containing markdown files. @@ -54,24 +56,30 @@ pub struct Config { /// Path to options.json file (optional). #[config(key = "module_options", allow_empty)] + #[template(example = PathBuf::from("options.json"))] pub module_options: Option, /// Path to custom template file. #[config(key = "template_path", allow_empty)] + #[template(example = PathBuf::from("templates/custom.html"))] pub template_path: Option, /// Path to template directory containing all template files. #[config(key = "template_dir", allow_empty)] + #[template(example = PathBuf::from("templates"))] pub template_dir: Option, /// Paths to custom stylesheets. + #[template(example = vec![PathBuf::from("assets/custom.css")])] pub stylesheet_paths: Vec, /// Paths to custom JavaScript files. + #[template(example = vec![PathBuf::from("assets/custom.js")])] pub script_paths: Vec, /// Directory containing additional assets. #[config(key = "assets_dir", allow_empty)] + #[template(example = PathBuf::from("assets"))] pub assets_dir: Option, /// Options for copying custom assets. @@ -80,10 +88,12 @@ pub struct Config { /// Path to manpage URL mappings JSON file. #[config(key = "manpage_urls_path", allow_empty)] + #[template(example = PathBuf::from("manpage-urls.json"))] pub manpage_urls_path: Option, /// Path to user-defined Tree-sitter query overrides. #[config(key = "syntax_queries_path", allow_empty)] + #[template(example = PathBuf::from("queries"))] pub syntax_queries_path: Option, /// Title for the documentation. @@ -92,6 +102,7 @@ pub struct Config { /// Number of threads to use for parallel processing. #[config(key = "jobs", allow_empty)] + #[template(example = 4usize)] pub jobs: Option, /// Whether to generate anchors for headings. @@ -158,6 +169,7 @@ pub struct Config { /// recursively for `.nix` files. Entries with nixdoc comments (`/** ... */`) /// are extracted and rendered as a library reference page (`lib.html`). #[serde(default)] + #[template(example = vec![PathBuf::from("lib")])] pub nixdoc_inputs: Vec, /// Index page configuration. @@ -822,7 +834,6 @@ impl Config { format: &str, path: &Path, ) -> Result<(), ConfigError> { - // Get template from the templates module let config_content = crate::templates::get_template(format) .map_err(|e| ConfigError::Template(e.to_string()))?; diff --git a/crates/ndg-config/src/index.rs b/crates/ndg-config/src/index.rs index c8f16ee6b..aa8e54922 100644 --- a/crates/ndg-config/src/index.rs +++ b/crates/ndg-config/src/index.rs @@ -4,7 +4,12 @@ /// whether to use `README.md` as the homepage and whether to generate a /// fallback index when no index file is provided. #[derive( - Debug, Clone, serde::Serialize, serde::Deserialize, ndg_macros::Configurable, + Debug, + Clone, + serde::Serialize, + serde::Deserialize, + ndg_macros::Configurable, + ndg_macros::ConfigTemplate, )] #[serde(default, deny_unknown_fields)] pub struct IndexConfig { diff --git a/crates/ndg-config/src/markdown.rs b/crates/ndg-config/src/markdown.rs index 6789de9cc..6d9aea607 100644 --- a/crates/ndg-config/src/markdown.rs +++ b/crates/ndg-config/src/markdown.rs @@ -1,9 +1,11 @@ use ndg_commonmark::MarkdownExtension; -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use serde::{Deserialize, Serialize}; /// Configuration for Markdown rendering. -#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Default, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct MarkdownConfig { /// Additional Comrak Markdown extensions to enable. diff --git a/crates/ndg-config/src/matchers.rs b/crates/ndg-config/src/matchers.rs index 9cf4a0f2f..9e9629507 100644 --- a/crates/ndg-config/src/matchers.rs +++ b/crates/ndg-config/src/matchers.rs @@ -1,3 +1,4 @@ +use ndg_macros::ConfigTemplate; use regex::Regex; use serde::{ Deserialize, @@ -83,14 +84,16 @@ where } /// Option name matching criteria (exact or regex). -#[derive(Debug, Clone, Serialize, Default)] +#[derive(Debug, Clone, Serialize, Default, ConfigTemplate)] pub struct OptionNameMatch { /// Exact option name match. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = "programs.git".to_string())] pub exact: Option, /// Regex pattern for option name matching. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = r"^services\..*".to_string())] pub regex: Option, /// Compiled regex cache (populated after validation). diff --git a/crates/ndg-config/src/meta.rs b/crates/ndg-config/src/meta.rs index f7bc71080..44dc87b1d 100644 --- a/crates/ndg-config/src/meta.rs +++ b/crates/ndg-config/src/meta.rs @@ -1,6 +1,6 @@ use std::{ffi::OsStr, path::PathBuf}; -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; @@ -24,7 +24,7 @@ fn default_rel() -> String { /// dest = "apple-touch-icon.png" /// rel = "apple-touch-icon" /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ConfigTemplate)] #[serde(deny_unknown_fields)] pub struct FaviconEntry { /// Path to the favicon file on disk. The file is copied to the root of the @@ -72,7 +72,9 @@ impl FaviconEntry { /// /// Contains `OpenGraph` tags, additional meta tags, and favicon entries to be /// injected into the HTML `` element. -#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Default, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct MetaConfig { /// `OpenGraph` tags (e.g., `{"og:title": "...", "og:image": "..."}`) @@ -83,5 +85,12 @@ pub struct MetaConfig { /// Favicon entries. Each entry produces one `` tag in the HTML head. #[serde(default)] + #[template(nested, example = FaviconEntry { + href: PathBuf::from("favicon.png"), + dest: None, + rel: default_rel(), + mime_type: None, + sizes: None, + })] pub favicon: Vec, } diff --git a/crates/ndg-config/src/options.rs b/crates/ndg-config/src/options.rs index f0a8f2d39..cc154667a 100644 --- a/crates/ndg-config/src/options.rs +++ b/crates/ndg-config/src/options.rs @@ -1,6 +1,6 @@ use std::path::{Component, Path}; -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use serde::{Deserialize, Serialize}; use crate::matchers::OptionNameMatch; @@ -10,7 +10,9 @@ const fn default_true() -> bool { } /// Filters applied to module options before rendering options documentation. -#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Default, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct OptionsConfig { /// Optional filtering configuration for module options. @@ -38,7 +40,9 @@ impl OptionsConfig { } /// Filters applied to module options before rendering options documentation. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct FilterConfig { /// Include only options whose names start with this prefix. @@ -140,7 +144,9 @@ fn default_pages_root() -> String { } /// Configuration for splitting option documentation across generated pages. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct OptionsPagesConfig { /// Whether multi-page option documentation is enabled. @@ -158,6 +164,7 @@ pub struct OptionsPagesConfig { pub root: String, /// Pattern-based matching rules for deep or custom option page groups. + #[template(nested)] pub matches: Vec, } @@ -220,15 +227,19 @@ impl OptionsPagesConfig { } /// Matching rule for custom option page routing. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive( + Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, ConfigTemplate, +)] #[serde(deny_unknown_fields)] pub struct OptionsPageMatch { /// Option name matching criteria. #[serde(skip_serializing_if = "Option::is_none")] + #[template(nested)] pub name: Option, /// Custom page prefix depth for matching options. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = 1usize)] pub depth: Option, /// Override display title for the generated page group. diff --git a/crates/ndg-config/src/postprocess.rs b/crates/ndg-config/src/postprocess.rs index 861a64b83..dad2e54d2 100644 --- a/crates/ndg-config/src/postprocess.rs +++ b/crates/ndg-config/src/postprocess.rs @@ -1,4 +1,4 @@ -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use serde::{Deserialize, Serialize}; /// Configuration for HTML/CSS/JS postprocessing @@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize}; /// - CSS: `lightningcss` - Production-grade CSS parsing and minification /// - JavaScript: `oxc_minifier` - Production-grade JavaScript minification with /// 17+ optimization passes -#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Default, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct PostprocessConfig { /// Whether to minify HTML output @@ -41,7 +43,9 @@ pub struct PostprocessConfig { /// Options for HTML minification /// /// These control `minify-html` behavior. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct HtmlMinifyOptions { /// Remove HTML comments @@ -62,7 +66,9 @@ impl Default for HtmlMinifyOptions { /// Options for CSS minification /// /// These control `lightningcss` behavior. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct CssMinifyOptions { /// Enable minification @@ -92,7 +98,9 @@ impl Default for CssMinifyOptions { /// Options for JavaScript minification /// /// These control `oxc_minifier` behavior. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct JsMinifyOptions { /// Enable compression optimizations diff --git a/crates/ndg-config/src/search.rs b/crates/ndg-config/src/search.rs index 08afb6ab3..20cdac0c4 100644 --- a/crates/ndg-config/src/search.rs +++ b/crates/ndg-config/src/search.rs @@ -1,8 +1,10 @@ -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use serde::{Deserialize, Serialize}; /// Configuration for search functionality -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct SearchConfig { /// Whether search functionality is enabled diff --git a/crates/ndg-config/src/sidebar.rs b/crates/ndg-config/src/sidebar.rs index ffa6d8157..860f67809 100644 --- a/crates/ndg-config/src/sidebar.rs +++ b/crates/ndg-config/src/sidebar.rs @@ -1,11 +1,13 @@ -use ndg_macros::Configurable; +use ndg_macros::{ConfigTemplate, Configurable}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize}; use crate::matchers::{MatchField, OptionNameMatch, deserialize_match_field}; /// Configuration for sidebar behavior. -#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Default, Configurable, ConfigTemplate, +)] #[serde(deny_unknown_fields)] #[expect( clippy::struct_excessive_bools, @@ -42,14 +44,17 @@ pub struct SidebarConfig { /// Pattern-based matching rules for sidebar items. #[serde(default)] + #[template(nested)] pub matches: Vec, /// Per-page table of contents configuration. #[serde(default)] + #[template(nested)] pub toc: SidebarTocConfig, /// Options sidebar configuration. #[serde(default)] + #[template(nested)] pub options: Option, } @@ -98,10 +103,13 @@ impl SidebarConfig { } /// Configuration for generated per-page tables of contents. -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[derive( + Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, ConfigTemplate, +)] pub struct SidebarTocConfig { /// Heading titles to exclude from tables of contents. #[serde(default)] + #[template(nested)] pub exclude: Vec, } @@ -159,14 +167,16 @@ impl std::str::FromStr for SidebarOrdering { } /// Path matching criteria -#[derive(Debug, Clone, Serialize, Default)] +#[derive(Debug, Clone, Serialize, Default, ConfigTemplate)] pub struct PathMatch { /// Exact path match. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = "getting-started.md".to_string())] pub exact: Option, /// Regex pattern for path matching. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = r"^guides/.*\.md$".to_string())] pub regex: Option, /// Compiled regex cache (populated after validation). @@ -234,14 +244,16 @@ impl<'de> Deserialize<'de> for PathMatch { } /// Title matching criteria (exact or regex). -#[derive(Debug, Clone, Serialize, Default)] +#[derive(Debug, Clone, Serialize, Default, ConfigTemplate)] pub struct TitleMatch { /// Exact title match. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = "Release Notes".to_string())] pub exact: Option, /// Regex pattern for title matching. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = "^Tutorial:.*".to_string())] pub regex: Option, /// Compiled regex cache (populated after validation). @@ -350,15 +362,19 @@ impl<'de> Deserialize<'de> for TitleMatch { } /// Pattern-based matching rule for sidebar items. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive( + Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, ConfigTemplate, +)] #[serde(deny_unknown_fields)] pub struct SidebarMatch { /// Path matching criteria. #[serde(skip_serializing_if = "Option::is_none")] + #[template(nested)] pub path: Option, /// Title matching criteria. #[serde(skip_serializing_if = "Option::is_none")] + #[template(nested)] pub title: Option, /// Override title with this value. @@ -372,6 +388,7 @@ pub struct SidebarMatch { /// 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")] + #[template(example = "getting-started.md".to_string())] pub parent: Option, } @@ -469,7 +486,9 @@ impl SidebarMatch { } /// Configuration for options sidebar behavior. -#[derive(Debug, Clone, Serialize, Deserialize, Configurable)] +#[derive( + Debug, Clone, Serialize, Deserialize, Configurable, ConfigTemplate, +)] #[serde(default, deny_unknown_fields)] pub struct OptionsConfig { /// Depth of parent categories in options TOC. @@ -495,6 +514,7 @@ pub struct OptionsConfig { pub ordering: SidebarOrdering, /// Pattern-based matching rules for options. + #[template(nested)] pub matches: Vec, } @@ -542,11 +562,14 @@ impl OptionsConfig { } /// Matching rule for options. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive( + Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, ConfigTemplate, +)] #[serde(deny_unknown_fields)] pub struct OptionsMatch { /// Option name matching criteria. #[serde(skip_serializing_if = "Option::is_none")] + #[template(nested)] pub name: Option, /// Override display name with this value. @@ -555,6 +578,7 @@ pub struct OptionsMatch { /// Custom grouping depth for this option. #[serde(skip_serializing_if = "Option::is_none")] + #[template(example = 2usize)] pub depth: Option, /// Custom position in sidebar. diff --git a/crates/ndg-config/src/templates.rs b/crates/ndg-config/src/templates.rs index 92255a5f7..5c339cd60 100644 --- a/crates/ndg-config/src/templates.rs +++ b/crates/ndg-config/src/templates.rs @@ -1,354 +1,218 @@ -use std::fmt; +//! Struct-derived starter configuration and annotated TOML rendering. -/// Error type for template operations. -/// -/// Represents the various errors that can occur during template -/// operations, providing clear error messages and context for debugging. -#[derive(Debug)] +use std::{borrow::Cow, path::PathBuf}; + +use serde::Serialize; +use thiserror::Error; + +use crate::Config; + +/// Errors encountered while rendering a starter configuration. +#[derive(Debug, Error)] pub enum TemplateError { - /// Indicates that the requested configuration format is not supported. - /// Contains the name of the unsupported format. + /// The requested file format is unsupported. + #[error("Unsupported config format: {0}")] UnsupportedFormat(String), + /// A configuration value cannot be represented as TOML. + #[error("Failed to serialize TOML configuration: {0}")] + Toml(#[from] toml::ser::Error), + /// A configuration value cannot be represented as JSON. + #[error("Failed to serialize JSON configuration: {0}")] + Json(#[from] serde_json::Error), } -impl fmt::Display for TemplateError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnsupportedFormat(format) => { - write!(f, "Unsupported config format: {format}") - }, - } - } +pub(crate) trait ConfigTemplate { + fn write_template( + &self, + writer: &mut TemplateWriter, + ) -> Result<(), TemplateError>; } -impl std::error::Error for TemplateError {} - -/// Default configuration template in TOML. We've also included sufficient -/// amount of comments to explain each field so that the user is not immediately -/// lost. Yuck though, I do not like having to embed a string one bit. -pub const DEFAULT_TOML_TEMPLATE: &str = r#"# NDG Configuration File - -# Input directory containing markdown files -input_dir = "docs" - -# Output directory for generated documentation -output_dir = "build" - -# Path to options.json file (optional) -# module_options = "options.json" - -# Nix files or directories containing RFC 145-style /** ... */ comments. -# Requires an ndg build with the default `nixdoc` Cargo feature enabled. -# nixdoc_inputs = ["lib"] - -# Title for the documentation -title = "My Project Documentation" - -# Footer text for the documentation -footer_text = "Generated with ndg" - -# Number of threads to use for parallel processing (defaults to number of CPU cores) -# jobs = 4 - -# Template customization -# Path to custom template file -# template_path = "templates/custom.html" - -# Path to template directory containing all template files -# template_dir = "templates" - -# Path to custom stylesheet -# stylesheet_path = "assets/custom.css" - -# Paths to custom JavaScript files -# script_paths = ["assets/custom.js", "assets/search.js"] - -# Directory containing additional assets -# assets_dir = "assets" - -# Path to manpage URL mappings JSON file -# manpage_urls_path = "manpage-urls.json" - -# Whether to generate anchors for headings -generate_anchors = true - -# Whether to enable syntax highlighting for code blocks -highlight_code = true - -# Path to user-defined Tree-sitter query overrides -# syntax_queries_path = "queries" - -# How to handle hard tabs in code blocks (one of "none", "warn", or "normalize") -tab_style = "none" - -# Duplicate heading anchor handling (one of "error", "warn", or "deduplicate") -# - "error": fail the build on duplicate anchors (default) -# - "warn": log a warning and keep duplicate IDs -# - "deduplicate": make anchors unique with deterministic -1, -2, ... suffixes -# Headings matching [sidebar.toc] exclusions are ignored for validation. -# [anchor] -# on_duplicate = "error" - -# GitHub revision for linking to source files (defaults to 'local') -revision = "main" - -# Markdown rendering options -# [markdown] -# extensions = ["math-dollars", "math-code", "math-latex"] - -# Index page configuration -# [index] -# Whether to use README.md as the homepage when index.md is not present. -# When enabled, README.md will be rendered as index.html instead of README.html. -# use_readme = false - -# Whether to generate a fallback index.html when no index file is provided. -# When disabled, no fallback index will be created if there's no index.md or README.md. -# generate_fallback = true - -# OpenGraph tags to inject into the HTML head (example: { og:title = "My Project", og:image = "..." }) -# opengraph = { og:title = "My Project", og:image = "https://example.com/image.png" } - -# 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 - -# Global module options configuration -# [options.filter] -# Include only option names under this prefix. -# prefix = "services.nginx" - -# Include only options whose type contains this text. -# type = "boolean" - -# Include only options whose name or description contains this text. -# search = "enable" - -# Include only options with default values or descriptions. -# has_default = false -# has_description = false - -# Include options marked internal or invisible. -# include_internal = true - -# Split option documentation into generated group pages. -# [options.pages] -# enabled = false -# depth = 1 -# root = "options" - -# Route deep option trees to their own page. -# [[options.pages.matches]] -# name.regex = "^foo\\.bar\\.baz(\\.|$)" -# depth = 3 -# title = "Foo Bar Baz" -# position = 10 - -# Sidebar configuration -# [sidebar] -# Enable numbering for sidebar items -# numbered = true - -# Include special files in numbering sequence -# Only has effect when numbered = true. -# number_special_files = false - -# Ordering algorithm for sidebar items: "alphabetical", "custom", or "filesystem" -# ordering = "alphabetical" - -# Exclude headings from generated per-page tables of contents. -# [sidebar.toc] -# [[sidebar.toc.exclude]] -# regex = "^(Inputs|Type|Examples)$" - -# Pattern-based sidebar matching rules -# Rules are evaluated in order, and the first matching rule is applied. -# All specified conditions are expected to match. - -# Simple exact path match (shorthand syntax) -# [[sidebar.matches]] -# 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" -# new_title = "What's New" -# position = 999 - -# Regex patterns for more advanced matching (nested syntax required) -# [[sidebar.matches]] -# path.regex = "^api/.*\\.md$" -# position = 50 - -# Combined conditions (path regex & title regex) -# [[sidebar.matches]] -# path.regex = "^guides/.*\\.md$" -# title.regex = "^Tutorial:.*" -# position = 10 - -# Options sidebar configuration -# [sidebar.options] -# Depth of parent categories in options TOC (defaults to 2) -# depth = 2 - -# Render child attributes as recursive dropdowns. -# nested = false -# Maximum child nesting depth below each category, or 0 for unlimited. -# nested_depth = 0 -# When nested, keep one-child categories as dropdowns instead of direct links. -# collapse_singletons = false - -# Ordering algorithm for options: "alphabetical", "custom", or "filesystem" -# ordering = "alphabetical" - -# Pattern-based options matching rules -# Rules are evaluated in order, and the first matching rule is applied. - -# Hide internal options from the TOC -# [[sidebar.options.matches]] -# name.regex = "^myInternal\\..*" -# hidden = true - -# Rename a category for display -# [[sidebar.options.matches]] -# name = "programs.git" -# new_name = "Git Configuration" -# position = 5 +#[derive(Default)] +pub(crate) struct TemplateWriter { + output: String, + path: String, + value: String, + commented: bool, +} -# Set custom depth for specific options -# [[sidebar.options.matches]] -# name.regex = "^services\\..*" -# depth = 3 -# position = 10 +impl TemplateWriter { + fn comments(&mut self, docs: &str) { + for line in docs.lines() { + self.output.push_str("# "); + self.output.push_str(line); + self.output.push('\n'); + } + } -# Basic exact name match (shorthand syntax) -# [[sidebar.options.matches]] -# name = "networking.firewall" -# new_name = "Firewall Settings" -# position = 1 -"#; + pub(crate) fn field( + &mut self, + key: &str, + docs: &str, + value: &T, + commented: bool, + ) -> Result<(), TemplateError> { + self.value.clear(); + value.serialize(toml::ser::ValueSerializer::new(&mut self.value))?; + self.comments(docs); + let prefix = if self.commented || commented { + "# " + } else { + "" + }; + self.output.push_str(prefix); + self.output.push_str(&toml_key(key)); + self.output.push_str(" = "); + // TOML strings can contain literal newlines; comment every line of + // examples. + for (index, line) in self.value.lines().enumerate() { + if index > 0 { + self.output.push('\n'); + self.output.push_str(prefix); + } + self.output.push_str(line); + } + self.output.push_str("\n\n"); + Ok(()) + } -/// Default configuration template in JSON format. -pub const DEFAULT_JSON_TEMPLATE: &str = r#"{ - "input_dir": "docs", - "output_dir": "build", - "nixdoc_inputs": [], - "title": "My Project Documentation", - "footer_text": "Generated with ndg", - "generate_anchors": true, - "search": { - "enable": true, - "max_heading_level": 3 - }, - "options": { - "filter": { - "has_default": false, - "has_description": false, - "include_internal": true - }, - "pages": { - "enabled": false, - "depth": 1, - "root": "options", - "matches": [ - { - "name": { - "regex": "^foo\\.bar\\.baz(\\.|$)" - }, - "depth": 3, - "title": "Foo Bar Baz", - "position": 10 - } - ] + pub(crate) fn section( + &mut self, + key: &str, + docs: &str, + value: &T, + commented: bool, + array: bool, + ) -> Result<(), TemplateError> { + let previous_len = self.path.len(); + let previous_commented = self.commented; + if !self.path.is_empty() { + self.path.push('.'); } - }, - "highlight_code": true, - "markdown": { - "extensions": [] - }, - "syntax_queries_path": "queries", - "tab_style": "none", - "revision": "main", - "index": { - "use_readme": false, - "generate_fallback": true - }, - "sidebar": { - "numbered": true, - "number_special_files": false, - "ordering": "alphabetical", - "matches": [ - { - "path": "getting-started.md", - "parent": null, - "position": 1 - }, - { - "title": "Release Notes", - "new_title": "What's New", - "position": 999 - }, - { - "path": { - "regex": "^api/.*\\.md$" - }, - "position": 50 - } - ], - "options": { - "depth": 2, - "nested": false, - "nested_depth": 0, - "ordering": "alphabetical", - "matches": [ - { - "name": { - "regex": "^myInternal\\..*" - }, - "hidden": true - }, - { - "name": "programs.git", - "new_name": "Git Configuration", - "position": 5 - }, - { - "name": { - "regex": "^services\\..*" - }, - "depth": 3, - "position": 10 - } - ] + self.path.push_str(&toml_key(key)); + self.commented |= commented; + self.comments(docs); + if self.commented { + self.output.push_str("# "); } + self.output.push_str(if array { "[[" } else { "[" }); + self.output.push_str(&self.path); + self.output.push_str(if array { "]]\n\n" } else { "]\n\n" }); + let result = value.write_template(self); + self.path.truncate(previous_len); + self.commented = previous_commented; + result + } +} + +fn toml_key(key: &str) -> Cow<'_, str> { + if !key.is_empty() + && key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + Cow::Borrowed(key) + } else { + Cow::Owned(toml::Value::String(key.to_string()).to_string()) + } +} + +fn starter_config() -> Config { + Config { + input_dir: Some(PathBuf::from("docs")), + ..Config::default() } } -"#; -/// Get the correct configuration template based on the requested format. +/// Generate a starter configuration using the configuration types and defaults. +/// +/// TOML includes field documentation and commented examples for absent optional +/// settings. JSON contains only the starter values, never the TOML examples. /// /// # Errors /// -/// Returns an error if the requested format is not supported. -pub fn get_template(format: &str) -> Result<&'static str, TemplateError> { +/// Returns an error for unsupported formats or values that cannot be +/// serialized. +/// +/// # Examples +/// +/// ``` +/// let config = ndg_config::templates::get_template("json")?; +/// let _: ndg_config::Config = serde_json::from_str(&config)?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn get_template(format: &str) -> Result { + let config = starter_config(); match format.to_lowercase().as_str() { - "toml" => Ok(DEFAULT_TOML_TEMPLATE), - "json" => Ok(DEFAULT_JSON_TEMPLATE), + "toml" => { + let mut writer = TemplateWriter { + output: "# NDG Configuration File\n# Optional settings and sections \ + are commented out.\n\n" + .to_string(), + ..TemplateWriter::default() + }; + config.write_template(&mut writer)?; + Ok(writer.output) + }, + "json" => Ok(serde_json::to_string_pretty(&config)? + "\n"), _ => Err(TemplateError::UnsupportedFormat(format.to_string())), } } + +#[cfg(test)] +#[expect( + clippy::unwrap_used, + reason = "Tests can unwrap generated configurations" +)] +mod tests { + use tempfile::tempdir; + + use super::*; + use crate::{options, sidebar}; + + #[test] + fn test_init_formats_load_the_same_starter() { + let temp = tempdir().unwrap(); + let expected = serde_json::to_value(starter_config()).unwrap(); + for format in ["toml", "json"] { + let path = temp.path().join(format!("ndg.{format}")); + Config::generate_default_config(format, &path).unwrap(); + let config = Config::from_file(path).unwrap(); + assert_eq!(serde_json::to_value(config).unwrap(), expected); + } + } + + #[test] + fn test_template_nested_tables_preserve_root_values_and_rule_names() { + let config = Config { + title: "Quotes \" and\na new line".to_string(), + options: Some(options::OptionsConfig { + filter: Some(options::FilterConfig { + type_name: Some("boolean".to_string()), + ..options::FilterConfig::default() + }), + ..options::OptionsConfig::default() + }), + sidebar: Some(sidebar::SidebarConfig { + matches: vec![sidebar::SidebarMatch { + path: Some(sidebar::PathMatch { + exact: Some("guide.md".to_string()), + ..sidebar::PathMatch::default() + }), + position: Some(2), + ..sidebar::SidebarMatch::default() + }], + ..sidebar::SidebarConfig::default() + }), + ..starter_config() + }; + let mut writer = TemplateWriter::default(); + config.write_template(&mut writer).unwrap(); + let mut parsed: Config = toml::from_str(&writer.output).unwrap(); + parsed.validate().unwrap(); + assert_eq!( + serde_json::to_value(parsed).unwrap(), + serde_json::to_value(config).unwrap() + ); + } +} From f13cce147582a97e90c329a633daff75131ecf98 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 4 Oct 2026 13:38:54 +0300 Subject: [PATCH 3/7] docs: fixup 2.10.2 changelog; document `ndg init` changes Signed-off-by: NotAShelf Change-Id: I9dff65447dd9dd79485551cb02b3e8a66a6a6964 --- CHANGELOG.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa5d9134..de2a65376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,15 @@ changes. `sidebar.group_by_dir` report errors ([#290](https://github.com/feel-co/ndg/issues/290)). +### Changed + +- `ndg init` now generates configuration from the config types and defaults, + keeping TOML field comments and optional-setting examples in sync as settings + are added. JSON uses the same starter values without activating example paths + or sidebar rules. Long time coming honestly. + +## [2.10.2] + ### Fixed - Pages now link each script in `script_paths` at `assets/`, where @@ -60,6 +69,25 @@ changes. navigation links synchronized when non-heading anchors share their IDs, and preserves explicitly assigned IDs that collide with generated suffixes ([#286](https://github.com/feel-co/ndg/issues/286)). +- Sidebar and per-page TOC links now match rendered heading IDs for headings + containing quoted inline code, such as `"/package"`. Existing heading IDs and + deep links remain unchanged + ([#293](https://github.com/feel-co/ndg/issues/293)). + +### Changed + +- The documentation site now includes the ndg-commonmark syntax and highlighting + guides, with navigation entries and searchable content + ([#291](https://github.com/feel-co/ndg/issues/291)). + +## [2.10.1] + +### Fixed + +- Headings excluded from the per-page TOC now still follow the configured + duplicate-anchor policy. Exclusions no longer bypass duplicate validation or + deduplication, including for cached Markdown renders. +- Toggling the sidebar no longer shifts the main content horizontally. ## [2.10.0] @@ -712,7 +740,9 @@ documentation using the old hyphen-based format will need to be updated**. - Prevented panic in theme name fallback logic in ndg-commonmark - Prevented panic on empty fence character extraction in ndg-commonmark -[Unreleased]: https://github.com/feel-co/ndg/compare/v2.10.0..HEAD +[Unreleased]: https://github.com/feel-co/ndg/compare/v2.10.2..HEAD +[2.10.2]: https://github.com/feel-co/ndg/compare/v2.10.1..v2.10.2 +[2.10.1]: https://github.com/feel-co/ndg/compare/v2.10.0..v2.10.1 [2.10.0]: https://github.com/feel-co/ndg/compare/v2.9.0..v2.10.0 [2.9.0]: https://github.com/feel-co/ndg/compare/v2.8.2...v2.9.0 [2.8.2]: https://github.com/feel-co/ndg/compare/v2.8.1...v2.8.2 From 6c93b5e5c37f5c11e8bebe5b63641b2fd21b02fe Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 4 Oct 2026 13:53:32 +0300 Subject: [PATCH 4/7] various: validate custom script destinations before rendering and copying Followup for #298 Signed-off-by: NotAShelf Change-Id: I44546890ee0af5986ee2c7cd871a64be6a6a6964 --- crates/ndg-config/src/config.rs | 40 +++++++ crates/ndg-html/src/template.rs | 10 +- crates/ndg-html/tests/custom_scripts.rs | 136 +++++++++++++++++++----- crates/ndg-utils/src/assets.rs | 1 + 4 files changed, 159 insertions(+), 28 deletions(-) diff --git a/crates/ndg-config/src/config.rs b/crates/ndg-config/src/config.rs index fa68f20e4..d51fa6f6e 100644 --- a/crates/ndg-config/src/config.rs +++ b/crates/ndg-config/src/config.rs @@ -657,12 +657,52 @@ impl Config { None } + /// Validate filenames used when copying custom scripts into `assets/`. + /// + /// This checks destinations without reading source files, so rendering can + /// enforce the same rules as asset copying. + /// + /// # Errors + /// + /// Returns an error if a script has no filename or two scripts would share + /// the same destination filename. + /// + /// # Examples + /// + /// ``` + /// let config = ndg_config::Config::default(); + /// config.validate_script_destinations()?; + /// # Ok::<(), ndg_config::ConfigError>(()) + /// ``` + pub fn validate_script_destinations(&self) -> Result<(), ConfigError> { + let mut destinations = FxHashMap::default(); + for script_path in &self.script_paths { + let filename = script_path.file_name().ok_or_else(|| { + ConfigError::Config(format!( + "Invalid script_paths entry '{}': expected a path with a filename", + script_path.display() + )) + })?; + if let Some(previous) = destinations.insert(filename, script_path) { + return Err(ConfigError::Config(format!( + "script_paths entries '{}' and '{}' both copy to 'assets/{}'; \ + script filenames must be unique", + previous.display(), + script_path.display(), + filename.to_string_lossy() + ))); + } + } + Ok(()) + } + /// Validate all paths specified in the configuration /// /// # Errors /// /// Returns an error if any configured path does not exist or is invalid. pub fn validate_paths(&self) -> Result<(), ConfigError> { + self.validate_script_destinations()?; let mut errors = Vec::new(); // Module options file should exist if specified diff --git a/crates/ndg-html/src/template.rs b/crates/ndg-html/src/template.rs index bde077a11..19b4ed9a7 100644 --- a/crates/ndg-html/src/template.rs +++ b/crates/ndg-html/src/template.rs @@ -1619,6 +1619,7 @@ fn generate_custom_scripts( config: &Config, current_file_rel_path: &Path, ) -> Result { + config.validate_script_destinations()?; let mut custom_scripts = String::new(); let root_prefix = ndg_utils::html::calculate_root_relative_path(current_file_rel_path); @@ -1629,9 +1630,12 @@ fn generate_custom_scripts( for script_path in &config.script_paths { // `copy_assets` copies each script to `assets/`, so link there, // not to the configured source path. - let Some(file_name) = script_path.file_name() else { - continue; - }; + let file_name = script_path.file_name().ok_or_else(|| { + color_eyre::eyre::eyre!( + "Invalid script_paths entry '{}': expected a path with a filename", + script_path.display() + ) + })?; let script_relative_path = format!("{}assets/{}", root_prefix, file_name.to_string_lossy()); write!( diff --git a/crates/ndg-html/tests/custom_scripts.rs b/crates/ndg-html/tests/custom_scripts.rs index d923cc9bf..8f2d7724f 100644 --- a/crates/ndg-html/tests/custom_scripts.rs +++ b/crates/ndg-html/tests/custom_scripts.rs @@ -1,40 +1,126 @@ #![allow(clippy::expect_used, reason = "Fine in tests")] -use std::path::PathBuf; +use std::{ + fs, + path::{Path, PathBuf}, +}; mod common; use common::test_config; use ndg_html::template::render; -use tempfile::TempDir; +use ndg_utils::assets::copy_assets; +use tempfile::{TempDir, tempdir_in}; #[test] fn test_custom_script_links_to_copied_asset() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let output_dir = temp_dir.path(); - - // A script outside the output directory, as with a path in the Nix store. + let sources = TempDir::new().expect("Failed to create script sources"); + let cwd = std::env::current_dir().expect("Failed to resolve test directory"); + let relative_sources = + tempdir_in(&cwd).expect("Failed to create relative sources"); + let output = TempDir::new().expect("Failed to create output"); + let absolute_script = sources.path().join("absolute.js"); + let relative_script = relative_sources + .path() + .strip_prefix(&cwd) + .expect("Relative source must be under the test directory") + .join("relative.js"); + fs::write(&absolute_script, "window.absoluteScript = true;") + .expect("Write absolute script"); + fs::write(&relative_script, "window.relativeScript = true;") + .expect("Write relative script"); let config = ndg_config::Config { - script_paths: vec![PathBuf::from("/nix/store/abc-scripts/custom.js")], - ..test_config(output_dir) + script_paths: vec![absolute_script, relative_script], + ..test_config(output.path()) }; + copy_assets(&config).expect("Failed to copy scripts"); - let html = render( - &config, - "

Test content

", - "Test Page", - &[], - &PathBuf::from("docs/test.html"), - None, - ) - .expect("Failed to render page"); + for page in [Path::new("index.html"), Path::new("docs/test.html")] { + let html = + render(&config, "

Test content

", "Test Page", &[], page, None) + .expect("Failed to render page"); + let page_dir = output + .path() + .join(page) + .parent() + .expect("Page parent") + .to_path_buf(); + fs::create_dir_all(&page_dir).expect("Create page directory"); + for (filename, content) in [ + ("absolute.js", "window.absoluteScript = true;"), + ("relative.js", "window.relativeScript = true;"), + ] { + let src = html + .split(""), - "Script should link to the copied asset" - ); - assert!( - !html.contains("/nix/store/abc-scripts/custom.js"), - "Script should not link to its source path" +#[test] +fn test_custom_scripts_reject_paths_without_filenames() { + let output = TempDir::new().expect("Failed to create output"); + let config = ndg_config::Config { + script_paths: vec![PathBuf::from("/")], + ..test_config(output.path()) + }; + let render_error = + render(&config, "", "Test", &[], Path::new("index.html"), None) + .expect_err("Rendering must reject a script without a filename") + .to_string(); + let copy_error = copy_assets(&config) + .expect_err("Copying must reject a script without a filename") + .to_string(); + assert_eq!(render_error, copy_error); + assert!(render_error.contains("Invalid script_paths entry '/'")); + assert!(render_error.contains("expected a path with a filename")); + assert!(!output.path().join("assets").exists()); +} + +#[test] +fn test_custom_scripts_reject_duplicate_destination_filenames() { + let temp = TempDir::new().expect("Failed to create sources"); + let first = temp.path().join("first/custom.js"); + let second = temp.path().join("second/custom.js"); + fs::create_dir_all(first.parent().expect("First parent")) + .expect("Create first source directory"); + fs::create_dir_all(second.parent().expect("Second parent")) + .expect("Create second source directory"); + fs::write(&first, "window.first = true;").expect("Write first script"); + fs::write(&second, "window.second = true;").expect("Write second script"); + let output = temp.path().join("output"); + fs::create_dir_all(output.join("assets")).expect("Create existing assets"); + fs::write(output.join("assets/custom.js"), "previous script") + .expect("Write existing destination"); + let config = ndg_config::Config { + script_paths: vec![first.clone(), second.clone()], + ..test_config(&output) + }; + let render_error = + render(&config, "", "Test", &[], Path::new("index.html"), None) + .expect_err("Rendering must reject conflicting scripts") + .to_string(); + let copy_error = copy_assets(&config) + .expect_err("Copying must reject conflicting scripts") + .to_string(); + let config_error = config + .validate_paths() + .expect_err("Config validation must reject conflicting scripts") + .to_string(); + assert_eq!(render_error, copy_error); + assert_eq!(render_error, config_error); + assert!(render_error.contains(&first.display().to_string())); + assert!(render_error.contains(&second.display().to_string())); + assert!(render_error.contains("both copy to 'assets/custom.js'")); + assert_eq!( + fs::read_to_string(output.join("assets/custom.js")) + .expect("Read original destination"), + "previous script" ); } diff --git a/crates/ndg-utils/src/assets.rs b/crates/ndg-utils/src/assets.rs index 7659bf974..44ffa37c7 100644 --- a/crates/ndg-utils/src/assets.rs +++ b/crates/ndg-utils/src/assets.rs @@ -30,6 +30,7 @@ const MAIN_JS: &str = templates::MAIN_JS; /// /// Returns an error if any asset cannot be read or written. pub fn copy_assets(config: &Config) -> Result<()> { + config.validate_script_destinations()?; // Create assets directory let assets_dir = config.output_dir.join("assets"); fs::create_dir_all(&assets_dir)?; From 123d94396a0dccd11e4a091c840ba3850ad971f7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 4 Oct 2026 14:14:18 +0300 Subject: [PATCH 5/7] ndg-manpage: fixup formatting Signed-off-by: NotAShelf Change-Id: I81a781e8da590357a52c44a740bd2cc96a6a6964 --- crates/ndg-manpage/src/options.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ndg-manpage/src/options.rs b/crates/ndg-manpage/src/options.rs index 639a9e147..1effe9468 100644 --- a/crates/ndg-manpage/src/options.rs +++ b/crates/ndg-manpage/src/options.rs @@ -289,11 +289,11 @@ pub fn generate_manpage( fn process_raw_type(s: &str) -> String { // Replace common escape sequences with their troff equivalents let s = s - .replace('"', "\\[u201C]") // opening double quote - .replace("\\n", "\\en") // newline - .replace('\'', "\\[u2019]") // single quote - .replace('-', "\\-") // hyphen - .replace('.', "\\&."); // period + .replace('"', "\\[u201C]") // opening double quote + .replace("\\n", "\\en") // newline + .replace('\'', "\\[u2019]") // single quote + .replace('-', "\\-") // hyphen + .replace('.', "\\&."); // period // For closing quote after \n From 27efe7b9e2f0213b5ba239826f27a70542afcc40 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 4 Oct 2026 14:27:03 +0300 Subject: [PATCH 6/7] treewide: lint and format with Rust 1.101 ahead of time Signed-off-by: NotAShelf Change-Id: Ic73d33ddd054af9803b9f72be13b38576a6a6964 --- crates/ndg-config/src/sidebar.rs | 29 +---- crates/ndg-html/tests/html_template.rs | 35 ------ crates/ndg-pdf/src/lib.rs | 2 +- crates/ndg-utils/src/assets.rs | 25 ++--- crates/ndg-utils/src/markdown.rs | 2 +- ndg-commonmark/src/processor/extensions.rs | 6 +- ndg-commonmark/tests/formatter_markup.rs | 4 +- ndg-commonmark/tests/integration.rs | 2 - ndg-commonmark/tests/processor.rs | 2 - ndg/src/main.rs | 119 ++++++++++----------- ndg/tests/integration.rs | 24 +---- 11 files changed, 82 insertions(+), 168 deletions(-) diff --git a/crates/ndg-config/src/sidebar.rs b/crates/ndg-config/src/sidebar.rs index 860f67809..fda7b52bc 100644 --- a/crates/ndg-config/src/sidebar.rs +++ b/crates/ndg-config/src/sidebar.rs @@ -461,15 +461,10 @@ impl SidebarMatch { } // Check title matching - if self + self .title .as_ref() - .is_some_and(|title_match| !title_match.matches(title_str)) - { - return false; - } - - true + .is_none_or(|title_match| title_match.matches(title_str)) } /// Get the position for this match. @@ -711,17 +706,6 @@ mod tests { assert!(matches!(wrapper.ordering, SidebarOrdering::Filesystem)); } - #[test] - fn test_sidebar_config_default() { - let config = SidebarConfig::default(); - assert!(!config.numbered); - assert!(!config.number_special_files); - assert!(matches!(config.ordering, SidebarOrdering::Alphabetical)); - assert!(config.matches.is_empty()); - assert!(config.toc.exclude.is_empty()); - assert!(config.options.is_none()); - } - #[test] fn test_sidebar_match_exact_path() { let m = SidebarMatch { @@ -1118,15 +1102,6 @@ position = 1 // Options configuration tests - #[test] - fn test_options_config_default() { - let config = OptionsConfig::default(); - assert_eq!(config.depth, 2); - assert!(!config.collapse_singletons); - assert!(matches!(config.ordering, SidebarOrdering::Alphabetical)); - assert!(config.matches.is_empty()); - } - #[test] fn test_options_match_exact_name() { let m = OptionsMatch { diff --git a/crates/ndg-html/tests/html_template.rs b/crates/ndg-html/tests/html_template.rs index 0d6fb5ea8..affb4c20e 100644 --- a/crates/ndg-html/tests/html_template.rs +++ b/crates/ndg-html/tests/html_template.rs @@ -937,41 +937,6 @@ fn render_page_falls_back_to_default_template() { assert!(!html.contains("special-page")); } -#[test] -fn navbar_respects_search_generation_flag() { - let mut config = minimal_config(); - config.search = Some(ndg_config::search::SearchConfig { - enable: true, - ..Default::default() - }); - - let content = "

Test content

"; - let title = "Test Page"; - let headers: Vec
= vec![]; - let rel_path = Path::new("test.html"); - - let html = - template::render(&config, content, title, &headers, rel_path, None) - .expect("Should render HTML"); - - // Should contain search link when enabled - assert!(html.contains("Search") || html.contains("search")); - - // Test with search disabled - config.search = Some(ndg_config::search::SearchConfig { - enable: false, - ..Default::default() - }); - let html_no_search = - template::render(&config, content, title, &headers, rel_path, None) - .expect("Should render HTML"); - - // The navbar might still contain the word "search" in template structure, - // but the search link should be conditionally rendered - // We just verify it renders successfully with different configs - assert!(!html_no_search.is_empty()); -} - #[test] fn navbar_shows_options_link_when_configured() { let mut config = minimal_config(); diff --git a/crates/ndg-pdf/src/lib.rs b/crates/ndg-pdf/src/lib.rs index 6518438ed..b88fe7a3f 100644 --- a/crates/ndg-pdf/src/lib.rs +++ b/crates/ndg-pdf/src/lib.rs @@ -1135,7 +1135,7 @@ impl PdfBuilder { } if level == 1 { - let rule_y = self.y + line_h * 0.5; + let rule_y = line_h.mul_add(0.5, self.y); self.draw_line( Self::ML, rule_y, diff --git a/crates/ndg-utils/src/assets.rs b/crates/ndg-utils/src/assets.rs index 44ffa37c7..c05643851 100644 --- a/crates/ndg-utils/src/assets.rs +++ b/crates/ndg-utils/src/assets.rs @@ -353,18 +353,19 @@ fn generate_css(config: &Config) -> eyre::Result { })?; // Process SCSS if needed - let processed_content = - if stylesheet_path.extension().is_some_and(|ext| ext == "scss") { - grass::from_string(content.clone(), &grass::Options::default()) - .wrap_err({ - format!( - "Failed to compile SCSS to CSS for stylesheet {}", - index + 1 - ) - })? - } else { - content - }; + let processed_content = if stylesheet_path + .extension() + .is_some_and(|ext| ext == "scss") + { + grass::from_string(content, &grass::Options::default()).wrap_err({ + format!( + "Failed to compile SCSS to CSS for stylesheet {}", + index + 1 + ) + })? + } else { + content + }; // Add a comment to separate multiple stylesheets combined_css.push_str("\n\n/* Custom Stylesheet "); diff --git a/crates/ndg-utils/src/markdown.rs b/crates/ndg-utils/src/markdown.rs index b71b7ebc9..6d0a6cb95 100644 --- a/crates/ndg-utils/src/markdown.rs +++ b/crates/ndg-utils/src/markdown.rs @@ -398,7 +398,7 @@ fn process_markdown_files_impl( } else { for custom in custom_outputs { output_map.insert( - custom.clone(), + custom, ( result.html.clone(), result.headers.clone(), diff --git a/ndg-commonmark/src/processor/extensions.rs b/ndg-commonmark/src/processor/extensions.rs index b4b0836c3..93ae0b9f5 100644 --- a/ndg-commonmark/src/processor/extensions.rs +++ b/ndg-commonmark/src/processor/extensions.rs @@ -1598,7 +1598,7 @@ pub fn process_manpage_references( ], ); link.append(NodeRef::new_text(span_text.clone())); - to_replace.push((span_el.clone(), link)); + to_replace.push((span_el, link)); } } } @@ -1701,9 +1701,9 @@ pub fn process_option_references( QualName::new(None, ns!(html), local_name!("code")), vec![], ); - code.append(NodeRef::new_text(code_text.clone())); + code.append(NodeRef::new_text(code_text)); a.append(code); - to_replace.push((code_el.clone(), a)); + to_replace.push((code_el, a)); } // If should_link is false, leave the code element as-is (no wrapping) } diff --git a/ndg-commonmark/tests/formatter_markup.rs b/ndg-commonmark/tests/formatter_markup.rs index dae89f1dd..d9302a655 100644 --- a/ndg-commonmark/tests/formatter_markup.rs +++ b/ndg-commonmark/tests/formatter_markup.rs @@ -104,7 +104,7 @@ fn test_empty_markdown() { let md = ""; let processor = markdown_processor(); let result = processor.render(md); - assert!(result.html.is_empty()); + assert_eq!(result.html, ""); } #[test] @@ -112,7 +112,7 @@ fn test_markdown_with_only_whitespace() { let md = " \n\t\n "; let processor = markdown_processor(); let result = processor.render(md); - assert!(result.html.trim().is_empty()); + assert_eq!(result.html.trim(), ""); } #[test] diff --git a/ndg-commonmark/tests/integration.rs b/ndg-commonmark/tests/integration.rs index 7f235ca14..d6ccf0087 100644 --- a/ndg-commonmark/tests/integration.rs +++ b/ndg-commonmark/tests/integration.rs @@ -104,7 +104,6 @@ Visit the [](#getting-started) guide. let result = processor.render(complex_markdown); // Verify basic structure - assert!(!result.html.is_empty()); assert!(result.html.contains("")); assert!(result.html.contains("")); @@ -240,7 +239,6 @@ Valid option: {option}`boot.loader.grub.enable` let result = processor.render(edge_case_markdown); // Should not crash and produce valid HTML - assert!(!result.html.is_empty()); assert!(result.html.contains("")); // Should handle unicode in anchors diff --git a/ndg-commonmark/tests/processor.rs b/ndg-commonmark/tests/processor.rs index 2ed975ddb..489feb4b4 100644 --- a/ndg-commonmark/tests/processor.rs +++ b/ndg-commonmark/tests/processor.rs @@ -248,8 +248,6 @@ Content with null bytes \x00 let result = processor.render(md); let html = result.html; - // Should not crash and should produce some HTML output - assert!(!html.is_empty()); assert!(html.contains("")); // Should handle headers gracefully diff --git a/ndg/src/main.rs b/ndg/src/main.rs index 73aadc944..63de297f5 100644 --- a/ndg/src/main.rs +++ b/ndg/src/main.rs @@ -228,66 +228,6 @@ fn project_root(config_files: &[PathBuf]) -> PathBuf { ) } -#[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used, reason = "Tests can unwrap")] - - use tempfile::tempdir; - - use super::*; - - fn processed(output_path: &str) -> utils::markdown::ProcessedMarkdown { - utils::markdown::ProcessedMarkdown { - html_content: String::new(), - headers: Vec::new(), - title: None, - source_path: PathBuf::from("source.md"), - output_path: output_path.to_string(), - is_included: false, - frontmatter: None, - } - } - - #[test] - fn nested_index_page_is_not_root_homepage() { - let processed_markdown = vec![processed("hooks/index.html")]; - - assert!(!has_root_homepage(&processed_markdown)); - } - - #[test] - fn root_index_page_is_homepage() { - let processed_markdown = vec![processed("index.html")]; - - assert!(has_root_homepage(&processed_markdown)); - } - - #[test] - fn init_creates_or_updates_gitignore_idempotently() { - let temp = tempdir().unwrap(); - let new_project = temp.path().join("new"); - fs::create_dir(&new_project).unwrap(); - update_gitignore(&new_project.join("ndg.toml")).unwrap(); - assert_eq!( - fs::read_to_string(new_project.join(".gitignore")).unwrap(), - "# NDG generated cache\n.ndg-cache/\n" - ); - - let existing_project = temp.path().join("existing"); - fs::create_dir(&existing_project).unwrap(); - let gitignore = existing_project.join(".gitignore"); - fs::write(&gitignore, "result\n").unwrap(); - let config = existing_project.join("custom.toml"); - update_gitignore(&config).unwrap(); - update_gitignore(&config).unwrap(); - assert_eq!( - fs::read_to_string(gitignore).unwrap(), - "result\n\n# NDG generated cache\n.ndg-cache/\n" - ); - assert_eq!(project_root(&[config]), existing_project); - } -} - #[derive(Clone, Copy, Debug)] enum OutputMode { All, @@ -653,3 +593,62 @@ fn generate_documentation(config: &mut Config, cache_dir: &Path) -> Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, reason = "Tests can unwrap")] + + use tempfile::tempdir; + + use super::*; + + fn processed(output_path: &str) -> utils::markdown::ProcessedMarkdown { + utils::markdown::ProcessedMarkdown { + html_content: String::new(), + headers: Vec::new(), + title: None, + source_path: PathBuf::from("source.md"), + output_path: output_path.to_string(), + is_included: false, + frontmatter: None, + } + } + + #[test] + fn nested_index_page_is_not_root_homepage() { + let processed_markdown = vec![processed("hooks/index.html")]; + + assert!(!has_root_homepage(&processed_markdown)); + } + + #[test] + fn root_index_page_is_homepage() { + let processed_markdown = vec![processed("index.html")]; + + assert!(has_root_homepage(&processed_markdown)); + } + + #[test] + fn init_creates_or_updates_gitignore_idempotently() { + let temp = tempdir().unwrap(); + let new_project = temp.path().join("new"); + fs::create_dir(&new_project).unwrap(); + update_gitignore(&new_project.join("ndg.toml")).unwrap(); + assert_eq!( + fs::read_to_string(new_project.join(".gitignore")).unwrap(), + "# NDG generated cache\n.ndg-cache/\n" + ); + + let existing_project = temp.path().join("existing"); + fs::create_dir(&existing_project).unwrap(); + let gitignore = existing_project.join(".gitignore"); + fs::write(&gitignore, "result\n").unwrap(); + let config = existing_project.join("custom.toml"); + update_gitignore(&config).unwrap(); + update_gitignore(&config).unwrap(); + assert_eq!( + fs::read_to_string(gitignore).unwrap(), + "result\n\n# NDG generated cache\n.ndg-cache/\n" + ); + assert_eq!(project_root(&[config]), existing_project); + } +} diff --git a/ndg/tests/integration.rs b/ndg/tests/integration.rs index 2d61f04ba..a7d7c78fa 100644 --- a/ndg/tests/integration.rs +++ b/ndg/tests/integration.rs @@ -5,7 +5,7 @@ use ndg::{ config::{Config, postprocess::PostprocessConfig}, utils::assets::copy_assets, }; -use ndg_commonmark::{MarkdownExtension, MarkdownOptions, MarkdownProcessor}; +use ndg_commonmark::MarkdownExtension; use tempfile::tempdir; #[test] @@ -153,28 +153,6 @@ fn test_large_file_processing() { assert!(content.len() > 10000); } -#[test] -fn test_malformed_markdown_recovery() { - let malformed_md = r"# Unclosed [link - -Some text. - -- List item 1 -- List item 2 - -```unclosed code block - -End. -"; - - let result = MarkdownProcessor::new(MarkdownOptions { - highlight_code: false, - ..Default::default() - }) - .render(malformed_md); - assert!(!result.html.is_empty()); -} - #[test] fn test_sidebar_integration() { use std::path::Path; From 409bc160e6c29dd7bd5926601d65d904002d8f2f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 4 Oct 2026 15:13:48 +0300 Subject: [PATCH 7/7] ci: get Rust toolchain from Nix devshell Signed-off-by: NotAShelf Change-Id: I59652349a1df628ce301803c62ceef246a6a6964 --- .github/workflows/rust.yml | 64 ++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index c2b89ca30..699b5cfe6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -13,7 +13,6 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 CARGO_NET_RETRY: 10 - RUSTUP_MAX_RETRIES: 10 jobs: changes: @@ -37,6 +36,9 @@ jobs: - 'crates/**' - 'ndg/**' - 'ndg-commonmark/**' + - 'flake.nix' + - 'flake.lock' + - 'nix/internal/shell.nix' build: name: Build with Cargo needs: changes @@ -53,7 +55,17 @@ jobs: - name: Checkout Repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Nix + if: runner.os == 'Linux' + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + + - name: Expose devshell toolchain to cache action + if: runner.os == 'Linux' + run: echo "$PATH" >> "$GITHUB_PATH" + shell: nix develop --command bash -e -o pipefail {0} + - name: Install Rust + if: runner.os == 'macOS' uses: dtolnay/rust-toolchain@be39649afda95dbf70f87cce95f68b8d5797b296 # stable with: targets: ${{ matrix.target }} @@ -61,7 +73,13 @@ jobs: - name: Cache dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - name: Build + - name: Build with devshell + if: runner.os == 'Linux' + run: cargo build --release --target ${{ matrix.target }} + shell: nix develop --command bash -e -o pipefail {0} + + - name: Build with Cargo + if: runner.os == 'macOS' run: cargo build --release --target ${{ matrix.target }} features: @@ -72,21 +90,22 @@ jobs: matrix: backend: [syntastica, syntect] runs-on: ubuntu-latest + defaults: + run: + shell: nix develop --command bash -e -o pipefail {0} steps: - name: Checkout Repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install Rust - uses: dtolnay/rust-toolchain@be39649afda95dbf70f87cce95f68b8d5797b296 # stable + - name: Install Nix + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + + - name: Expose devshell toolchain to cache action + run: echo "$PATH" >> "$GITHUB_PATH" - name: Cache dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - name: Install cargo-nextest - uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20 - with: - tool: cargo-nextest - - name: Check backend without Markdown extensions run: cargo check -p ndg-commonmark --no-default-features --features ${{ matrix.backend }} --lib @@ -98,14 +117,18 @@ jobs: needs: changes if: ${{ needs.changes.outputs.files_changed == 'true' }} runs-on: ubuntu-latest + defaults: + run: + shell: nix develop --command bash -e -o pipefail {0} steps: - name: Checkout Repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install Rust - uses: dtolnay/rust-toolchain@be39649afda95dbf70f87cce95f68b8d5797b296 # stable - with: - components: clippy, rustfmt + - name: Install Nix + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + + - name: Expose devshell toolchain to cache action + run: echo "$PATH" >> "$GITHUB_PATH" - name: Cache dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -124,6 +147,9 @@ jobs: name: Test runs-on: ubuntu-latest needs: build + defaults: + run: + shell: nix develop --command bash -e -o pipefail {0} permissions: contents: write outputs: @@ -136,17 +162,15 @@ jobs: with: fetch-depth: 2 - - name: Install Rust - uses: dtolnay/rust-toolchain@be39649afda95dbf70f87cce95f68b8d5797b296 # stable + - name: Install Nix + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + + - name: Expose devshell toolchain to cache action + run: echo "$PATH" >> "$GITHUB_PATH" - name: Cache Dependencies uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - name: Install cargo-nextest - uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20 - with: - tool: cargo-nextest - - name: Run nextest id: nextest run: |