mirror of
https://github.com/zensical/zensical.git
synced 2026-09-24 07:15:34 +00:00
feature: add awesome-nav MkDocs plugin replacement
Signed-off-by: squidfunk <martin.donath@squidfunk.com>
This commit is contained in:
+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