feature: add awesome-nav MkDocs plugin replacement

Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
squidfunk
2026-09-01 18:36:01 +02:00
parent c3dbdfec55
commit 1e1fc7975b
11 changed files with 2455 additions and 6 deletions
@@ -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;
@@ -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<Settings>,
}
/// Inputs required to derive revision-complete navigation.
pub struct Dependencies<'a> {
/// Physical sources, including `.nav.yml` control files.
pub sources: &'a Stream<Id, Source>,
/// Rendered documentation pages.
pub pages: &'a Stream<Id, Page>,
}
#[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<crate::structure::nav::NavigationItem>,
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<BTreeMap<String, String>>);
impl Value for Documents {}
#[derive(Clone, Debug)]
struct Pages(Arc<Vec<Page>>);
impl Value for Pages {}
impl AwesomeNav {
/// Resolves immutable settings for one workflow lifetime.
pub fn new(config: &Config, strict: bool) -> Result<Self> {
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<Id, Navigation> {
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::<SourcePath>()?;
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<Key<Id>, 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<Key<Id>, 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<Key<Id>, Navigation>| {
navigation.values().next().cloned()
})
}
}
impl Logs {
fn new(logs: &AwesomeNavLogs) -> Result<Self> {
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<Level> {
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());
}
}
@@ -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<String>,
pub hide: bool,
pub flatten_single_child_sections: Option<bool>,
pub preserve_directory_names: Option<bool>,
pub use_index_title: Option<bool>,
pub sort: Sort,
pub ignore: Option<Vec<String>>,
pub nav: Option<Vec<Item>>,
pub append_unmatched: Option<bool>,
}
/// 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<Item>),
}
/// Options local to one pattern.
#[derive(Clone, Debug)]
pub struct PatternOptions {
pub glob: String,
pub flatten_single_child_sections: Option<bool>,
pub preserve_directory_names: Option<bool>,
pub sort: Sort,
pub ignore: Option<Vec<String>>,
pub append_unmatched: Option<bool>,
pub ignore_no_matches: bool,
}
/// Inheritable sorting options.
#[derive(Clone, Debug, Default)]
pub struct Sort {
pub by: Option<SortBy>,
pub direction: Option<Direction>,
pub kind: Option<SortKind>,
pub sections: Option<Sections>,
pub ignore_case: Option<bool>,
}
#[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<Config> {
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<Config> {
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<Vec<Item>> {
sequence(node, "nav")?.iter().map(parse_item).collect()
}
fn parse_item(node: &YamlOwned) -> Result<Item> {
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<PatternOptions> {
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<Sort> {
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<Vec<String>> {
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<String> {
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<bool> {
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());
}
}
@@ -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<Self> {
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<char> {
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"));
}
}
}
@@ -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<String>,
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<String>,
}
/// Revision-complete documentation catalog.
struct Catalog {
pages: BTreeMap<String, PageInfo>,
page_order: Vec<String>,
directories: BTreeSet<String>,
}
/// Navigation entry with deferred resolution state.
enum Entry {
Page(Resolved),
Directory {
path: String,
title: Option<String>,
config: Effective,
resolved: Option<Vec<Resolved>>,
},
Pattern {
options: PatternOptions,
config: Effective,
origin: String,
resolved: Option<Vec<Resolved>>,
},
Link(Resolved),
Section {
title: String,
children: Vec<Entry>,
},
}
/// 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<String>,
},
Link {
target: String,
},
Section(Vec<Resolved>),
}
struct Resolver<'a> {
settings: &'a Settings,
documents: &'a BTreeMap<String, String>,
catalog: Catalog,
pages: &'a [Page],
seen: HashSet<String>,
resolving: HashSet<String>,
parsed: HashMap<String, Config>,
diagnostics: Vec<Diagnostic>,
}
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::<Vec<_>>();
ordered.sort_by_key(|page| source_sort_key(page.source()));
let sources = ordered
.iter()
.map(|page| page.source().to_string())
.collect::<HashSet<_>>();
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::<Vec<_>>();
let mut directories = self
.directories
.iter()
.filter(|path| path.as_str() != ".")
.cloned()
.collect::<Vec<_>>();
directories.sort();
candidates.extend(directories.into_iter().map(|path| (path, true)));
candidates
}
}
impl<'a> Resolver<'a> {
fn new(
settings: &'a Settings, documents: &'a BTreeMap<String, String>,
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<Diagnostic>)> {
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<String>, from_pattern: bool,
) -> Result<Vec<Resolved>> {
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<Vec<Resolved>> {
let pattern = Pattern::compile(&options.glob)?;
let ignores = effective
.ignore
.iter()
.map(|value| Pattern::compile(value))
.collect::<Result<Vec<_>>>()?;
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::<Vec<_>>();
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<Item>, effective: &Effective,
) -> Result<Vec<Entry>> {
items
.into_iter()
.map(|item| self.parse_entry(root, item, effective))
.collect()
}
fn parse_entry(
&mut self, root: &str, item: Item, effective: &Effective,
) -> Result<Entry> {
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<String>,
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<Item> {
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<String> {
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<String>) -> 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<String> {
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<Entry>) -> Vec<Resolved> {
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<usize> {
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<String, String>, pages: &[Page],
) -> Result<(Navigation, Vec<Diagnostic>)> {
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"]
);
}
}
@@ -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<T: Item>(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<T: Item>(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::<String>()
} 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<Self::Item> {
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
);
}
}
+38
View File
@@ -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<String>,
/// Root title diagnostic.
pub root_title: Option<String>,
/// Root hiding diagnostic.
pub root_hide: Option<String>,
/// Unmatched pattern diagnostic.
pub no_matches: Option<String>,
}
// ----------------------------------------------------------------------------
/// Literate navigation plugin.
#[derive(Clone, Debug, Hash, FromPyObject, Serialize)]
#[pyo3(from_item_all)]
+17 -6
View File
@@ -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(),
+562
View File
@@ -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 %}
<item depth="{{ depth }}" title="{{ item.title or '' }}"
url="{{ item.url or '' }}" />
{{ 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) == []
+60
View File
@@ -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,
+48
View File
@@ -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)