Files
2026-10-12 12:41:21 +02:00

452 lines
14 KiB
Python
Vendored

# 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.
"""Test MkDocs file inclusion settings across builds and previews."""
from __future__ import annotations
import json
import subprocess
import sys
import time
from typing import TYPE_CHECKING, Any
import pytest
import yaml
import zensical
from zensical.config import ConfigurationError
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
_TEMPLATE = """\
{{ page.content }}
<nav>{% for item in nav.items recursive %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% if item.children %}{{ loop(item.children) }}{% endif %}
{% endfor %}</nav>
"""
def _project(
root: Path, files: dict[str, str], *, toml: bool = False, **settings: Any
) -> Path:
"""Create a project with observable content and navigation."""
for name, content in {"index.md": "# Home\n", **files}.items():
path = root / "docs" / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
overrides = root / "overrides"
overrides.mkdir()
(overrides / "main.html").write_text(_TEMPLATE, encoding="utf-8")
config = {
"site_name": "Inclusion",
"site_url": "https://example.com/",
"dev_addr": "127.0.0.1:0",
**settings,
}
if toml:
path = root / "zensical.toml"
text = "[project]\n" + "\n".join(
f"{key} = {json.dumps(value)}" for key, value in config.items()
)
text += '\n[project.theme]\ncustom_dir = "overrides"\n'
else:
path = root / "mkdocs.yml"
config["theme"] = {"custom_dir": "overrides"}
text = yaml.safe_dump(config)
path.write_text(text, encoding="utf-8")
return path
def _build(config: Path) -> None:
zensical.build(str(config), {"clean": False, "strict": True})
def _nav(site: Path) -> str:
return (
(site / "index.html")
.read_text()
.split("<nav>", 1)[1]
.split("</nav>", 1)[0]
)
@pytest.mark.parametrize("toml", [False, True])
@pytest.mark.parametrize(
"setting", ["exclude_docs", "draft_docs", "not_in_nav"]
)
def test_gitignore_patterns_and_negations(
tmp_path: Path, toml: bool, setting: str
) -> None:
# Anchors, recursive paths, escaped prefixes and ordered negations
# are exercised together.
excluded = [
"root.md",
"nested/secret.md",
"secret.md",
"drafts/deep/page.md",
"guide/old.md",
"#private.md",
"!private.md",
]
included = [
"nested/root.md",
"drafts/keep.md",
"guide/new.md",
"nested/guide/old.md",
"keep.md",
]
patterns = r"""# comment
/root.md
secret.md
drafts/
!drafts/keep.md
/guide/[a-z]*.md
!/guide/new.md
\#private.md
\!private.md
"""
config = _project(
tmp_path,
{name: f"# {name}\n" for name in excluded + included},
toml=toml,
**{setting: patterns},
)
_build(config)
site = tmp_path / "site"
nav = _nav(site)
search = json.loads((site / "search.json").read_text())
sitemap = (site / "sitemap.xml").read_text()
for name in included + excluded:
output = site / name.removesuffix(".md") / "index.html"
assert output.is_file() == (
name in included or setting == "not_in_nav"
), name
assert (f">{name}</a>" in nav) == (name in included), name
# Unlisted pages are included in search and the sitemap.
assert ("nested/secret/" in json.dumps(search)) == (setting == "not_in_nav")
assert ("nested/secret/" in sitemap) == (setting == "not_in_nav")
@pytest.mark.parametrize("restore", [False, True])
def test_default_exclusions_can_be_negated(
tmp_path: Path, restore: bool
) -> None:
files = {
".hidden.md": "# Hidden",
".assets/logo.svg": "asset",
"nested/.hidden/page.md": "# Nested hidden",
"templates/page.md": "# Template",
"templates/data.txt": "data",
"nested/templates/page.md": "# Ordinary page",
}
config = _project(
tmp_path, files, exclude_docs="!.*\n!/templates/" if restore else None
)
_build(config)
site = tmp_path / "site"
for output in [
".hidden/index.html",
".assets/logo.svg",
"nested/.hidden/page/index.html",
"templates/page/index.html",
"templates/data.txt",
]:
assert (site / output).is_file() == restore, output
assert (site / "nested/templates/page/index.html").is_file()
@pytest.mark.parametrize("setting", ["exclude_docs", "draft_docs"])
def test_exclusions_cover_resources_and_templates_before_processing(
tmp_path: Path, setting: str
) -> None:
files = {
"broken.md": "---\ninvalid: [\n---",
"private.txt": "private",
"export.html": "{{ must_not_render() }}",
}
config = _project(
tmp_path,
files,
extra_templates=["export.html"],
**{setting: "broken.md\n*.txt\nexport.html"}, # ty:ignore[invalid-argument-type]
)
(tmp_path / "overrides/private.txt").write_text("theme fallback")
(tmp_path / "overrides/theme.txt").write_text("theme asset")
_build(config)
site = tmp_path / "site"
for output in ["broken/index.html", "private.txt", "export.html"]:
assert not (site / output).exists(), output
assert (site / "theme.txt").read_text() == "theme asset"
def test_explicit_navigation_can_include_unlisted_pages(tmp_path: Path) -> None:
config = _project(
tmp_path,
{"hidden.md": "# Unlisted"},
not_in_nav="hidden.md",
nav=[{"Home": "index.md"}, {"Explicit title": "hidden.md"}],
)
_build(config)
assert 'href="hidden/">Explicit title</a>' in _nav(tmp_path / "site")
def test_exclude_plugin_can_filter_explicitly_included_hidden_pages(
tmp_path: Path,
) -> None:
files = {
".hidden/keep.md": "# Kept",
".hidden/drop.md": "---\ninvalid: [\n---",
}
config = _project(
tmp_path,
files,
exclude_docs="!.hidden/",
plugins=[{"exclude": {"glob": "*/drop.md"}}],
)
_build(config)
assert (tmp_path / "site/.hidden/keep/index.html").is_file()
assert not (tmp_path / "site/.hidden/drop/index.html").exists()
def test_exclusions_take_precedence_and_control_files_remain_available(
tmp_path: Path,
) -> None:
files = {
".nav.yml": "nav:\n - Custom home: index.md\n - '*'\n",
"private.md": "---\ninvalid: [\n---",
"draft.md": "# Draft",
"unlisted.md": "# Unlisted",
}
config = _project(
tmp_path,
files,
exclude_docs="private.md",
draft_docs="private.md\ndraft.md",
not_in_nav="!private.md\n!draft.md\nunlisted.md",
plugins=["awesome-nav"],
)
_build(config)
site = tmp_path / "site"
assert "Custom home" in _nav(site)
assert not (site / "private/index.html").exists()
assert not (site / "draft/index.html").exists()
assert (site / "unlisted/index.html").is_file()
@pytest.mark.parametrize(
"setting", ["exclude_docs", "draft_docs", "not_in_nav"]
)
@pytest.mark.parametrize("value", [True, 42, ["*.md"], {"pattern": "*.md"}])
def test_rejects_non_string_patterns(
tmp_path: Path, setting: str, value: Any
) -> None:
config = _project(tmp_path, {}, **{setting: value})
with pytest.raises(ConfigurationError, match=setting):
_build(config)
@pytest.mark.parametrize(
"setting", ["exclude_docs", "draft_docs", "not_in_nav"]
)
def test_reports_invalid_patterns(tmp_path: Path, setting: str) -> None:
config = _project(tmp_path, {}, **{setting: "!"}) # ty:ignore[invalid-argument-type]
with pytest.raises(ValueError, match=setting):
_build(config)
def test_serve_includes_marked_drafts_and_updates_inclusion(
tmp_path: Path,
) -> None:
files = {
"draft.md": "# Draft page",
"draft.txt": "draft asset",
"private.md": "# Private",
"moving.md": "# Moving",
"moving.txt": "moving asset",
"unlisted.md": "# Unlisted",
".included/page.md": "# Hidden page",
".included/data.txt": "hidden asset",
}
config = _project(
tmp_path,
files,
exclude_docs="private.md\n*.excluded.md\n*.excluded.txt\n!.included/",
draft_docs="draft.*",
not_in_nav="unlisted.md",
)
site = tmp_path / "site"
with (tmp_path / "serve.log").open("w+", encoding="utf-8") as log:
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]) -> None:
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if condition():
return
if process.poll() is not None:
break
time.sleep(0.02)
log.flush()
log.seek(0)
raise AssertionError(
f"serve did not update file inclusion: {log.read()}"
)
try:
wait_for(
lambda: (
(site / "draft/index.html").exists()
and (site / "index.html").exists()
and (site / "moving.txt").exists()
)
)
# Drafts are rendered with a marker
# and are omitted from inferred navigation.
assert (
'class="mkdocs-draft-marker zensical-draft-marker"'
in (site / "draft/index.html").read_text()
)
assert (site / "draft.txt").read_text() == "draft asset"
assert "Draft page" not in _nav(site)
assert "Unlisted" not in _nav(site)
assert not (site / "private/index.html").exists()
wait_for(
lambda: (
(site / ".included/page/index.html").exists()
and (site / ".included/data.txt").exists()
)
)
# Hidden directories are discovered
# when they are created during preview.
nested = tmp_path / "docs/.included/.new"
nested.mkdir()
(nested / "page.md").write_text("# New hidden page")
wait_for((site / ".included/.new/page/index.html").exists)
# Renamed hidden directories retain their pages and assets.
(tmp_path / "docs/.included").rename(tmp_path / "docs/visible")
wait_for(
lambda: (
(site / "visible/page/index.html").exists()
and not (site / ".included/page/index.html").exists()
)
)
# Outputs are removed when their sources
# are renamed into excluded paths.
(tmp_path / "docs/moving.md").rename(
tmp_path / "docs/moving.excluded.md"
)
(tmp_path / "docs/moving.txt").rename(
tmp_path / "docs/moving.excluded.txt"
)
wait_for(
lambda: (
not (site / "moving/index.html").exists()
and not (site / "moving.txt").exists()
)
)
# Updated drafts are included in subsequent revisions
# with the same marker.
(tmp_path / "docs/draft.md").write_text("# Updated draft")
wait_for(
lambda: (
"Updated draft" in (site / "draft/index.html").read_text()
)
)
assert (
'class="mkdocs-draft-marker zensical-draft-marker"'
in (site / "draft/index.html").read_text()
)
assert not (site / "moving.excluded/index.html").exists()
assert not (site / "moving.excluded.txt").exists()
# Changed patterns are reloaded and
# the previously excluded sources are published.
settings = yaml.safe_load(config.read_text())
settings.update(exclude_docs=None, draft_docs=None, not_in_nav=None)
config.write_text(yaml.safe_dump(settings))
wait_for(
lambda: (
(site / "private/index.html").exists()
and (site / "moving.excluded.txt").exists()
and "Updated draft" in _nav(site)
)
)
assert (
'class="mkdocs-draft-marker zensical-draft-marker"'
not in (site / "draft/index.html").read_text()
)
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
# A build after preview excludes drafts even when the render cache is warm.
settings.update(draft_docs="draft.*")
config.write_text(yaml.safe_dump(settings))
_build(config)
assert not (site / "draft/index.html").exists()
assert not (site / "draft.txt").exists()