From 1e1fc7975bb3953f48adc3518e45a6b2b8a175ca Mon Sep 17 00:00:00 2001 From: squidfunk Date: Tue, 1 Sep 2026 18:20:04 +0200 Subject: [PATCH] feature: add `awesome-nav` MkDocs plugin replacement Signed-off-by: squidfunk --- crates/zensical/src/compat/mkdocs/plugin.rs | 1 + .../src/compat/mkdocs/plugin/awesome_nav.rs | 244 ++++++ .../mkdocs/plugin/awesome_nav/config.rs | 324 +++++++ .../mkdocs/plugin/awesome_nav/pattern.rs | 110 +++ .../mkdocs/plugin/awesome_nav/resolver.rs | 817 ++++++++++++++++++ .../compat/mkdocs/plugin/awesome_nav/sort.rs | 234 +++++ crates/zensical/src/config/plugins.rs | 38 + crates/zensical/src/workflow.rs | 23 +- python/tests/integration/test_awesome_nav.py | 562 ++++++++++++ python/tests/unit/test_config.py | 60 ++ python/zensical/config.py | 48 + 11 files changed, 2455 insertions(+), 6 deletions(-) create mode 100644 crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/awesome_nav/config.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/awesome_nav/pattern.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs create mode 100644 crates/zensical/src/compat/mkdocs/plugin/awesome_nav/sort.rs create mode 100644 python/tests/integration/test_awesome_nav.py diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 4afc80c..e4095eb 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -35,6 +35,7 @@ use crate::structure::markdown::Markdown; use super::html::{self, Visitor}; pub mod autorefs; +pub mod awesome_nav; pub mod literate_nav; pub mod meta; pub mod minify; diff --git a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs new file mode 100644 index 0000000..8771d13 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav.rs @@ -0,0 +1,244 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! Native compatibility pipeline for filesystem-backed awesome navigation. + +use anyhow::{bail, Context, Result}; +use std::collections::BTreeMap; +use std::fs; +use std::sync::Arc; + +use zrx::id::Id; +use zrx::stream::function::Collection; +use zrx::stream::{Key, Signal, Stream, Value}; + +use crate::config::plugins::AwesomeNavLogs; +use crate::config::Config; +use crate::path::SourcePath; +use crate::structure::nav::Navigation; +use crate::structure::page::Page; +use crate::watcher::Source; + +mod config; +mod pattern; +mod resolver; +mod sort; + +/// Native awesome-nav pipeline. +#[derive(Clone, Debug)] +pub struct AwesomeNav { + settings: Arc, +} + +/// Inputs required to derive revision-complete navigation. +pub struct Dependencies<'a> { + /// Physical sources, including `.nav.yml` control files. + pub sources: &'a Stream, + /// Rendered documentation pages. + pub pages: &'a Stream, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Level { + Info, + Warning, + Error, +} + +#[derive(Clone, Debug)] +pub struct Diagnostic { + pub level: Level, + pub message: String, +} + +#[derive(Clone, Copy, Debug)] +pub struct Logs { + pub nav_override: Level, + pub root_title: Level, + pub root_hide: Level, + pub no_matches: Level, +} + +#[derive(Clone, Debug)] +pub struct Settings { + enabled: bool, + docs: String, + filename: String, + configured: Vec, + strict: bool, + logs: Logs, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Document { + path: SourcePath, + content: String, +} + +impl Value for Document {} + +#[derive(Clone, Debug, Default)] +struct Documents(Arc>); + +impl Value for Documents {} + +#[derive(Clone, Debug)] +struct Pages(Arc>); + +impl Value for Pages {} + +impl AwesomeNav { + /// Resolves immutable settings for one workflow lifetime. + pub fn new(config: &Config, strict: bool) -> Result { + let plugin = &config.project.plugins.awesome_nav.config; + if plugin.filename.is_empty() { + bail!("awesome-nav filename must not be empty") + } + Ok(Self { + settings: Arc::new(Settings { + enabled: plugin.enabled, + docs: config.project.docs_dir.clone(), + filename: plugin.filename.clone(), + configured: config.project.nav.clone(), + strict, + logs: Logs::new(&plugin.logs)?, + }), + }) + } + + /// Returns whether awesome-nav replaces other navigation producers. + pub fn is_enabled(&self) -> bool { + self.settings.enabled + } + + /// Installs control-file discovery, settlement and navigation compilation. + pub fn setup( + &self, dependencies: Dependencies<'_>, + ) -> Signal { + let settings = self.settings.clone(); + let documents = dependencies.sources.filter_map({ + let settings = settings.clone(); + move |id: &Id, source: &Source| { + if !settings.enabled || id.context() != settings.docs { + return Ok(None); + } + let path = id.location().parse::()?; + if !is_config_file(&path, &settings.filename) { + return Ok(None); + } + let content = + fs::read_to_string(&**source).with_context(|| { + format!("failed to read awesome-nav file {path}") + })?; + Ok::<_, anyhow::Error>(Some(Document { path, content })) + } + }); + let documents = documents.reduce( + |documents: &dyn Collection, Document>| { + Some(Documents(Arc::new( + documents + .values() + .map(|document| { + ( + document.path.to_string(), + document.content.clone(), + ) + }) + .collect(), + ))) + }, + ); + let pages = dependencies.pages.reduce( + |pages: &dyn Collection, Page>| { + Some(Pages(Arc::new(pages.values().cloned().collect()))) + }, + ); + let navigation = pages.product(&documents).map( + move |pages: &Pages, documents: &Documents| { + let (navigation, diagnostics) = resolver::resolve( + &settings, + &documents.0, + pages.0.as_ref(), + )?; + report(&diagnostics, settings.strict)?; + Ok::<_, anyhow::Error>(navigation) + }, + ); + navigation.reduce(|navigation: &dyn Collection, Navigation>| { + navigation.values().next().cloned() + }) + } +} + +impl Logs { + fn new(logs: &AwesomeNavLogs) -> Result { + Ok(Self { + nav_override: level(logs.nav_override.as_deref(), Level::Warning)?, + root_title: level(logs.root_title.as_deref(), Level::Warning)?, + root_hide: level(logs.root_hide.as_deref(), Level::Warning)?, + no_matches: level(logs.no_matches.as_deref(), Level::Warning)?, + }) + } +} + +fn level(value: Option<&str>, default: Level) -> Result { + match value { + None => Ok(default), + Some("info") => Ok(Level::Info), + Some("warning") => Ok(Level::Warning), + Some("error") => Ok(Level::Error), + Some(value) => bail!("invalid awesome-nav log level: {value}"), + } +} + +fn report(diagnostics: &[Diagnostic], strict: bool) -> Result<()> { + let mut failed = false; + for diagnostic in diagnostics { + let label = match diagnostic.level { + Level::Info => "INFO", + Level::Warning => "WARNING", + Level::Error => "ERROR", + }; + eprintln!("{label} - {}", diagnostic.message); + failed |= diagnostic.level == Level::Error + || (strict && diagnostic.level == Level::Warning); + } + if failed { + bail!("Aborted because awesome-nav reported errors") + } + Ok(()) +} + +fn is_config_file(path: &SourcePath, filename: &str) -> bool { + path.as_str() == filename + || path + .as_str() + .strip_suffix(filename) + .is_some_and(|prefix| prefix.ends_with('/')) +} + +#[cfg(test)] +mod tests { + use super::{is_config_file, level, Level}; + + #[test] + fn discovers_root_and_nested_configuration() { + assert!(is_config_file(&".nav.yml".parse().unwrap(), ".nav.yml")); + assert!(is_config_file( + &"guide/.nav.yml".parse().unwrap(), + ".nav.yml" + )); + assert!(!is_config_file( + &"guide/not.nav.yml".parse().unwrap(), + ".nav.yml" + )); + } + + #[test] + fn validates_log_levels() { + assert_eq!(level(Some("info"), Level::Warning).unwrap(), Level::Info); + assert!(level(Some("debug"), Level::Warning).is_err()); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/config.rs b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/config.rs new file mode 100644 index 0000000..7a179fd --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/config.rs @@ -0,0 +1,324 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! Strict `.nav.yml` parsing and configuration models. + +use anyhow::{bail, Context, Result}; +use saphyr::{LoadableYamlNode, YamlOwned}; + +/// One directory configuration before inheritance is applied. +#[derive(Clone, Debug, Default)] +pub struct Config { + pub title: Option, + pub hide: bool, + pub flatten_single_child_sections: Option, + pub preserve_directory_names: Option, + pub use_index_title: Option, + pub sort: Sort, + pub ignore: Option>, + pub nav: Option>, + pub append_unmatched: Option, +} + +/// One configured navigation entry. +#[derive(Clone, Debug)] +pub enum Item { + Target(String), + Named { title: String, value: Named }, + Pattern(PatternOptions), +} + +/// Value of a named navigation entry. +#[derive(Clone, Debug)] +pub enum Named { + Target(String), + Children(Vec), +} + +/// Options local to one pattern. +#[derive(Clone, Debug)] +pub struct PatternOptions { + pub glob: String, + pub flatten_single_child_sections: Option, + pub preserve_directory_names: Option, + pub sort: Sort, + pub ignore: Option>, + pub append_unmatched: Option, + pub ignore_no_matches: bool, +} + +/// Inheritable sorting options. +#[derive(Clone, Debug, Default)] +pub struct Sort { + pub by: Option, + pub direction: Option, + pub kind: Option, + pub sections: Option, + pub ignore_case: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SortBy { + Path, + Filename, + Title, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + Ascending, + Descending, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SortKind { + Natural, + Alphabetical, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Sections { + First, + Last, + Mixed, +} + +/// Parses one complete awesome-nav configuration document. +pub fn parse(path: &str, source: &str) -> Result { + let documents = + YamlOwned::load_from_str(source.trim_start_matches('\u{feff}')) + .with_context(|| format!("Parsing error [{path}]"))?; + if documents.len() != 1 { + bail!( + "awesome-nav configuration must contain one YAML document [{path}]" + ) + } + let root = &documents[0]; + if root.is_null() { + return Ok(Config::default()); + } + parse_config(root).with_context(|| format!("Validation error [{path}]")) +} + +fn parse_config(node: &YamlOwned) -> Result { + let mapping = mapping(node, "configuration root")?; + let mut config = Config::default(); + for (key, value) in mapping { + match string(key, "configuration key")? { + "title" => config.title = Some(non_empty(value, "title")?), + "hide" => config.hide = boolean(value, "hide")?, + "flatten_single_child_sections" => { + config.flatten_single_child_sections = + Some(boolean(value, "flatten_single_child_sections")?); + } + "preserve_directory_names" => { + config.preserve_directory_names = + Some(boolean(value, "preserve_directory_names")?); + } + "use_index_title" => { + config.use_index_title = + Some(boolean(value, "use_index_title")?); + } + "sort" => config.sort = parse_sort(value)?, + "ignore" => config.ignore = Some(parse_ignore(value)?), + "nav" => config.nav = Some(parse_items(value)?), + "append_unmatched" => { + config.append_unmatched = + Some(boolean(value, "append_unmatched")?); + } + key => bail!("unknown awesome-nav option: {key}"), + } + } + Ok(config) +} + +fn parse_items(node: &YamlOwned) -> Result> { + sequence(node, "nav")?.iter().map(parse_item).collect() +} + +fn parse_item(node: &YamlOwned) -> Result { + if let Some(value) = node.as_str() { + if value.is_empty() { + bail!("nav entries must not be empty") + } + return Ok(Item::Target(value.into())); + } + let mapping = mapping(node, "nav entry")?; + if mapping.keys().any(|key| key.as_str() == Some("glob")) { + return parse_pattern(mapping).map(Item::Pattern); + } + if mapping.len() != 1 { + bail!("named nav entries must contain exactly one item") + } + let (key, value) = mapping.iter().next().expect("length checked"); + let title = non_empty(key, "nav title")?; + let value = if let Some(target) = value.as_str() { + if target.is_empty() { + bail!("nav targets must not be empty") + } + Named::Target(target.into()) + } else { + Named::Children(parse_items(value)?) + }; + Ok(Item::Named { title, value }) +} + +fn parse_pattern(mapping: &saphyr::MappingOwned) -> Result { + let mut glob = None; + let mut options = PatternOptions { + glob: String::new(), + flatten_single_child_sections: None, + preserve_directory_names: None, + sort: Sort::default(), + ignore: None, + append_unmatched: None, + ignore_no_matches: false, + }; + for (key, value) in mapping { + match string(key, "pattern option")? { + "glob" => glob = Some(non_empty(value, "glob")?), + "flatten_single_child_sections" => { + options.flatten_single_child_sections = + Some(boolean(value, "flatten_single_child_sections")?); + } + "preserve_directory_names" => { + options.preserve_directory_names = + Some(boolean(value, "preserve_directory_names")?); + } + "sort" => options.sort = parse_sort(value)?, + "ignore" => options.ignore = Some(parse_ignore(value)?), + "append_unmatched" => { + options.append_unmatched = + Some(boolean(value, "append_unmatched")?); + } + "ignore_no_matches" => { + options.ignore_no_matches = + boolean(value, "ignore_no_matches")?; + } + key => bail!("unknown pattern option: {key}"), + } + } + options.glob = glob.context("pattern options require 'glob'")?; + Ok(options) +} + +fn parse_sort(node: &YamlOwned) -> Result { + let mut sort = Sort::default(); + for (key, value) in mapping(node, "sort")? { + match string(key, "sort option")? { + "by" => { + sort.by = Some(match string(value, "sort.by")? { + "path" => SortBy::Path, + "filename" => SortBy::Filename, + "title" => SortBy::Title, + value => bail!("invalid sort.by value: {value}"), + }); + } + "direction" => { + sort.direction = Some(match string(value, "sort.direction")? { + "asc" => Direction::Ascending, + "desc" => Direction::Descending, + value => bail!("invalid sort.direction value: {value}"), + }); + } + "type" => { + sort.kind = Some(match string(value, "sort.type")? { + "natural" => SortKind::Natural, + "alphabetical" => SortKind::Alphabetical, + value => bail!("invalid sort.type value: {value}"), + }); + } + "sections" => { + sort.sections = Some(match string(value, "sort.sections")? { + "first" => Sections::First, + "last" => Sections::Last, + "mixed" => Sections::Mixed, + value => bail!("invalid sort.sections value: {value}"), + }); + } + "ignore_case" => { + sort.ignore_case = Some(boolean(value, "sort.ignore_case")?); + } + key => bail!("unknown sort option: {key}"), + } + } + Ok(sort) +} + +fn parse_ignore(node: &YamlOwned) -> Result> { + if let Some(value) = node.as_str() { + if value.is_empty() { + bail!("ignore patterns must not be empty") + } + return Ok(vec![value.into()]); + } + sequence(node, "ignore")? + .iter() + .map(|value| non_empty(value, "ignore pattern")) + .collect() +} + +fn mapping<'a>( + node: &'a YamlOwned, name: &str, +) -> Result<&'a saphyr::MappingOwned> { + node.as_mapping() + .with_context(|| format!("{name} must be a mapping")) +} + +fn sequence<'a>(node: &'a YamlOwned, name: &str) -> Result<&'a [YamlOwned]> { + node.as_sequence() + .map(Vec::as_slice) + .with_context(|| format!("{name} must be a list")) +} + +fn string<'a>(node: &'a YamlOwned, name: &str) -> Result<&'a str> { + node.as_str() + .with_context(|| format!("{name} must be a string")) +} + +fn non_empty(node: &YamlOwned, name: &str) -> Result { + let value = string(node, name)?; + if value.is_empty() { + bail!("{name} must not be empty") + } + Ok(value.into()) +} + +fn boolean(node: &YamlOwned, name: &str) -> Result { + node.as_bool() + .with_context(|| format!("{name} must be a boolean")) +} + +#[cfg(test)] +mod tests { + use super::{parse, Item, Named, Sections, SortBy}; + + #[test] + fn parses_complete_configuration() { + let config = parse( + ".nav.yml", + "title: Guide\nhide: true\nsort:\n by: title\n sections: first\nignore: [$inherit, '*.hidden.md']\nnav:\n - Home: index.md\n - API:\n - api.md\n - glob: '*.md'\n ignore_no_matches: true\n", + ) + .unwrap(); + assert_eq!(config.title.as_deref(), Some("Guide")); + assert!(config.hide); + assert_eq!(config.sort.by, Some(SortBy::Title)); + assert_eq!(config.sort.sections, Some(Sections::First)); + assert_eq!(config.ignore.unwrap().len(), 2); + let nav = config.nav.unwrap(); + assert!(matches!( + &nav[0], + Item::Named { value: Named::Target(value), .. } if value == "index.md" + )); + assert!(matches!(&nav[2], Item::Pattern(_))); + } + + #[test] + fn rejects_unknown_and_invalid_options() { + assert!(parse(".nav.yml", "unknown: true\n").is_err()); + assert!(parse(".nav.yml", "hide: nope\n").is_err()); + assert!(parse(".nav.yml", "nav: [\"\"]\n").is_err()); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/pattern.rs b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/pattern.rs new file mode 100644 index 0000000..1548d62 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/pattern.rs @@ -0,0 +1,110 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! Replaceable awesome-nav pattern matching boundary. + +use anyhow::{bail, Result}; +use globset::{GlobBuilder, GlobMatcher}; + +/// One compiled POSIX navigation pattern. +#[derive(Clone, Debug)] +pub struct Pattern { + source: String, + directory_only: bool, + matcher: GlobMatcher, +} + +impl Pattern { + /// Compiles one pattern with path separators kept significant. + pub fn compile(source: &str) -> Result { + if let Some(operator) = extglob(source) { + bail!( + "unsupported awesome-nav extglob operator '{operator}(' in pattern {source:?}" + ) + } + let matcher = GlobBuilder::new(source) + .literal_separator(true) + .build()? + .compile_matcher(); + Ok(Self { + source: source.into(), + directory_only: source.ends_with('/'), + matcher, + }) + } + + /// Returns whether a canonical page or directory candidate matches. + pub fn matches(&self, candidate: &str) -> bool { + if self.directory_only && !candidate.ends_with('/') { + return false; + } + self.matcher.is_match(candidate) + || candidate + .strip_suffix('/') + .is_some_and(|candidate| self.matcher.is_match(candidate)) + } + + /// Returns the original expression for diagnostics. + pub fn source(&self) -> &str { + &self.source + } +} + +/// Finds the first unescaped extglob operator. +fn extglob(source: &str) -> Option { + let mut escaped = false; + let mut chars = source.chars().peekable(); + while let Some(character) = chars.next() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + continue; + } + if matches!(character, '@' | '?' | '*' | '+' | '!') + && chars.peek() == Some(&'(') + { + return Some(character); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::Pattern; + + #[test] + fn matches_component_sensitive_globs() { + let pattern = Pattern::compile("guide/**/*.md").unwrap(); + assert!(pattern.matches("guide/start.md")); + assert!(pattern.matches("guide/api/type.md")); + assert!(!pattern.matches("other/guide/start.md")); + + let pattern = Pattern::compile("{index,README}.md").unwrap(); + assert!(pattern.matches("index.md")); + assert!(pattern.matches("README.md")); + + let pattern = Pattern::compile("*").unwrap(); + assert!(pattern.matches("guide/")); + assert!(!pattern.matches("guide/start.md")); + + let pattern = Pattern::compile("*/").unwrap(); + assert!(pattern.matches("guide/")); + assert!(!pattern.matches("guide.md")); + } + + #[test] + fn rejects_every_extglob_operator() { + for operator in ['@', '?', '*', '+', '!'] { + let error = Pattern::compile(&format!("{operator}(a|b).md")) + .unwrap_err() + .to_string(); + assert!(error.contains("unsupported awesome-nav extglob")); + } + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs new file mode 100644 index 0000000..1c1416e --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/resolver.rs @@ -0,0 +1,817 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! Filesystem-derived awesome-nav resolution. + +use anyhow::{bail, Context, Result}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use crate::structure::dynamic::Dynamic; +use crate::structure::nav::{ + source_sort_key, to_title, Navigation, Plan, PlanItem, +}; +use crate::structure::page::Page; + +use super::config::{ + self, Config, Direction, Item, Named, PatternOptions, Sections, Sort, + SortBy, SortKind, +}; +use super::pattern::Pattern; +use super::sort::{self, Settings as SortSettings}; +use super::{Diagnostic, Level, Settings}; + +/// Fully inherited directory options. +#[derive(Clone, Debug)] +struct Effective { + layout: Layout, + sort: SortSettings, + ignore: Vec, + append_unmatched: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Layout { + flatten: bool, + preserve_names: bool, + use_index_title: bool, +} + +/// One filesystem page needed during navigation resolution. +#[derive(Clone, Debug)] +struct PageInfo { + path: String, + title: String, + metadata_title: Option, +} + +/// Revision-complete documentation catalog. +struct Catalog { + pages: BTreeMap, + page_order: Vec, + directories: BTreeSet, +} + +/// Navigation entry with deferred resolution state. +enum Entry { + Page(Resolved), + Directory { + path: String, + title: Option, + config: Effective, + resolved: Option>, + }, + Pattern { + options: PatternOptions, + config: Effective, + origin: String, + resolved: Option>, + }, + Link(Resolved), + Section { + title: String, + children: Vec, + }, +} + +/// One resolved item retaining sort facts until final lowering. +#[derive(Clone, Debug)] +struct Resolved { + path: String, + title: String, + sort_title: String, + kind: ResolvedKind, +} + +#[derive(Clone, Debug)] +enum ResolvedKind { + Page { + target: String, + explicit_title: Option, + }, + Link { + target: String, + }, + Section(Vec), +} + +struct Resolver<'a> { + settings: &'a Settings, + documents: &'a BTreeMap, + catalog: Catalog, + pages: &'a [Page], + seen: HashSet, + resolving: HashSet, + parsed: HashMap, + diagnostics: Vec, +} + +impl Default for Effective { + fn default() -> Self { + Self { + layout: Layout::default(), + sort: SortSettings { + by: SortBy::Path, + direction: Direction::Ascending, + kind: SortKind::Natural, + sections: Sections::Last, + ignore_case: false, + }, + ignore: Vec::new(), + append_unmatched: false, + } + } +} + +impl Catalog { + fn new(pages: &[Page]) -> Self { + let mut ordered = pages.iter().collect::>(); + ordered.sort_by_key(|page| source_sort_key(page.source())); + let sources = ordered + .iter() + .map(|page| page.source().to_string()) + .collect::>(); + let mut result = Self { + pages: BTreeMap::new(), + page_order: Vec::new(), + directories: BTreeSet::from([String::from(".")]), + }; + for page in ordered { + let path = page.source().to_string(); + if page.source().file_name() == "README.md" + && sources.contains(&join(&parent(&path), "index.md")) + { + continue; + } + let metadata_title = match page.meta.get("title") { + Some(Dynamic::String(title)) => Some(title.clone()), + _ => None, + }; + result.page_order.push(path.clone()); + result.pages.insert( + path.clone(), + PageInfo { + path: path.clone(), + title: page.title.clone(), + metadata_title, + }, + ); + let mut directory = parent(&path); + loop { + result.directories.insert(directory.clone()); + if directory == "." { + break; + } + directory = parent(&directory); + } + } + result + } + + fn page(&self, path: &str) -> Option<&PageInfo> { + self.pages.get(path) + } + + fn is_directory(&self, path: &str) -> bool { + self.directories.contains(path) + } + + fn candidates(&self) -> Vec<(String, bool)> { + let mut candidates = self + .page_order + .iter() + .cloned() + .map(|path| (path, false)) + .collect::>(); + let mut directories = self + .directories + .iter() + .filter(|path| path.as_str() != ".") + .cloned() + .collect::>(); + directories.sort(); + candidates.extend(directories.into_iter().map(|path| (path, true))); + candidates + } +} + +impl<'a> Resolver<'a> { + fn new( + settings: &'a Settings, documents: &'a BTreeMap, + pages: &'a [Page], + ) -> Self { + Self { + settings, + documents, + catalog: Catalog::new(pages), + pages, + seen: HashSet::new(), + resolving: HashSet::new(), + parsed: HashMap::new(), + diagnostics: Vec::new(), + } + } + + fn resolve(mut self) -> Result<(Navigation, Vec)> { + let config = self.directory_config(".")?.clone(); + if !self.settings.configured.is_empty() { + self.diagnostic( + self.settings.logs.nav_override, + "'nav' config from mkdocs.yml is being replaced with one generated by awesome-nav", + None, + ); + } + let config_path = self.config_path("."); + if config.title.is_some() { + self.diagnostic( + self.settings.logs.root_title, + "'title' option has no effect at the top level", + self.documents + .contains_key(&config_path) + .then_some(config_path.as_str()), + ); + } + if config.hide { + self.diagnostic( + self.settings.logs.root_hide, + "'hide' option has no effect at the top level", + self.documents + .contains_key(&config_path) + .then_some(config_path.as_str()), + ); + } + let effective = Self::inherit(".", &Effective::default(), &config); + let items = Self::items(&config, &effective); + let mut entries = self.parse_entries(".", items, &effective)?; + self.resolve_entries(&mut entries)?; + let resolved = flatten(entries); + let plan = + Plan::new(resolved.into_iter().map(Resolved::plan).collect()); + Ok((plan.compile(self.pages.to_vec()), self.diagnostics)) + } + + fn resolve_directory( + &mut self, path: &str, parent_config: &Effective, + title: Option, from_pattern: bool, + ) -> Result> { + if !self.resolving.insert(path.into()) { + bail!("recursive awesome-nav directory reference: {path}") + } + self.seen.insert(path.into()); + let local = self.directory_config(path)?.clone(); + if from_pattern && local.hide { + self.resolving.remove(path); + return Ok(Vec::new()); + } + let effective = Self::inherit(path, parent_config, &local); + let items = Self::items(&local, &effective); + let mut entries = self.parse_entries(path, items, &effective)?; + self.resolve_entries(&mut entries)?; + let children = flatten(entries); + self.resolving.remove(path); + if children.is_empty() { + return Ok(Vec::new()); + } + if effective.layout.flatten + && children.len() == 1 + && !matches!(children[0].kind, ResolvedKind::Link { .. }) + { + return Ok(children); + } + let title = title + .or(local.title) + .or_else(|| { + (!effective.layout.preserve_names + && effective.layout.use_index_title) + .then(|| self.index_title(path)) + .flatten() + }) + .unwrap_or_else(|| { + let name = file_name(path); + if effective.layout.preserve_names { + name.into() + } else { + to_title(name) + } + }); + Ok(vec![Resolved { + path: path.into(), + sort_title: title.clone(), + title, + kind: ResolvedKind::Section(children), + }]) + } + + fn resolve_entries(&mut self, entries: &mut [Entry]) -> Result<()> { + self.reserve_pages(entries); + loop { + let depth = deepest_directory(entries); + let Some(depth) = depth else { break }; + self.resolve_directories(entries, depth)?; + } + self.resolve_patterns(entries) + } + + fn reserve_pages(&mut self, entries: &mut [Entry]) { + for entry in entries { + match entry { + Entry::Page(item) => { + self.seen.insert(item.path.clone()); + } + Entry::Section { children, .. } => self.reserve_pages(children), + _ => {} + } + } + } + + fn resolve_directories( + &mut self, entries: &mut [Entry], depth: usize, + ) -> Result<()> { + for entry in entries { + match entry { + Entry::Directory { path, title, config, resolved } + if resolved.is_none() && components(path) == depth => + { + *resolved = Some(self.resolve_directory( + path, + config, + title.clone(), + false, + )?); + } + Entry::Section { children, .. } => { + self.resolve_directories(children, depth)?; + } + _ => {} + } + } + Ok(()) + } + + fn resolve_patterns(&mut self, entries: &mut [Entry]) -> Result<()> { + for entry in entries { + match entry { + Entry::Pattern { + options, + config, + origin, + resolved, + } => { + *resolved = + Some(self.resolve_pattern(options, config, origin)?); + } + Entry::Section { children, .. } => { + self.resolve_patterns(children)?; + } + _ => {} + } + } + Ok(()) + } + + fn resolve_pattern( + &mut self, options: &PatternOptions, effective: &Effective, + origin: &str, + ) -> Result> { + let pattern = Pattern::compile(&options.glob)?; + let ignores = effective + .ignore + .iter() + .map(|value| Pattern::compile(value)) + .collect::>>()?; + let mut candidates = self + .catalog + .candidates() + .into_iter() + .filter(|(path, directory)| { + if self.seen.contains(path) { + return false; + } + let candidate = if *directory { + format!("{path}/") + } else { + path.clone() + }; + pattern.matches(&candidate) + && !ignores.iter().any(|ignore| ignore.matches(&candidate)) + }) + .collect::>(); + candidates.sort_by(|(left, left_dir), (right, right_dir)| { + left_dir.cmp(right_dir).then_with(|| { + if *left_dir { + components(right).cmp(&components(left)) + } else { + std::cmp::Ordering::Equal + } + }) + }); + let mut matches = Vec::new(); + let mut had_match = false; + for (path, directory) in candidates { + self.seen.insert(path.clone()); + if directory { + if self.directory_config(&path)?.hide { + continue; + } + had_match = true; + matches.extend( + self.resolve_directory(&path, effective, None, true)?, + ); + } else if let Some(page) = self.catalog.page(&path) { + had_match = true; + matches.push(Resolved::page(page, None)); + } + } + if !had_match && !options.ignore_no_matches { + self.diagnostic( + self.settings.logs.no_matches, + &format!( + "The nav item '{}' doesn't match any files or directories", + pattern.source() + ), + Some(origin), + ); + } + sort::apply(&mut matches, effective.sort); + Ok(matches) + } + + fn parse_entries( + &mut self, root: &str, items: Vec, effective: &Effective, + ) -> Result> { + items + .into_iter() + .map(|item| self.parse_entry(root, item, effective)) + .collect() + } + + fn parse_entry( + &mut self, root: &str, item: Item, effective: &Effective, + ) -> Result { + match item { + Item::Target(target) => { + Ok(self.target(root, target, None, effective)) + } + Item::Named { + title, + value: Named::Target(target), + } => { + let path = join(root, &target); + if let Some(page) = self.catalog.page(&path) { + Ok(Entry::Page(Resolved::page(page, Some(title)))) + } else if self.catalog.is_directory(&path) { + Ok(Entry::Directory { + path, + title: Some(title), + config: effective.clone(), + resolved: None, + }) + } else { + Ok(Entry::Link(Resolved { + path: target.clone(), + sort_title: title.clone(), + title, + kind: ResolvedKind::Link { target }, + })) + } + } + Item::Named { + title, + value: Named::Children(children), + } => Ok(Entry::Section { + title, + children: self.parse_entries(root, children, effective)?, + }), + Item::Pattern(options) => { + let config = Self::pattern_config(root, effective, &options); + let options = PatternOptions { + glob: absolute_pattern(root, &options.glob), + ..options + }; + Ok(Entry::Pattern { + options, + config, + origin: self.config_path(root), + resolved: None, + }) + } + } + } + + fn target( + &mut self, root: &str, target: String, title: Option, + effective: &Effective, + ) -> Entry { + let path = join(root, target.trim_end_matches('/')); + if let Some(page) = self.catalog.page(&path) { + return Entry::Page(Resolved::page(page, title)); + } + if self.catalog.is_directory(&path) { + return Entry::Directory { + path, + title, + config: effective.clone(), + resolved: None, + }; + } + let options = PatternOptions { + glob: absolute_pattern(root, &target), + flatten_single_child_sections: None, + preserve_directory_names: None, + sort: Sort::default(), + ignore: None, + append_unmatched: None, + ignore_no_matches: false, + }; + Entry::Pattern { + options, + config: effective.clone(), + origin: self.config_path(root), + resolved: None, + } + } + + fn pattern_config( + root: &str, parent: &Effective, options: &PatternOptions, + ) -> Effective { + let mut effective = parent.clone(); + effective.layout.flatten = options + .flatten_single_child_sections + .unwrap_or(effective.layout.flatten); + effective.layout.preserve_names = options + .preserve_directory_names + .unwrap_or(effective.layout.preserve_names); + effective.sort = merge_sort(effective.sort, &options.sort); + effective.append_unmatched = options + .append_unmatched + .unwrap_or(effective.append_unmatched); + if let Some(ignore) = &options.ignore { + effective.ignore = resolve_ignores(root, &parent.ignore, ignore); + } + effective + } + + fn inherit(root: &str, parent: &Effective, config: &Config) -> Effective { + let mut effective = parent.clone(); + effective.layout.flatten = config + .flatten_single_child_sections + .unwrap_or(effective.layout.flatten); + effective.layout.preserve_names = config + .preserve_directory_names + .unwrap_or(effective.layout.preserve_names); + effective.layout.use_index_title = config + .use_index_title + .unwrap_or(effective.layout.use_index_title); + effective.sort = merge_sort(effective.sort, &config.sort); + effective.append_unmatched = config + .append_unmatched + .unwrap_or(effective.append_unmatched); + if let Some(ignore) = &config.ignore { + effective.ignore = resolve_ignores(root, &parent.ignore, ignore); + } + effective + } + + fn items(config: &Config, effective: &Effective) -> Vec { + let mut items = config.nav.clone().unwrap_or_else(|| { + vec![ + Item::Pattern(default_pattern("index.md")), + Item::Pattern(default_pattern("README.md")), + Item::Pattern(default_pattern("*")), + ] + }); + if effective.append_unmatched { + items.push(Item::Pattern(default_pattern("*"))); + } + items + } + + fn directory_config(&mut self, root: &str) -> Result<&Config> { + let path = self.config_path(root); + if !self.parsed.contains_key(&path) { + let parsed = match self.documents.get(&path) { + Some(source) => config::parse(&path, source)?, + None => Config::default(), + }; + self.parsed.insert(path.clone(), parsed); + } + Ok(self.parsed.get(&path).expect("inserted above")) + } + + fn config_path(&self, root: &str) -> String { + join(root, &self.settings.filename) + } + + fn index_title(&self, root: &str) -> Option { + self.catalog + .page(&join(root, "index.md")) + .and_then(|page| page.metadata_title.clone()) + } + + fn diagnostic(&mut self, level: Level, message: &str, path: Option<&str>) { + self.diagnostics.push(Diagnostic { + level, + message: path.map_or_else( + || format!("awesome-nav: {message}"), + |path| format!("awesome-nav: {message} [{path}]"), + ), + }); + } +} + +impl Resolved { + fn page(page: &PageInfo, explicit_title: Option) -> Self { + Self { + path: page.path.clone(), + sort_title: page + .metadata_title + .clone() + .unwrap_or_else(|| file_name(&page.path).into()), + title: explicit_title.clone().unwrap_or_else(|| page.title.clone()), + kind: ResolvedKind::Page { + target: page.path.clone(), + explicit_title, + }, + } + } + + fn plan(self) -> PlanItem { + match self.kind { + ResolvedKind::Page { target, explicit_title } => { + PlanItem::reference(explicit_title, target) + } + ResolvedKind::Link { target } => { + PlanItem::reference(Some(self.title), target) + } + ResolvedKind::Section(children) => PlanItem::section( + self.title, + children.into_iter().map(Self::plan).collect(), + ), + } + } +} + +impl sort::Item for Resolved { + fn path(&self) -> &str { + &self.path + } + + fn sort_title(&self) -> &str { + &self.sort_title + } + + fn is_section(&self) -> bool { + matches!(self.kind, ResolvedKind::Section(_)) + } +} + +fn default_pattern(glob: &str) -> PatternOptions { + PatternOptions { + glob: glob.into(), + flatten_single_child_sections: None, + preserve_directory_names: None, + sort: Sort::default(), + ignore: None, + append_unmatched: None, + ignore_no_matches: true, + } +} + +fn merge_sort(parent: SortSettings, local: &Sort) -> SortSettings { + SortSettings { + by: local.by.unwrap_or(parent.by), + direction: local.direction.unwrap_or(parent.direction), + kind: local.kind.unwrap_or(parent.kind), + sections: local.sections.unwrap_or(parent.sections), + ignore_case: local.ignore_case.unwrap_or(parent.ignore_case), + } +} + +fn resolve_ignores( + root: &str, parent: &[String], values: &[String], +) -> Vec { + let mut result = Vec::new(); + for value in values { + if value == "$inherit" { + result.extend_from_slice(parent); + } else if let Some(value) = value.strip_prefix('/') { + result.push(absolute_pattern(root, value)); + } else { + result.push(absolute_pattern(root, &format!("**/{value}"))); + } + } + result +} + +fn flatten(entries: Vec) -> Vec { + let mut result = Vec::new(); + for entry in entries { + match entry { + Entry::Page(item) | Entry::Link(item) => result.push(item), + Entry::Directory { resolved, .. } + | Entry::Pattern { resolved, .. } => { + result.extend(resolved.unwrap_or_default()); + } + Entry::Section { title, children } => { + let children = flatten(children); + if !children.is_empty() { + result.push(Resolved { + path: String::new(), + sort_title: title.clone(), + title, + kind: ResolvedKind::Section(children), + }); + } + } + } + } + result +} + +fn deepest_directory(entries: &[Entry]) -> Option { + entries + .iter() + .filter_map(|entry| match entry { + Entry::Directory { path, resolved: None, .. } => { + Some(components(path)) + } + Entry::Section { children, .. } => deepest_directory(children), + _ => None, + }) + .max() +} + +fn absolute_pattern(root: &str, pattern: &str) -> String { + let trailing = pattern.ends_with('/'); + let value = join(root, pattern.trim_end_matches('/')); + if trailing { + format!("{value}/") + } else { + value + } +} + +fn join(root: &str, target: &str) -> String { + let mut parts = Vec::new(); + let source = if target.starts_with('/') || root == "." { + target.trim_start_matches('/').into() + } else { + format!("{root}/{target}") + }; + for part in source.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + part => parts.push(part), + } + } + if parts.is_empty() { + ".".into() + } else { + parts.join("/") + } +} + +fn parent(path: &str) -> String { + path.rsplit_once('/') + .map_or_else(|| ".".into(), |(parent, _)| parent.into()) +} + +fn file_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +fn components(path: &str) -> usize { + path.split('/') + .filter(|part| !part.is_empty() && *part != ".") + .count() +} + +/// Resolves native awesome-nav configuration and returns diagnostics. +pub fn resolve( + settings: &Settings, documents: &BTreeMap, pages: &[Page], +) -> Result<(Navigation, Vec)> { + Resolver::new(settings, documents, pages) + .resolve() + .context("failed to resolve awesome navigation") +} + +#[cfg(test)] +mod tests { + use super::resolve_ignores; + + #[test] + fn resolves_relative_absolute_and_inherited_ignores() { + assert_eq!( + resolve_ignores( + "guide", + &["**/draft.md".into()], + &["$inherit".into(), "*.hidden.md".into(), "/local.md".into()], + ), + ["**/draft.md", "guide/**/*.hidden.md", "guide/local.md"] + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/sort.rs b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/sort.rs new file mode 100644 index 0000000..2fdc5fa --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/awesome_nav/sort.rs @@ -0,0 +1,234 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! Awesome-nav sorting compatible with upstream's natsort settings. + +use std::cmp::Ordering; + +use super::config::{Direction, Sections, SortBy, SortKind}; + +/// Effective inherited sorting settings. +#[derive(Clone, Copy, Debug)] +pub struct Settings { + pub by: SortBy, + pub direction: Direction, + pub kind: SortKind, + pub sections: Sections, + pub ignore_case: bool, +} + +/// Sort facts exposed by one resolved navigation item. +pub trait Item { + fn path(&self) -> &str; + fn sort_title(&self) -> &str; + fn is_section(&self) -> bool; +} + +/// Sorts items while retaining stable section grouping. +pub fn apply(items: &mut [T], settings: Settings) { + items.sort_by(|left, right| { + let ordering = compare(left, right, settings); + if settings.direction == Direction::Descending { + ordering.reverse() + } else { + ordering + } + }); + if settings.sections != Sections::Mixed { + items.sort_by_key(|item| { + match (settings.sections, item.is_section()) { + (Sections::First, true) | (Sections::Last, false) => 0, + _ => 1, + } + }); + } +} + +fn compare(left: &T, right: &T, settings: Settings) -> Ordering { + let keys = |item: &T| match settings.by { + SortBy::Path => vec![item.path().to_owned()], + SortBy::Filename => { + vec![file_name(item.path()).into(), item.path().to_owned()] + } + SortBy::Title => vec![ + item.sort_title().to_owned(), + file_name(item.path()).into(), + item.path().to_owned(), + ], + }; + keys(left) + .into_iter() + .zip(keys(right)) + .map(|(left, right)| match settings.kind { + SortKind::Natural if settings.by == SortBy::Title => { + natural(&left, &right, settings.ignore_case) + } + SortKind::Natural => { + natural_path(&left, &right, settings.ignore_case) + } + SortKind::Alphabetical => { + alphabetical(&left, &right, settings.ignore_case) + } + }) + .find(|ordering| *ordering != Ordering::Equal) + .unwrap_or(Ordering::Equal) +} + +fn alphabetical(left: &str, right: &str, ignore_case: bool) -> Ordering { + if ignore_case { + left.to_lowercase().cmp(&right.to_lowercase()) + } else { + left.cmp(right) + } +} + +fn natural(left: &str, right: &str, ignore_case: bool) -> Ordering { + let mut left = chunks(left); + let mut right = chunks(right); + loop { + match (left.next(), right.next()) { + (Some(Chunk::Number(left)), Some(Chunk::Number(right))) => { + let left = left.trim_start_matches('0'); + let right = right.trim_start_matches('0'); + let ordering = + left.len().cmp(&right.len()).then_with(|| left.cmp(right)); + if ordering != Ordering::Equal { + return ordering; + } + } + (Some(Chunk::Text(left)), Some(Chunk::Text(right))) => { + let ordering = group_letters(left, ignore_case) + .cmp(&group_letters(right, ignore_case)); + if ordering != Ordering::Equal { + return ordering; + } + } + (Some(Chunk::Number(_)), Some(Chunk::Text(_))) => { + return Ordering::Less; + } + (Some(Chunk::Text(_)), Some(Chunk::Number(_))) => { + return Ordering::Greater; + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => return Ordering::Equal, + } + } +} + +fn natural_path(left: &str, right: &str, ignore_case: bool) -> Ordering { + let left = path_parts(left); + let right = path_parts(right); + for (left, right) in left.iter().zip(&right) { + let ordering = natural(left, right, ignore_case); + if ordering != Ordering::Equal { + return ordering; + } + } + left.len().cmp(&right.len()) +} + +fn path_parts(path: &str) -> Vec<&str> { + let mut result = Vec::new(); + for component in path.split('/') { + if let Some(index) = component.rfind('.') + && index > 0 + { + result.push(&component[..index]); + result.push(&component[index..]); + } else { + result.push(component); + } + } + result +} + +fn group_letters(value: &str, ignore_case: bool) -> String { + let mut output = String::with_capacity(value.len() * 2); + for character in value.chars() { + let value = if ignore_case { + character.to_lowercase().collect::() + } else { + character.to_string() + }; + output.extend(value.chars().flat_map(char::to_lowercase)); + output.push_str(&value); + } + output +} + +enum Chunk<'a> { + Number(&'a str), + Text(&'a str), +} + +struct Chunks<'a> { + source: &'a str, + cursor: usize, +} + +impl<'a> Iterator for Chunks<'a> { + type Item = Chunk<'a>; + + fn next(&mut self) -> Option { + if self.cursor == self.source.len() { + return None; + } + let numeric = self.source[self.cursor..] + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit()); + let start = self.cursor; + for (offset, character) in self.source[start..].char_indices() { + if character.is_ascii_digit() != numeric { + self.cursor = start + offset; + let value = &self.source[start..self.cursor]; + return Some(if numeric { + Chunk::Number(value) + } else { + Chunk::Text(value) + }); + } + } + self.cursor = self.source.len(); + let value = &self.source[start..]; + Some(if numeric { + Chunk::Number(value) + } else { + Chunk::Text(value) + }) + } +} + +fn chunks(source: &str) -> Chunks<'_> { + Chunks { source, cursor: 0 } +} + +fn file_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +#[cfg(test)] +mod tests { + use super::{alphabetical, natural, natural_path}; + use std::cmp::Ordering; + + #[test] + fn natural_sort_orders_numeric_runs_and_grouped_case() { + assert_eq!( + natural_path("page2.md", "page10.md", false), + Ordering::Less + ); + assert_eq!(natural("8", "9", false), Ordering::Less); + assert_eq!(natural("09", "9", false), Ordering::Equal); + assert_eq!(natural_path("2.md", "2-suffix.md", false), Ordering::Less); + assert_eq!(natural("A", "a", false), Ordering::Less); + assert_eq!(natural("a", "B", false), Ordering::Less); + assert_eq!( + alphabetical("page2.md", "page10.md", false), + Ordering::Greater + ); + } +} diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index c1363df..df33b8b 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -64,12 +64,50 @@ pub struct Plugins { pub tags: TagsPlugin, /// Literate navigation plugin. pub literate_nav: LiterateNavPlugin, + /// Awesome navigation plugin. + pub awesome_nav: AwesomeNavPlugin, /// Offline plugin. pub offline: OfflinePlugin, } // ---------------------------------------------------------------------------- +/// Awesome navigation plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct AwesomeNavPlugin { + /// Plugin configuration. + pub config: AwesomeNavPluginConfig, +} + +/// Awesome navigation plugin configuration. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct AwesomeNavPluginConfig { + /// Whether awesome navigation is enabled. + pub enabled: bool, + /// Folder-relative configuration file name. + pub filename: String, + /// Configurable diagnostic levels. + pub logs: AwesomeNavLogs, +} + +/// Awesome navigation diagnostic levels. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct AwesomeNavLogs { + /// Configured navigation replacement diagnostic. + pub nav_override: Option, + /// Root title diagnostic. + pub root_title: Option, + /// Root hiding diagnostic. + pub root_hide: Option, + /// Unmatched pattern diagnostic. + pub no_matches: Option, +} + +// ---------------------------------------------------------------------------- + /// Literate navigation plugin. #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 812603c..3fb49c0 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -44,8 +44,8 @@ use zrx::stream::{ use crate::compat::mkdocs::plugin::autorefs::UnresolvedAutorefs; use crate::compat::mkdocs::{ plugin::{ - self, autorefs, literate_nav, meta, minify, mkdocstrings, redirects, - search, tags, + self, autorefs, awesome_nav, literate_nav, meta, minify, mkdocstrings, + redirects, search, tags, }, resource, }; @@ -276,12 +276,23 @@ impl Main { ) }) }); - let nav = literate_nav::LiterateNav::new(&self.config).setup( - literate_nav::Dependencies { + let awesome_nav = + awesome_nav::AwesomeNav::new(&self.config, self.strict).expect( + "awesome-nav configuration is validated during loading", + ); + let nav = if awesome_nav.is_enabled() { + awesome_nav.setup(awesome_nav::Dependencies { sources: &sources, pages: &page, - }, - ); + }) + } else { + literate_nav::LiterateNav::new(&self.config).setup( + literate_nav::Dependencies { + sources: &sources, + pages: &page, + }, + ) + }; let autorefs_input = rendered_page.map(|rendered: &RenderedPage| autorefs::PageInput { source: rendered.page.source().clone(), diff --git a/python/tests/integration/test_awesome_nav.py b/python/tests/integration/test_awesome_nav.py new file mode 100644 index 0000000..501b42c --- /dev/null +++ b/python/tests/integration/test_awesome_nav.py @@ -0,0 +1,562 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +"""Integration tests for native mkdocs-awesome-nav compatibility.""" + +from __future__ import annotations + +import subprocess +import sys +import time +from typing import TYPE_CHECKING, Any + +import pytest +from bs4 import BeautifulSoup + +import zensical + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} + + +def _write_template(root: Path) -> None: + """Write a compact recursive navigation oracle.""" + overrides = root / "overrides" + overrides.mkdir() + (overrides / "main.html").write_text( + """\ +{% macro render(items, depth) %} +{% for item in items %} + +{{ render(item.children, depth + 1) }} +{% endfor %} +{% endmacro %} +{{ render(nav.items, 0) }} +""", + encoding="utf-8", + ) + + +def _items(root: Path) -> list[tuple[int, str, str]]: + output_path = root / "site" / "index.html" + if not output_path.exists(): + output_path = next((root / "site").rglob("*.html")) + output = output_path.read_text() + soup = BeautifulSoup(output, "html.parser") + return [ + (int(str(item["depth"])), str(item["title"]), str(item["url"])) + for item in soup.find_all("item") + ] + + +def _items_or_none(root: Path) -> list[tuple[int, str, str]] | None: + try: + return _items(root) + except (FileNotFoundError, StopIteration): + return None + + +def _write_config(root: Path, plugin: str = "awesome-nav") -> Path: + config = root / "mkdocs.yml" + config.write_text( + f"""\ +site_name: Awesome navigation +theme: + name: material + custom_dir: overrides +plugins: + - {plugin} +""", + encoding="utf-8", + ) + return config + + +def test_resolves_nested_configuration_patterns_options_and_links( + tmp_path: Path, +) -> None: + """The native pipeline resolves a representative awesome-nav project.""" + docs = tmp_path / "docs" + api = docs / "guide" / "api" + api.mkdir(parents=True) + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "z10.md").write_text("# Ten\n", encoding="utf-8") + (docs / "z2.md").write_text("# Two\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + """\ +nav: + - index.md + - Guide: guide + - Resources: + - z*.md + - Website: https://example.com +""", + encoding="utf-8", + ) + guide = docs / "guide" + (guide / "index.md").write_text( + "---\ntitle: Guide landing\n---\n# Overview\n", encoding="utf-8" + ) + (guide / "start.md").write_text("# Start\n", encoding="utf-8") + (guide / "draft.hidden.md").write_text( + "# Hidden\n", encoding="utf-8" + ) + (guide / ".nav.yml").write_text( + """\ +use_index_title: true +ignore: "*.hidden.md" +sort: + by: filename + type: natural +nav: + - index.md + - glob: "*.md" + - api +""", + encoding="utf-8", + ) + (api / "one.md").write_text("# One\n", encoding="utf-8") + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Home", ""), + (0, "Guide", ""), + (1, "Guide landing", "guide/"), + (1, "Start", "guide/start/"), + (1, "Api", ""), + (2, "One", "guide/api/one/"), + (0, "Resources", ""), + (1, "Two", "z2/"), + (1, "Ten", "z10/"), + (0, "Website", "https://example.com"), + ] + + +def test_explicit_pages_are_claimed_before_earlier_patterns( + tmp_path: Path, +) -> None: + """Resolution priority is independent of declaration position.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + for name in ("index.md", "other.md", "last.md"): + (docs / name).write_text(f"# {name}\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + 'nav:\n - "*"\n - Last: last.md\n', encoding="utf-8" + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "index.md", ""), + (0, "other.md", "other/"), + (0, "Last", "last/"), + ] + + +def test_default_navigation_discovers_nested_directories_without_config( + tmp_path: Path, +) -> None: + """The default index-first navigation also works without `.nav.yml`.""" + docs = tmp_path / "docs" + guide = docs / "guide" + guide.mkdir(parents=True) + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "other.md").write_text("# Other\n", encoding="utf-8") + (guide / "start.md").write_text("# Start\n", encoding="utf-8") + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Home", ""), + (0, "Other", "other/"), + (0, "Guide", ""), + (1, "Start", "guide/start/"), + ] + + +def test_default_navigation_prefers_index_over_readme(tmp_path: Path) -> None: + """MkDocs suppresses a README when the same directory has an index.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "index.md").write_text("# Index\n", encoding="utf-8") + (docs / "README.md").write_text("# Readme\n", encoding="utf-8") + (docs / "other.md").write_text("# Other\n", encoding="utf-8") + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Index", ""), + (0, "Other", "other/"), + ] + + +def test_pattern_options_hide_directories_flatten_and_sort_by_metadata( + tmp_path: Path, +) -> None: + """Pattern-local behavior is applied before matches are sorted.""" + docs = tmp_path / "docs" + visible = docs / "visible" + hidden = docs / "hidden" + visible.mkdir(parents=True) + hidden.mkdir() + _write_template(tmp_path) + (visible / "a.md").write_text("# Zed\n", encoding="utf-8") + (visible / "b.md").write_text( + "---\ntitle: 0 First\n---\n# Bee\n", encoding="utf-8" + ) + (hidden / "page.md").write_text("# Hidden\n", encoding="utf-8") + (hidden / ".nav.yml").write_text("hide: true\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + """\ +nav: + - glob: "*/" + flatten_single_child_sections: true + sort: + by: title +""", + encoding="utf-8", + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Visible", ""), + (1, "0 First", "visible/b/"), + (1, "Zed", "visible/a/"), + ] + + +def test_inherits_ignore_and_append_unmatched_with_explicit_false_override( + tmp_path: Path, +) -> None: + """Child booleans override parents and ignore lists expand `$inherit`.""" + docs = tmp_path / "docs" + guide = docs / "guide" + guide.mkdir(parents=True) + _write_template(tmp_path) + (guide / "keep.md").write_text("# Keep\n", encoding="utf-8") + (guide / "extra.md").write_text("# Extra\n", encoding="utf-8") + (guide / "skip.hidden.md").write_text("# Hidden\n", encoding="utf-8") + (guide / "skip.draft.md").write_text("# Draft\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + """\ +flatten_single_child_sections: true +append_unmatched: true +ignore: "*.hidden.md" +nav: [guide] +""", + encoding="utf-8", + ) + (guide / ".nav.yml").write_text( + """\ +flatten_single_child_sections: false +ignore: + - $inherit + - "*.draft.md" +nav: [keep.md] +""", + encoding="utf-8", + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Guide", ""), + (1, "Keep", "guide/keep/"), + (1, "Extra", "guide/extra/"), + ] + + +def test_preserved_directory_name_precedes_index_title(tmp_path: Path) -> None: + """Literal directory names win when both title options are enabled.""" + docs = tmp_path / "docs" + section = docs / "literal-name" + section.mkdir(parents=True) + _write_template(tmp_path) + (section / "index.md").write_text( + "---\ntitle: Metadata title\n---\n# Index\n", encoding="utf-8" + ) + (section / "other.md").write_text("# Other\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + "preserve_directory_names: true\nuse_index_title: true\n", + encoding="utf-8", + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path)[0] == (0, "literal-name", "") + + +def test_flattening_keeps_directory_around_a_single_external_link( + tmp_path: Path, +) -> None: + """Upstream only flattens a lone page or section, never a link.""" + docs = tmp_path / "docs" + section = docs / "links" + section.mkdir(parents=True) + _write_template(tmp_path) + (section / "placeholder.md").write_text("# Placeholder\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + "flatten_single_child_sections: true\nnav: [links]\n", + encoding="utf-8", + ) + (section / ".nav.yml").write_text( + "nav:\n - Website: https://example.com\n", encoding="utf-8" + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Links", ""), + (1, "Website", "https://example.com"), + ] + + +def test_custom_filename_and_explicit_empty_navigation(tmp_path: Path) -> None: + """The plugin option selects control files and preserves an empty nav.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "awesome.yml").write_text("nav: []\n", encoding="utf-8") + + plugin = "awesome-nav:\n filename: awesome.yml" + zensical.build(str(_write_config(tmp_path, plugin)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [] + + +def test_natural_sort_matches_upstream_numeric_and_grouped_case_order( + tmp_path: Path, +) -> None: + """Natural sorting treats extensions, integer runs and case like natsort.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + pages = { + "2.md": "2", + "2-suffix.md": "2 suffix", + "2.5.md": "2.5", + "10.md": "10", + "numeric-a.md": "9", + "numeric-z.md": "8", + "A-upper.md": "A", + "a-lower.md": "a", + "B-upper.md": "B", + "b-lower.md": "b", + } + for name, title in pages.items(): + (docs / name).write_text( + f'---\ntitle: "{title}"\n---\n# Page\n', encoding="utf-8" + ) + (docs / ".nav.yml").write_text( + "sort:\n by: title\n", encoding="utf-8" + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert [title for _, title, _ in _items(tmp_path)] == [ + "2", + "2 suffix", + "2.5", + "8", + "9", + "10", + "A", + "a", + "B", + "b", + ] + + +def test_deep_explicit_directory_resolves_before_its_parent( + tmp_path: Path, +) -> None: + """A separately listed child directory is not consumed by its parent.""" + docs = tmp_path / "docs" + nested = docs / "foo" / "bar" + nested.mkdir(parents=True) + _write_template(tmp_path) + (docs / "foo" / "foo.md").write_text("# Foo\n", encoding="utf-8") + (nested / "bar.md").write_text("# Bar\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + "nav: [foo, foo/bar]\n", encoding="utf-8" + ) + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Foo", ""), + (1, "Foo", "foo/foo/"), + (0, "Bar", ""), + (1, "Bar", "foo/bar/bar/"), + ] + + +def test_recursive_directory_pattern_resolves_deepest_matches_first( + tmp_path: Path, +) -> None: + """A parent pattern match cannot consume a separately matched child.""" + docs = tmp_path / "docs" + nested = docs / "foo" / "bar" + nested.mkdir(parents=True) + _write_template(tmp_path) + (docs / "foo" / "foo.md").write_text("# Foo\n", encoding="utf-8") + (nested / "bar.md").write_text("# Bar\n", encoding="utf-8") + (docs / ".nav.yml").write_text("nav: ['**/']\n", encoding="utf-8") + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Foo", ""), + (1, "Foo", "foo/foo/"), + (0, "Bar", ""), + (1, "Bar", "foo/bar/bar/"), + ] + + +def test_globstar_flattens_pages_at_every_depth(tmp_path: Path) -> None: + """A bare globstar claims pages directly and leaves directories empty.""" + docs = tmp_path / "docs" + deep = docs / "bar" / "nested" + deep.mkdir(parents=True) + _write_template(tmp_path) + (docs / "foo.md").write_text("# Root\n", encoding="utf-8") + (docs / "bar" / "foo.md").write_text("# Child\n", encoding="utf-8") + (deep / "foo.md").write_text("# Deep\n", encoding="utf-8") + (docs / ".nav.yml").write_text("nav: ['**']\n", encoding="utf-8") + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Child", "bar/foo/"), + (0, "Deep", "bar/nested/foo/"), + (0, "Root", "foo/"), + ] + + +def test_serve_rebuilds_navigation_after_control_file_edit( + tmp_path: Path, +) -> None: + """The settled source dependency invalidates navigation during serve.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "other.md").write_text("# Other\n", encoding="utf-8") + navigation = docs / ".nav.yml" + navigation.write_text("nav: [index.md]\n", encoding="utf-8") + config = _write_config(tmp_path) + with config.open("a", encoding="utf-8") as stream: + stream.write("dev_addr: 127.0.0.1:0\n") + log = (tmp_path / "serve.log").open("w+", encoding="utf-8") + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "zensical", + "serve", + "--config-file", + str(config), + ], + cwd=tmp_path, + stdout=log, + stderr=subprocess.STDOUT, + ) + + def wait_for( + condition: Callable[[], bool], timeout: float = 10.0 + ) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return + if process.poll() is not None: + log.flush() + log.seek(0) + raise AssertionError( + f"serve exited with status {process.returncode}: " + f"{log.read()}" + ) + time.sleep(0.02) + log.flush() + log.seek(0) + raise AssertionError(f"serve did not rebuild navigation: {log.read()}") + + try: + wait_for(lambda: _items_or_none(tmp_path) == [(0, "Home", "")]) + with navigation.open("r+", encoding="utf-8") as stream: + stream.write("nav: [other.md]\n") + stream.truncate() + wait_for( + lambda: _items_or_none(tmp_path) == [(0, "Other", "other/")] + ) + assert process.poll() is None + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + log.close() + + +def test_awesome_nav_replaces_literate_nav_and_rejects_extglobs( + tmp_path: Path, +) -> None: + """Upstream event ordering makes awesome-nav the final navigation owner.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / "other.md").write_text("# Other\n", encoding="utf-8") + (docs / "SUMMARY.md").write_text( + "* [Other](other.md)\n", encoding="utf-8" + ) + navigation = docs / ".nav.yml" + navigation.write_text("nav: [index.md]\n", encoding="utf-8") + config = _write_config(tmp_path, "awesome-nav\n - literate-nav") + + zensical.build(str(config), _BUILD_OPTIONS) + assert _items(tmp_path) == [(0, "Home", "")] + + navigation.write_text( + "nav:\n - '@(index.md|other.md)'\n", encoding="utf-8" + ) + with pytest.raises(Exception, match="unsupported awesome-nav extglob"): + zensical.build(str(config), _BUILD_OPTIONS) + + +def test_no_match_diagnostics_obey_strict_and_configured_levels( + tmp_path: Path, +) -> None: + """Warnings fail strict builds while an explicit info level does not.""" + docs = tmp_path / "docs" + docs.mkdir() + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (docs / ".nav.yml").write_text("nav: [missing.md]\n", encoding="utf-8") + config = _write_config(tmp_path) + + with pytest.raises(Exception, match="awesome-nav reported errors"): + zensical.build(str(config), {"clean": False, "strict": True}) + + config = _write_config( + tmp_path, + "awesome-nav:\n logs:\n no_matches: info", + ) + zensical.build(str(config), {"clean": False, "strict": True}) + assert _items(tmp_path) == [] diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index 803f2f4..f509847 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -250,6 +250,66 @@ class TestPluginShimming: "toc": {"permalink": False}, } + @pytest.mark.parametrize("entry", ["awesome-nav", {"awesome-nav": None}]) + def test_awesome_nav_presence_enables_defaults( + self, tmp_path: Path, entry: object + ) -> None: + config = self._parse_yaml(tmp_path, plugins=[entry]) + assert config["plugins"]["awesome_nav"]["config"] == { + "enabled": True, + "filename": ".nav.yml", + "logs": { + "nav_override": None, + "root_title": None, + "root_hide": None, + "no_matches": None, + }, + } + + def test_awesome_nav_is_disabled_when_absent( + self, tmp_path: Path + ) -> None: + plugin = self._parse_yaml(tmp_path, plugins=[])["plugins"] + assert plugin["awesome_nav"]["config"]["enabled"] is False + + def test_awesome_nav_normalizes_filename_and_logs( + self, tmp_path: Path + ) -> None: + config = self._parse_yaml( + tmp_path, + plugins={ + "awesome-nav": { + "filename": "awesome.yml", + "logs": {"no_matches": "error"}, + } + }, + ) + plugin = config["plugins"]["awesome_nav"]["config"] + assert plugin["filename"] == "awesome.yml" + assert plugin["logs"]["no_matches"] == "error" + assert plugin["logs"]["root_title"] is None + + @pytest.mark.parametrize( + ("plugin", "message"), + [ + ([], "configuration must be a mapping"), + ({"unknown": True}, "unknown awesome-nav option"), + ({"filename": 42}, "filename must be a string"), + ({"filename": ""}, "filename must not be empty"), + ({"logs": "warning"}, "logs must be a mapping"), + ({"logs": {"unknown": "info"}}, "unknown awesome-nav log"), + ( + {"logs": {"no_matches": "debug"}}, + "must be info, warning or error", + ), + ], + ) + def test_awesome_nav_rejects_invalid_plugin_options( + self, tmp_path: Path, plugin: object, message: str + ) -> None: + with pytest.raises(cfg_module.ConfigurationError, match=message): + self._parse_yaml(tmp_path, plugins={"awesome-nav": plugin}) + def test_minify_plugin_is_normalized(self, tmp_path: Path) -> None: config = self._parse_yaml( tmp_path, diff --git a/python/zensical/config.py b/python/zensical/config.py index 8a286a2..9afbd2a 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -1418,6 +1418,54 @@ def _convert_plugins(value: Any, config: dict) -> dict: literate_nav["mdx_configs"] = extension_configs plugins["literate_nav"] = literate_nav + # Normalize mkdocs-awesome-nav without importing or executing the plugin. + # Rust owns discovery, YAML parsing, matching and navigation resolution. + awesome_nav: dict[str, Any] + if "awesome-nav" not in plugins: + awesome_nav = {"enabled": False} + else: + awesome_nav_config = plugins.pop("awesome-nav") + if awesome_nav_config is not None and not isinstance( + awesome_nav_config, dict + ): + raise ConfigurationError( + "awesome-nav configuration must be a mapping" + ) + awesome_nav = dict(awesome_nav_config or {}) + set_default(awesome_nav, "enabled", True, bool) + unknown = set(awesome_nav) - {"enabled", "filename", "logs"} + if unknown: + option = sorted(unknown)[0] + raise ConfigurationError(f"unknown awesome-nav option: {option}") + set_default(awesome_nav, "filename", ".nav.yml") + if not isinstance(awesome_nav["filename"], str): + raise ConfigurationError("awesome-nav filename must be a string") + if not awesome_nav["filename"]: + raise ConfigurationError("awesome-nav filename must not be empty") + logs = awesome_nav.get("logs") + if logs is None: + logs = {} + elif not isinstance(logs, dict): + raise ConfigurationError("awesome-nav logs must be a mapping") + logs = dict(logs) + unknown = set(logs) - { + "nav_override", + "root_title", + "root_hide", + "no_matches", + } + if unknown: + option = sorted(unknown)[0] + raise ConfigurationError(f"unknown awesome-nav log option: {option}") + for name in ("nav_override", "root_title", "root_hide", "no_matches"): + set_default(logs, name, None) + if logs[name] not in (None, "info", "warning", "error"): + raise ConfigurationError( + f"awesome-nav log level '{name}' must be info, warning or error" + ) + awesome_nav["logs"] = logs + plugins["awesome_nav"] = awesome_nav + # Define defaults for offline plugin offline = set_default(plugins, "offline", {"enabled": False}, dict) set_default(offline, "enabled", True, bool)