feature: support mkdocs-nav-weight plugin

Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
Timothée Mazzucotelli committed 2026-10-07 18:14:15 +02:00
1 parent d210186d9d
commit edbaf5516b
14 files changed
+1043 -27

No files matched your search

+441
View File
@@ -0,0 +1,441 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
"""Integration tests for native mkdocs-nav-weight 0.3.0 compatibility."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
import yaml
from bs4 import BeautifulSoup
import zensical
if TYPE_CHECKING:
from pathlib import Path
from bs4 import Tag
_TEMPLATE = """\
{% 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) }}
{% for item in nav.pages %}<flat url="{{ item.url }}" />{% endfor %}
{% for item in page.ancestors %}<ancestor title="{{ item.title }}" />{% endfor %}
<current title="{{ page.title }}"
previous="{{ page.previous_page.url if page.previous_page else '' }}"
next="{{ page.next_page.url if page.next_page else '' }}" />
""" # noqa: E501
def _markdown(title: str, **metadata: Any) -> str:
"""Create a Markdown source with the metadata relevant to a test."""
front_matter = yaml.safe_dump(metadata) if metadata else ""
return f"---\n{front_matter}---\n# {title}\n"
def _project(
root: Path,
documents: dict[str, str],
settings: dict[str, Any] | None = None,
nav: list[Any] | None = None,
) -> Path:
"""Write a project with observable navigation and page connections."""
overrides = root / "overrides"
overrides.mkdir()
(overrides / "main.html").write_text(_TEMPLATE, encoding="utf-8")
for name, content in documents.items():
path = root / "docs" / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
config: dict[str, Any] = {
"site_name": "Navigation weights",
"theme": {"name": "material", "custom_dir": "overrides"},
"plugins": [{"mkdocs-nav-weight": settings or {}}],
}
if nav is not None:
config["nav"] = nav
path = root / "mkdocs.yml"
path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
return path
def _build(config: Path, *, strict: bool = False) -> None:
zensical.build(str(config), {"clean": False, "strict": strict})
def _output(root: Path, page: str = "index.html") -> BeautifulSoup:
return BeautifulSoup(
(root / "site" / page).read_text(encoding="utf-8"), "html.parser"
)
def _items(output: BeautifulSoup) -> list[tuple[int, str, str]]:
return [
(int(str(item["depth"])), str(item["title"]), str(item["url"]))
for item in output.find_all("item")
]
def _pages(output: BeautifulSoup) -> list[str]:
return [str(item["url"]) for item in output.find_all("flat")]
def _current(root: Path, page: str) -> Tag:
"""Return the current page element and assert that it exists."""
current = _output(root, page).select_one("current")
assert current is not None
return current
def test_sorts_pages_sections_and_empty_indexes(tmp_path: Path) -> None:
# Section metadata is supplied by an empty index; all sources are rendered.
config = _project(
tmp_path,
{
"index.md": "# Home\n",
"a.md": _markdown("Later", weight=3.5),
"z.md": _markdown("Early", weight=-2),
"b.md": "# Default\n",
"guide/index.md": _markdown(
"Handbook", weight=-1, retitled=True, empty=True
),
"guide/a.md": _markdown("Second", weight=2),
"guide/z.md": _markdown("First", weight=1),
"reference/README.md": _markdown("Reference index", weight=5),
"reference/topic.md": "# Topic\n",
"unindexed/topic.md": "# Unindexed\n",
},
{"default_page_weight": 4},
)
_build(config)
output = _output(tmp_path)
assert _items(output) == [
(0, "Home", ""),
(0, "Early", "z/"),
(0, "Handbook", ""),
(1, "First", "guide/z/"),
(1, "Second", "guide/a/"),
(0, "Unindexed", ""),
(1, "Unindexed", "unindexed/topic/"),
(0, "Later", "a/"),
(0, "Default", "b/"),
(0, "Reference", ""),
(1, "Reference index", "reference/"),
(1, "Topic", "reference/topic/"),
]
assert "guide/" not in _pages(output)
assert (tmp_path / "site" / "guide" / "index.html").is_file()
assert _current(tmp_path, "guide/z/index.html")["next"] == "guide/a/"
@pytest.mark.parametrize("included", [False, True])
def test_hides_pages_and_isolates_hidden_sections(
tmp_path: Path,
included: bool,
) -> None:
config = _project(
tmp_path,
{
# Root index metadata is ignored, as in the released plugin.
"index.md": _markdown(
"Home", headless=True, empty=True, retitled=True, weight=100
),
"a.md": "# Visible\n",
"hidden.md": _markdown("Hidden page", headless=True, weight=5),
"secret/index.md": _markdown("Secret", headless=True),
"secret/next.md": "# Secret next\n",
"secret/hidden.md": _markdown("Hidden child", headless=True),
},
{"headless_included": included},
)
_build(config)
output = _output(tmp_path)
assert _items(output) == [(0, "Home", ""), (0, "Visible", "a/")]
expected_pages = ["", "a/"]
if included:
expected_pages += [
"hidden/",
"secret/",
"secret/next/",
"secret/hidden/",
]
assert _pages(output) == expected_pages
# Page sequences within hidden sections are kept separate.
secret = _current(tmp_path, "secret/index.html")
assert secret["previous"] == ""
assert secret["next"] == "secret/next/"
assert _current(tmp_path, "secret/next/index.html")["previous"] == "secret/"
hidden = _current(tmp_path, "hidden/index.html")
assert hidden["previous"] == hidden["next"] == ""
hidden_child = _output(tmp_path, "secret/hidden/index.html")
assert [item["title"] for item in hidden_child.find_all("ancestor")] == [
"Secret"
]
assert _current(tmp_path, "a/index.html")["next"] == ""
@pytest.mark.parametrize("reverse", [False, True])
def test_preserves_equal_weight_order_in_configured_navigation(
tmp_path: Path,
reverse: bool,
) -> None:
config = _project(
tmp_path,
{
"index.md": "# Home\n",
"first.md": _markdown("First", weight=1),
"second.md": _markdown("Second", weight=True),
"group/README.md": _markdown("Source title", weight=2),
"group/child.md": _markdown("Child", weight=-4),
},
{"reverse": reverse, "index_weight": -3, "section_renamed": True},
[
{"Home": "index.md"},
{"Second alias": "second.md"},
{"First alias": "first.md"},
{"Website": "https://example.com"},
{"Group": [{"Index alias": "group/README.md"}, "group/child.md"]},
],
)
_build(config)
output = _output(tmp_path)
expected = [
(0, "Home", ""),
(0, "Website", "https://example.com"),
(0, "Second alias", "second/"),
(0, "First alias", "first/"),
(0, "Index alias", ""),
(1, "Child", "group/child/"),
(1, "Index alias", "group/"),
]
if reverse:
expected = [
expected[4],
expected[6],
expected[5],
expected[2],
expected[3],
expected[1],
expected[0],
]
assert _items(output) == expected
first = _current(tmp_path, "first/index.html")
assert first["title"] == "First alias"
second = _current(tmp_path, "second/index.html")
assert second["next"] == "first/"
if reverse:
assert first["next"] == ""
else:
assert second["previous"] == ""
@pytest.mark.parametrize("warning", [False, True])
def test_invalid_metadata_falls_back_and_obeys_strict_mode(
tmp_path: Path,
warning: bool,
) -> None:
config = _project(
tmp_path,
{
"index.md": "# Home\n",
"first.md": _markdown("First", weight="1", headless="true"),
"second.md": _markdown("Second", weight=2),
"group/index.md": _markdown(
"Group index", weight=[], retitled=1, empty="true"
),
},
{"warning": warning, "default_page_weight": 3},
)
if warning:
with pytest.raises(
RuntimeError, match="mkdocs-nav-weight reported warnings"
):
_build(config, strict=True)
else:
_build(config, strict=True)
assert _items(_output(tmp_path)) == [
(0, "Home", ""),
(0, "Second", "second/"),
(0, "First", "first/"),
(0, "Group", ""),
(1, "Group index", "group/"),
]
def test_disabled_plugin_preserves_navigation(tmp_path: Path) -> None:
config = _project(
tmp_path,
{
"index.md": "# Home\n",
"a.md": _markdown("Alpha", headless=True, weight=10),
"z.md": _markdown("Zeta", weight=-10),
},
{"enabled": False},
)
_build(config)
assert _items(_output(tmp_path)) == [
(0, "Home", ""),
(0, "Alpha", "a/"),
(0, "Zeta", "z/"),
]
def test_metadata_changes_update_cached_navigation(tmp_path: Path) -> None:
config = _project(
tmp_path,
{
"index.md": "# Home\n",
"a.md": _markdown("Alpha", weight=1),
"z.md": _markdown("Zeta", weight=2),
},
)
_build(config)
assert _pages(_output(tmp_path)) == ["", "a/", "z/"]
# Ordering, titles, and visibility must be updated during a cached rebuild.
(tmp_path / "docs" / "z.md").write_text(
_markdown("Renamed", weight=-1), encoding="utf-8"
)
(tmp_path / "docs" / "a.md").write_text(
_markdown("Alpha", headless=True), encoding="utf-8"
)
_build(config)
assert _items(_output(tmp_path)) == [(0, "Home", ""), (0, "Renamed", "z/")]
@pytest.mark.parametrize("plugin", ["awesome-nav", "literate-nav"])
def test_applies_weights_after_other_navigation_plugins(
tmp_path: Path, plugin: str
) -> None:
config = _project(
tmp_path,
{
"index.md": "# Home\n",
"a.md": _markdown("Alpha", weight=2),
"z.md": _markdown("Zeta", weight=1),
},
)
data = yaml.safe_load(config.read_text(encoding="utf-8"))
data["plugins"].append(plugin)
config.write_text(yaml.safe_dump(data), encoding="utf-8")
if plugin == "awesome-nav":
control = tmp_path / "docs" / ".nav.yml"
control.write_text(
"nav:\n - index.md\n - First alias: a.md\n"
" - Second alias: z.md\n",
encoding="utf-8",
)
else:
control = tmp_path / "docs" / "SUMMARY.md"
control.write_text(
"* [Home](index.md)\n* [First alias](a.md)\n"
"* [Second alias](z.md)\n",
encoding="utf-8",
)
_build(config)
assert _items(_output(tmp_path)) == [
(0, "Home", ""),
(0, "Second alias", "z/"),
(0, "First alias", "a/"),
]
def test_preserves_large_integer_weights(tmp_path: Path) -> None:
# Adjacent integers must remain distinct above floating-point precision.
config = _project(
tmp_path,
{
"index.md": "# Home\n",
"a.md": _markdown("Larger", weight=9007199254740993),
"b.md": _markdown("Float", weight=9007199254740992.0),
"z.md": _markdown("Smaller", weight=9007199254740992),
},
)
_build(config)
assert _pages(_output(tmp_path)) == ["", "b/", "z/", "a/"]
@pytest.mark.parametrize("enabled", [None, False, True])
def test_connections_skip_links_only_when_nav_weight_is_enabled(
tmp_path: Path,
enabled: bool | None,
) -> None:
# External URLs and unresolved references are kept in configured navigation.
config = _project(
tmp_path,
{"index.md": "# Home\n", "last.md": "# Last\n"},
{"enabled": enabled} if enabled is not None else None,
[
{"Home": "index.md"},
{"Website": "https://example.com"},
{"Missing": "missing.md"},
{"Download": "manual.pdf"},
{"Notebook": "notebook.html"},
{"Last": "last.md"},
{"Author's website": "https://author.example.com"},
],
)
if enabled is None:
settings = yaml.safe_load(config.read_text(encoding="utf-8"))
settings["plugins"] = []
config.write_text(yaml.safe_dump(settings), encoding="utf-8")
_build(config)
home = _current(tmp_path, "index.html")
last = _current(tmp_path, "last/index.html")
if enabled:
assert home["next"] == "last/"
assert last["previous"] == ""
assert last["next"] == ""
else:
assert home["next"] == "https://example.com"
assert last["previous"] == "notebook.html"
assert last["next"] == "https://author.example.com"
+58
View File
@@ -40,6 +40,7 @@ PYTHON_PLUGINS = (
"minify",
"literate-nav",
"awesome-nav",
"mkdocs-nav-weight",
"offline",
"mike",
"autorefs",
@@ -118,6 +119,7 @@ def test_preserves_plugin_presence_semantics() -> None:
"minify",
"literate_nav",
"awesome_nav",
"nav_weight",
"offline",
):
assert plugins[name]["config"]["enabled"] is False
@@ -859,3 +861,59 @@ def test_api_autonav_preserves_module_option_order() -> None:
"pkg.*",
".*",
]
@pytest.mark.parametrize(
"entry",
[
"mkdocs-nav-weight",
{"mkdocs-nav-weight": None},
{"mkdocs-nav-weight": {}},
],
)
def test_nav_weight_defaults(entry: Any) -> None:
plugins = _convert_plugins([entry])
assert plugins["nav_weight"]["config"] == {
"enabled": True,
"section_renamed": False,
"index_weight": -10,
"warning": True,
"reverse": False,
"headless_included": False,
"default_page_weight": 0,
}
@pytest.mark.parametrize("option", ["index_weight", "default_page_weight"])
@pytest.mark.parametrize("value", [-3, 1.25, True])
def test_nav_weight_accepts_numeric_options(option: str, value: Any) -> None:
plugins = _convert_plugins({"mkdocs-nav-weight": {option: value}})
assert plugins["nav_weight"]["config"][option] == value
@pytest.mark.parametrize("option", ["index_weight", "default_page_weight"])
@pytest.mark.parametrize("value", ["1", [], {}])
def test_nav_weight_rejects_invalid_numeric_options(
option: str, value: Any
) -> None:
with pytest.raises(
ConfigurationError, match=f"mkdocs-nav-weight {option} must be a number"
):
_convert_plugins({"mkdocs-nav-weight": {option: value}})
@pytest.mark.parametrize(
"option",
["enabled", "section_renamed", "warning", "reverse", "headless_included"],
)
@pytest.mark.parametrize("value", [1, "true", [], {}])
def test_nav_weight_rejects_invalid_boolean_options(
option: str, value: Any
) -> None:
with pytest.raises(
ConfigurationError,
match=f"mkdocs-nav-weight {option} must be a boolean",
):
_convert_plugins({"mkdocs-nav-weight": {option: value}})