feature: add literate-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 32bd870744
commit c3dbdfec55
14 changed files with 2115 additions and 34 deletions
+371
View File
@@ -0,0 +1,371 @@
# Copyright (c) 2025-2026 Zensical and contributors
# SPDX-License-Identifier: MIT
# All contributions are certified under the DCO
"""Integration tests for native mkdocs-literate-nav compatibility."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from bs4 import BeautifulSoup
import zensical
if TYPE_CHECKING:
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]]:
"""Extract the template's normalized navigation records."""
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 test_resolves_markers_nested_files_wildcards_and_external_links(
tmp_path: Path,
) -> None:
"""The complete native pipeline reproduces a mixed literate nav."""
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 / "ignored.md").write_text("# Ignored\n", encoding="utf-8")
(docs / "SUMMARY.md").write_text(
"""\
* [Ignored before marker](ignored.md)
<!--nav-->
* [Home](index.md)
* [Guide](guide/)
* [Project](https://example.com/project)
""",
encoding="utf-8",
)
(docs / "guide" / "SUMMARY.md").write_text(
"""\
* [Overview](index.md)
* [Start](start.md)
* API
* api/*.md
* *
""",
encoding="utf-8",
)
(docs / "guide" / "index.md").write_text(
"# Overview\n", encoding="utf-8"
)
(docs / "guide" / "start.md").write_text(
"# Start\n", encoding="utf-8"
)
(docs / "guide" / "advanced.md").write_text(
"# Advanced\n", encoding="utf-8"
)
(api / "one.md").write_text("# One\n", encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
theme:
name: material
custom_dir: overrides
plugins:
- literate-nav
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert _items(tmp_path) == [
(0, "Home", ""),
(0, "Guide", ""),
(1, "Overview", "guide/"),
(1, "Start", "guide/start/"),
(1, "API", ""),
(2, "One", "guide/api/one/"),
(1, "Advanced", "guide/advanced/"),
(0, "Project", "https://example.com/project"),
]
def test_resolves_configured_directory_through_nested_literate_nav(
tmp_path: Path,
) -> None:
"""A titled directory in configured nav delegates to its own file."""
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")
(guide / "SUMMARY.md").write_text(
"* [Start](start.md)\n", encoding="utf-8"
)
(guide / "start.md").write_text("# Start\n", encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
theme:
name: material
custom_dir: overrides
plugins:
- literate-nav
nav:
- Home: index.md
- Guide: guide/
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert _items(tmp_path) == [
(0, "Home", ""),
(0, "Guide", ""),
(1, "Start", "guide/start/"),
]
def test_preserves_entity_spellings_in_titles(tmp_path: Path) -> None:
"""The HTML transport does not collapse distinct Markdown title text."""
docs = tmp_path / "docs"
docs.mkdir()
_write_template(tmp_path)
(docs / "SUMMARY.md").write_text(
"""\
* [a&amp;b](a.md)
* [a&b](b.md)
* [a&amp;amp;b](c.md)
* [\\__init__](d.md)
* [\\`hi`](e.md)
""",
encoding="utf-8",
)
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
theme:
name: material
custom_dir: overrides
plugins:
- literate-nav
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
output = next((tmp_path / "site").rglob("*.html")).read_text()
assert 'title="a&amp;b"' in output
assert 'title="a&b"' in output
assert 'title="a&amp;amp;b"' in output
assert 'title="__init__"' in output
assert 'title="`hi`"' in output
def test_marker_preserves_reference_definitions_from_the_complete_document(
tmp_path: Path,
) -> None:
"""The marker changes list selection without isolating Markdown state."""
docs = tmp_path / "docs"
docs.mkdir()
_write_template(tmp_path)
(docs / "SUMMARY.md").write_text(
"""\
[guide]: guide.md
- [Ignored](ignored.md)
<!--nav-->
- [Earlier](ignored.md)
<!--nav-->
- [Guide][guide]
Gap
- [Later](ignored.md)
""",
encoding="utf-8",
)
(docs / "guide.md").write_text("# Guide\n", encoding="utf-8")
(docs / "ignored.md").write_text("# Ignored\n", encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
theme:
name: material
custom_dir: overrides
plugins:
- literate-nav
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert _items(tmp_path) == [(0, "Guide", "guide/")]
def test_applies_plugin_local_tab_length(tmp_path: Path) -> None:
"""Plugin-local indentation controls the navigation Markdown parser."""
docs = tmp_path / "docs"
docs.mkdir()
_write_template(tmp_path)
(docs / "SUMMARY.md").write_text(
"- Guide\n - [Start](start.md)\n", encoding="utf-8"
)
(docs / "start.md").write_text("# Start\n", encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
theme:
name: material
custom_dir: overrides
plugins:
- literate-nav:
tab_length: 2
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert _items(tmp_path) == [
(0, "Guide", ""),
(1, "Start", "start/"),
]
def test_directory_wildcards_do_not_consume_files(tmp_path: Path) -> None:
"""A slash wildcard leaves files available to following wildcards."""
docs = tmp_path / "docs"
section = docs / "section2"
section.mkdir(parents=True)
_write_template(tmp_path)
(docs / "SUMMARY.md").write_text(
"- */\n- *.md\n", encoding="utf-8"
)
(docs / "item1.md").write_text("# Item 1\n", encoding="utf-8")
(docs / "item2.md").write_text("# Item 2\n", encoding="utf-8")
(section / "item.md").write_text("# Section item\n", encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
theme:
name: material
custom_dir: overrides
plugins:
- literate-nav
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert _items(tmp_path) == [
(0, "Section2", ""),
(1, "Section item", "section2/item/"),
(0, "Item 1", "item1/"),
(0, "Item 2", "item2/"),
]
def test_explicitly_empty_literate_navigation_stays_empty(
tmp_path: Path,
) -> None:
"""An exhausted wildcard must not reactivate automatic navigation."""
docs = tmp_path / "docs"
docs.mkdir()
_write_template(tmp_path)
(docs / "SUMMARY.md").write_text("- *\n", encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
theme:
name: material
custom_dir: overrides
plugins:
- literate-nav
""",
encoding="utf-8",
)
zensical.build(str(config), _BUILD_OPTIONS)
assert _items(tmp_path) == []
@pytest.mark.parametrize(
"summary",
[
"* Empty section\n",
"* **[Obscured](page.md)**\n",
"* [First](first.md)[Second](second.md)\n",
"* [Page](page.md) trailing text\n",
"1. * [Item](section/item.md)\n",
"1. Section *one*\n * [Item](section/item.md)\n",
],
)
def test_rejects_ambiguous_navigation_items(
tmp_path: Path, summary: str
) -> None:
"""Invalid list items fail instead of producing surprising navigation."""
docs = tmp_path / "docs"
docs.mkdir()
(docs / "SUMMARY.md").write_text(summary, encoding="utf-8")
config = tmp_path / "mkdocs.yml"
config.write_text(
"""\
site_name: Literate navigation
plugins:
- literate-nav
""",
encoding="utf-8",
)
with pytest.raises(RuntimeError):
zensical.build(str(config), _BUILD_OPTIONS)
+48
View File
@@ -202,6 +202,54 @@ class TestPluginShimming:
"redirect_maps": {},
}
@pytest.mark.parametrize("entry", ["literate-nav", {"literate-nav": None}])
def test_literate_nav_presence_enables_defaults(
self, tmp_path: Path, entry: object
) -> None:
config = self._parse_yaml(tmp_path, plugins=[entry])
plugin = config["plugins"]["literate_nav"]["config"]
assert plugin == {
"enabled": True,
"nav_file": "SUMMARY.md",
"implicit_index": False,
"tab_length": 4,
"markdown_extensions": [],
"mdx_configs": {},
}
def test_literate_nav_is_disabled_when_absent(
self, tmp_path: Path
) -> None:
config = self._parse_yaml(tmp_path, plugins=[])
assert config["plugins"]["literate_nav"]["config"]["enabled"] is False
def test_literate_nav_normalizes_local_markdown_extensions(
self, tmp_path: Path
) -> None:
config = self._parse_yaml(
tmp_path,
plugins={
"literate-nav": {
"nav_file": "NAV.md",
"implicit_index": True,
"tab_length": 2,
"markdown_extensions": [
"abbr",
{"toc": {"permalink": False}},
],
}
},
)
plugin = config["plugins"]["literate_nav"]["config"]
assert plugin["nav_file"] == "NAV.md"
assert plugin["implicit_index"] is True
assert plugin["tab_length"] == 2
assert plugin["markdown_extensions"] == ["abbr", "toc"]
assert plugin["mdx_configs"] == {
"abbr": {},
"toc": {"permalink": False},
}
def test_minify_plugin_is_normalized(self, tmp_path: Path) -> None:
config = self._parse_yaml(
tmp_path,
+142
View File
@@ -0,0 +1,142 @@
# 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 NONINFRINGEMENT. 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.
"""Narrow Python-Markdown adapter for native literate navigation."""
from __future__ import annotations
from copy import deepcopy
from itertools import dropwhile
from typing import TYPE_CHECKING
from xml.etree import ElementTree
from markdown import Markdown
from markdown.preprocessors import Preprocessor
from markdown.treeprocessors import Treeprocessor
from zensical.config import get_config
if TYPE_CHECKING:
from collections.abc import Callable
# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
# Private-use scalar framing text that must survive XML and HTML decoding.
_TEXT_ESCAPE = "\U000f0000"
# ----------------------------------------------------------------------------
# Classes
# ----------------------------------------------------------------------------
class _MarkerPreprocessor(Preprocessor):
"""Replace explicit navigation markers with a tree-visible placeholder."""
def __init__(self, md: Markdown):
super().__init__(md)
self.placeholder: str | None = None
def run(self, lines: list[str]) -> list[str]:
for index, line in enumerate(lines):
if line.strip() == "<!--nav-->":
self.placeholder = self.md.htmlStash.store("")
lines[index] = self.placeholder + "\n"
return lines
class _CaptureTreeprocessor(Treeprocessor):
"""Capture the selected root list at literate-nav's processing phase."""
def __init__(self, md: Markdown, marker: _MarkerPreprocessor):
super().__init__(md)
self.marker = marker
self.nav: ElementTree.Element | None = None
def run(self, root: ElementTree.Element) -> None:
if self.marker.placeholder is None:
candidates = reversed(root)
else:
candidates = dropwhile(
lambda element: element.text != self.marker.placeholder,
root,
)
for element in candidates:
if element.tag in {"ul", "ol"}:
self.nav = deepcopy(element)
return
# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------
def render(content: str) -> str:
"""Capture literate navigation with its local Markdown configuration.
This returns only the selected list subtree. Interpretation belongs to the
native compatibility module, keeping filesystem and navigation semantics
out of Python.
"""
plugin = get_config()["plugins"]["literate_nav"]["config"]
md = Markdown(
extensions=plugin["markdown_extensions"],
extension_configs=plugin["mdx_configs"],
tab_length=plugin["tab_length"],
)
# Parse the complete document so definitions and extension state before
# an explicit marker remain available to the selected navigation list.
# Keep inline HTML and entities in the captured tree instead of replacing
# them with placeholders that only a complete Markdown render can restore.
md.inlinePatterns.deregister("html", strict=False)
md.inlinePatterns.deregister("entity", strict=False)
marker = _MarkerPreprocessor(md)
capture = _CaptureTreeprocessor(md, marker)
md.preprocessors.register(marker, "zensical_literate_nav_marker", 25)
md.treeprocessors.register(capture, "zensical_literate_nav_capture", 19)
md.convert(content)
if capture.nav is None:
return ""
_encode_tree(capture.nav, md.treeprocessors["unescape"].unescape)
return ElementTree.tostring(capture.nav, encoding="unicode")
def _escape_text(value: str) -> str:
"""Encode ampersands and the escape scalar without ambiguity."""
return value.replace(_TEXT_ESCAPE, _TEXT_ESCAPE + "S").replace(
"&", _TEXT_ESCAPE + "A"
)
def _encode_tree(
root: ElementTree.Element, unescape: Callable[[str], str]
) -> None:
"""Preserve text across XML serialization and Rust's HTML tokenizer."""
for element in root.iter():
if element.text:
element.text = _escape_text(unescape(element.text))
if element.tail:
element.tail = _escape_text(unescape(element.tail))
+65 -1
View File
@@ -31,7 +31,7 @@ import pickle
from importlib.metadata import EntryPoint, entry_points
from importlib.util import find_spec
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any
from typing import IO, TYPE_CHECKING, Any, cast
from urllib.parse import urljoin, urlparse
import yaml
@@ -1246,6 +1246,50 @@ def _convert_markdown_extensions(value: Any) -> tuple[list[str], dict]:
return mdx_exts, mdx_configs
def _convert_plugin_markdown_extensions(
value: Any,
) -> tuple[list[str], dict[str, dict[str, Any]]]:
"""Normalize a plugin-local Python-Markdown configuration.
Unlike the site renderer, plugin-local Markdown parsers do not inherit
Zensical's default extensions. This mirrors MkDocs' MarkdownExtensions
configuration option while retaining extension names and configuration in
Python, where callable values remain usable.
"""
markdown_extensions: list[str] = []
mdx_configs: dict[str, dict[str, Any]] = {}
if value is None:
return markdown_extensions, mdx_configs
items: Any = value.items() if isinstance(value, dict) else value
for item in items:
if isinstance(item, tuple):
extension, extension_config = item
elif isinstance(item, dict):
if len(item) != 1:
raise ConfigurationError(
"Markdown extension mappings must contain one entry"
)
extension, extension_config = next(iter(item.items()))
elif isinstance(item, str):
extension, extension_config = item, {}
else:
raise ConfigurationError(
"Markdown extensions must be strings or mappings"
)
if not isinstance(extension, str):
raise ConfigurationError("Markdown extension names must be strings")
if extension_config is None:
extension_config = {}
if not isinstance(extension_config, dict):
raise ConfigurationError(
"Markdown extension configurations must be mappings"
)
normalized_config = cast("dict[str, Any]", extension_config)
markdown_extensions.append(extension)
mdx_configs[extension] = normalized_config
return markdown_extensions, mdx_configs
def _convert_plugins(value: Any, config: dict) -> dict:
"""Convert plugins configuration to something we can work with."""
plugins = {}
@@ -1354,6 +1398,26 @@ def _convert_plugins(value: Any, config: dict) -> dict:
minify["htmlmin_opts"] = htmlmin_opts
plugins["minify"] = minify
# Normalize mkdocs-literate-nav without importing or executing the plugin.
# Python retains extension objects and callables for the narrow Markdown
# rendering boundary; Rust owns discovery and navigation resolution.
literate_nav: dict[str, Any]
if "literate-nav" not in plugins:
literate_nav = {"enabled": False}
else:
literate_nav_config = plugins.pop("literate-nav")
literate_nav = dict(literate_nav_config or {})
set_default(literate_nav, "enabled", True, bool)
set_default(literate_nav, "nav_file", "SUMMARY.md", str)
set_default(literate_nav, "implicit_index", False, bool)
set_default(literate_nav, "tab_length", 4, int)
extensions, extension_configs = _convert_plugin_markdown_extensions(
literate_nav.get("markdown_extensions", [])
)
literate_nav["markdown_extensions"] = extensions
literate_nav["mdx_configs"] = extension_configs
plugins["literate_nav"] = literate_nav
# Define defaults for offline plugin
offline = set_default(plugins, "offline", {"enabled": False}, dict)
set_default(offline, "enabled", True, bool)
-4
View File
@@ -35,10 +35,6 @@ from zensical.extensions.autorefs import set_autorefs_page
from zensical.extensions.context import ContextExtension, Page
from zensical.extensions.links import LinksExtension
# ----------------------------------------------------------------------------
# Functions
# ----------------------------------------------------------------------------
def render(content: str, path: str, url: str, metadata: str = "{}") -> dict:
"""Render Markdown and return HTML.