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
+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)