mirror of
https://github.com/zensical/zensical.git
synced 2026-09-25 07:45:36 +00:00
feature: add awesome-nav MkDocs plugin replacement
Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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) == []
|
||||
Vendored
+60
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user