# 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 coverage for macros page selection and verbose output.""" from __future__ import annotations import json import os import subprocess import sys import time from textwrap import dedent from typing import TYPE_CHECKING from urllib.parse import parse_qs, urlsplit import pytest import yaml from bs4 import BeautifulSoup import zensical if TYPE_CHECKING: from pathlib import Path def _write_config( root: Path, options: dict[str, str | bool], *, toml: bool = False ) -> Path: (root / "content").mkdir(exist_ok=True) overrides = root / "overrides" overrides.mkdir(exist_ok=True) (overrides / "main.html").write_text("{{ page.content }}", encoding="utf-8") if toml: config = root / "zensical.toml" content = ( '[project]\nsite_name = "Macros"\ndocs_dir = "content"\n' '[project.theme]\ncustom_dir = "overrides"\n' "[project.plugins.macros]\n" ) content += "\n".join( f"{name} = {json.dumps(value)}" for name, value in options.items() ) else: config = root / "mkdocs.yml" content = yaml.safe_dump( { "site_name": "Macros", "docs_dir": "content", "theme": {"custom_dir": "overrides"}, "plugins": {"macros": options}, } ) config.write_text(content, encoding="utf-8") return config @pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) @pytest.mark.parametrize("target_location", ["docs", "project", "external"]) def test_default_include_directory_builds_symlinked_pages( tmp_path: Path, toml: bool, target_location: str ) -> None: project = tmp_path / "project" project.mkdir() config = _write_config(project, {"on_error_fail": True}, toml=toml) docs = project / "content" target_dir = {"docs": docs, "project": project, "external": tmp_path} target = target_dir[target_location] / "page.md" target.write_text("# Hello\n\nValue: {{ 1 + 1 }}\n", encoding="utf-8") # Two aliases are discovered alongside the target without ending the build. for name in ["index.md", "alias.md"]: try: (docs / name).symlink_to(target) except OSError as error: pytest.skip(f"symbolic links unavailable: {error}") zensical.build(str(config), {"clean": True, "strict": False}) for output in ["index.html", "alias/index.html"]: html = (project / "site" / output).read_text(encoding="utf-8") assert "Value: 2" in html @pytest.mark.parametrize("dependency", ["page", "include"]) def test_serve_rebuilds_after_symlinked_macro_dependency_changes( tmp_path: Path, dependency: str ) -> None: config = _write_config(tmp_path, {"on_error_fail": True}) with config.open("a", encoding="utf-8") as stream: stream.write("dev_addr: 127.0.0.1:0\n") docs = tmp_path / "content" target = tmp_path / ("page.md" if dependency == "page" else "fragment.j2") target.write_text("First value: {{ 1 + 1 }}\n", encoding="utf-8") alias = docs / ("index.md" if dependency == "page" else "fragment.j2") try: alias.symlink_to(target) except OSError as error: pytest.skip(f"symbolic links unavailable: {error}") if dependency == "include": (docs / "index.md").write_text( '{% include "fragment.j2" %}\n', encoding="utf-8" ) output = tmp_path / "site" / "index.html" with (tmp_path / "serve.log").open("w+", encoding="utf-8") as log: process = subprocess.Popen( # noqa: S603 [sys.executable, "-m", "zensical", "serve", "-f", str(config)], cwd=tmp_path, stdout=log, stderr=subprocess.STDOUT, ) def wait_for(text: str) -> None: deadline = time.monotonic() + 10 while time.monotonic() < deadline: try: content = output.read_text(encoding="utf-8") except FileNotFoundError: content = "" if text in content: return if process.poll() is not None: break time.sleep(0.02) log.seek(0) pytest.fail(f"serve did not rebuild: {log.read()}") try: wait_for("First value: 2") # Canonical targets are still watched after alias discovery. target.write_text("Updated value: {{ 1 + 1 }}\n", encoding="utf-8") timestamp = target.stat().st_mtime + 2 os.utime(target, (timestamp, timestamp)) wait_for("Updated value: 2") assert process.poll() is None finally: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) @pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) def test_force_render_paths_uses_docs_paths_and_page_metadata( tmp_path: Path, toml: bool ) -> None: config = _write_config( tmp_path, { "render_by_default": False, "force_render_paths": ( "/guides/\n!guides/drafts/\nguides/drafts/keep.md" ), }, toml=toml, ) pages = { "index.md": (None, False), "guides/index.md": (None, True), "guides/disabled.md": (False, False), "guides/drafts/page.md": (None, False), "guides/drafts/keep.md": (None, True), "outside.md": (True, True), "nested/guides/page.md": (None, False), } for path, (override, _) in pages.items(): page = tmp_path / "content" / path page.parent.mkdir(parents=True, exist_ok=True) header = ( "" if override is None else f"---\nrender_macros: {str(override).lower()}\n---\n" ) page.write_text(header + "Value: {{ 1 + 1 }}\n", encoding="utf-8") zensical.build(str(config), {"clean": True, "strict": False}) for path, (_, rendered) in pages.items(): output = tmp_path / "site" / path.removesuffix(".md") output = ( output.with_suffix(".html") if output.name == "index" else output / "index.html" ) html = output.read_text(encoding="utf-8") assert ("Value: 2" if rendered else "Value: {{ 1 + 1 }}") in html, path def test_force_render_paths_changes_between_builds(tmp_path: Path) -> None: for index, (pattern, rendered) in enumerate( [("", False), ("/index.md", True), ("!index.md", False)] ): config = _write_config( tmp_path, { "render_by_default": False, "force_render_paths": pattern, }, ) if index == 0: (tmp_path / "content" / "index.md").write_text( "Value: {{ 1 + 1 }}\n", encoding="utf-8" ) zensical.build(str(config), {"clean": index == 0, "strict": False}) html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") assert ("Value: 2" if rendered else "Value: {{ 1 + 1 }}") in html def test_verbose_chatter_reaches_cli_and_can_be_disabled( tmp_path: Path, ) -> None: for index, verbose in enumerate([False, True, False]): config = _write_config( tmp_path, {"verbose": verbose, "on_error_fail": True} ) if index == 0: (tmp_path / "content" / "index.md").write_text( '{{ greet("World") }}\n', encoding="utf-8" ) (tmp_path / "main.py").write_text( "def define_env(env):\n" ' chatter = env.start_chatting("Example", color="cyan")\n' ' chatter("Registered macros")\n' " @env.macro\n" " def greet(name):\n" ' chatter("Greeting:", name)\n' ' return "Hello " + name\n', encoding="utf-8", ) result = subprocess.run( # noqa: S603 [sys.executable, "-m", "zensical.main", "build", "-f", str(config)], capture_output=True, text=True, check=True, ) html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") assert "Hello World" in html if verbose: assert "[macros - Example] - Registered macros" in result.stderr assert "[macros - Example] - Greeting: World" in result.stderr assert "Rendering page: index.md" in result.stderr assert "Loading local module:" in result.stderr else: assert "[macros -" not in result.stderr assert "[macros -" not in result.stdout assert "[macros -" not in html @pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) @pytest.mark.parametrize( ("mode", "expected"), [ ("keep", "Value: {{ missing }}"), ("silent", "Value:"), ("strict", "Macro Rendering Error"), ("lax", "Value:"), ], ) def test_undefined_modes_reach_rendered_pages( tmp_path: Path, toml: bool, mode: str, expected: str ) -> None: config = _write_config(tmp_path, {"on_undefined": mode}, toml=toml) (tmp_path / "content" / "index.md").write_text( "Value: {{ missing }}\n", encoding="utf-8" ) zensical.build(str(config), {"clean": True, "strict": False}) html = BeautifulSoup( (tmp_path / "site" / "index.html").read_text(encoding="utf-8"), "html.parser", ) assert expected in html.get_text() if mode == "strict": assert "UndefinedError" in html.get_text() assert "index.md" in html.get_text() else: assert html.get_text(strip=True) == expected @pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) def test_default_includes_and_imports_are_refreshed_between_builds( tmp_path: Path, toml: bool ) -> None: config = _write_config(tmp_path, {"on_error_fail": True}, toml=toml) docs_dir = tmp_path / "content" (docs_dir / "guide").mkdir() (docs_dir / "guide" / "index.md").write_text( '{% include "fragment.j2" %}\n\n{% import "helpers.j2" as helpers %}' '{{ helpers.greet("Ada") }}\n', encoding="utf-8", ) fragment = docs_dir / "fragment.j2" helpers = docs_dir / "helpers.j2" for index, label in enumerate(["First", "Updated"]): fragment.write_text(f"{label} fragment", encoding="utf-8") helpers.write_text( "{% macro greet(name) %}" + label + " greeting {{ name }}{% endmacro %}", encoding="utf-8", ) # File timestamps are advanced without a delay # so cached renders are checked. for template in [fragment, helpers]: mtime = int(template.stat().st_mtime) + index + 1 os.utime(template, (mtime, mtime)) zensical.build(str(config), {"clean": index == 0, "strict": False}) html = (tmp_path / "site" / "guide" / "index.html").read_text( encoding="utf-8" ) assert f"{label} fragment" in html assert f"{label} greeting Ada" in html @pytest.mark.parametrize("on_error_fail", [False, True]) def test_macros_receive_each_pages_title_and_project_context( tmp_path: Path, on_error_fail: bool ) -> None: config = _write_config( tmp_path, {"on_error_fail": on_error_fail}, toml=True ) (tmp_path / "main.py").write_text( dedent("""\ from pathlib import Path def define_env(env): @env.macro def describe(): assert Path(env.project_dir).samefile(env.conf['root_dir']) assert env.variables['page'].title == env.page.title return 'Page: ' + env.page.title """), encoding="utf-8", ) # Metadata, headings, and filename fallbacks are checked in the same build. pages = { "index.md": ("", "Home"), "metadata.md": ( "---\ntitle: Metadata title\n---\n# Heading\n\n", "Metadata title", ), "heading.md": ("# Heading title\n\n", "Heading title"), "file-name.md": ("", "File name"), "templated.md": ( "---\ntitle: Title {{ 2 + 3 }}\n---\n", "Title {{ 2 + 3 }}", ), "templated-heading.md": ( "# Heading {{ 2 + 3 }}\n\n", "Heading {{ 2 + 3 }}", ), } for path, (prefix, _) in pages.items(): (tmp_path / "content" / path).write_text( prefix + "{{ describe() }}\n", encoding="utf-8" ) zensical.build(str(config), {"clean": True, "strict": False}) for path, (_, title) in pages.items(): output = ( tmp_path / "site" / ( "index.html" if path == "index.md" else path.removesuffix(".md") + "/index.html" ) ) assert f"Page: {title}" in output.read_text(encoding="utf-8"), path @pytest.mark.parametrize("first_title", [None, "Get started"]) def test_macros_use_the_first_configured_navigation_title( tmp_path: Path, first_title: str | None ) -> None: config = _write_config(tmp_path, {"on_error_fail": True}) settings = yaml.safe_load(config.read_text(encoding="utf-8")) first_reference = ( "index.md" if first_title is None else {first_title: "index.md"} ) settings["nav"] = [ {"Section": [first_reference]}, {"Duplicate": "index.md"}, ] config.write_text(yaml.safe_dump(settings), encoding="utf-8") (tmp_path / "content" / "index.md").write_text( "---\ntitle: Metadata {{ 2 + 3 }}\n---\n{{ page_title() }}\n", encoding="utf-8", ) (tmp_path / "main.py").write_text( dedent("""\ def define_env(env): @env.macro def page_title(): return env.page.title """), encoding="utf-8", ) zensical.build(str(config), {"clean": True, "strict": False}) # The original title is read during body rendering, # before its own rendering pass. html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") assert f"
{first_title or 'Metadata {{ 2 + 3 }}'}
" in html assert "Duplicate" not in html @pytest.mark.parametrize("title_source", ["metadata", "heading", "navigation"]) def test_title_macros_are_rendered_after_the_body( tmp_path: Path, title_source: str ) -> None: config = _write_config(tmp_path, {"on_error_fail": True}) title = "Title {{ next_number() }}" header = f"---\ntitle: {title}\n---\n" if title_source == "metadata" else "" if title_source == "navigation": settings = yaml.safe_load(config.read_text(encoding="utf-8")) settings["nav"] = [{title: "index.md"}] config.write_text(yaml.safe_dump(settings), encoding="utf-8") (tmp_path / "overrides" / "main.html").write_text( "First
" in html assert "Second
" in html @pytest.mark.parametrize("page_path", ["index.md", "guide.md"]) @pytest.mark.parametrize( ( "metadata_title", "navigation_title", "expected_metadata_title", "expected_navigation_title", ), [ pytest.param( "Metadata {{ next_number() }}", None, "Metadata 2", "Metadata 2", id="metadata", ), pytest.param( "Metadata {{ next_number() }}", "Short", "Metadata 2", "Short", id="separate-navigation-label", ), pytest.param( "Metadata {{ next_number() }}", "Navigation {{ next_number() }}", "Metadata 3", "Navigation 2", id="both-templated", ), pytest.param( "Metadata {{ next_number() }}", "Metadata {{ next_number() }}", "Metadata 2", "Metadata 2", id="same-template", ), pytest.param( "Long metadata title", "Short", "Long metadata title", "Short", id="plain-metadata", ), pytest.param( "Long metadata title", "Navigation {{ next_number() }}", "Long metadata title", "Navigation 2", id="plain-metadata-templated-navigation", ), ], ) def test_theme_uses_rendered_metadata_titles( tmp_path: Path, page_path: str, metadata_title: str, navigation_title: str | None, expected_metadata_title: str, expected_navigation_title: str, ) -> None: config = _write_config( tmp_path, {"on_error_fail": True, "on_undefined": "strict"} ) settings = yaml.safe_load(config.read_text(encoding="utf-8")) # The bundled theme is used so browser titles, # headers, and feedback are covered. settings["theme"].pop("custom_dir") if navigation_title is not None: settings["nav"] = [{navigation_title: page_path}] settings["extra"] = { "analytics": { "feedback": { "title": "Was this helpful?", "ratings": [ { "icon": "material/thumb-up", "name": "Yes", "data": 1, "note": 'Send feedback', } ], } } } config.write_text(yaml.safe_dump(settings), encoding="utf-8") (tmp_path / "content" / page_path).write_text( "---\n" + yaml.safe_dump({"title": metadata_title}) + "---\n# Heading\n\nBody: {{ next_number() }}\n", encoding="utf-8", ) (tmp_path / "main.py").write_text( dedent("""\ def define_env(env): calls = 0 original_title = env.page.meta['title'] @env.macro def next_number(): nonlocal calls assert env.page.meta['title'] == original_title calls += 1 return calls """), encoding="utf-8", ) # The rendered metadata title is also retained # when cached Markdown is reused. for clean in (True, False): zensical.build(str(config), {"clean": clean, "strict": False}) output = "index.html" if page_path == "index.md" else "guide/index.html" html = BeautifulSoup( (tmp_path / "site" / output).read_text(encoding="utf-8"), "html.parser", ) browser_title = html.select_one("title") header_title = html.select_one('[data-md-component="header-topic"]') feedback_link = html.select_one(".md-feedback__note a") assert browser_title is not None assert header_title is not None assert feedback_link is not None assert ( browser_title.get_text(strip=True) == expected_metadata_title + " - Macros" ) assert header_title.get_text(strip=True) == expected_metadata_title assert parse_qs(urlsplit(str(feedback_link["href"])).query) == { "title": [expected_metadata_title] } assert expected_navigation_title in [ link.get_text(" ", strip=True) for link in html.select("a.md-nav__link") ] assert "Body: 1" in html.get_text() def test_extra_title_does_not_replace_the_page_title(tmp_path: Path) -> None: config = _write_config(tmp_path, {"on_error_fail": True}) settings = yaml.safe_load(config.read_text(encoding="utf-8")) settings["extra"] = {"title": "Generated {{ 2 + 3 }}"} config.write_text(yaml.safe_dump(settings), encoding="utf-8") (tmp_path / "content" / "index.md").write_text( "# Heading\n\n{{ page_title() }}\n", encoding="utf-8" ) (tmp_path / "main.py").write_text( dedent("""\ def define_env(env): @env.macro def page_title(): return env.page.title """), encoding="utf-8", ) zensical.build(str(config), {"clean": True, "strict": False}) # An extra variable named title is not used as page metadata # by MkDocs Macros. html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") assert "Heading
" in html def test_macro_title_does_not_use_nav_replaced_by_literate_nav( tmp_path: Path, ) -> None: config = _write_config(tmp_path, {"on_error_fail": True}) settings = yaml.safe_load(config.read_text(encoding="utf-8")) settings["nav"] = [{"Discarded title": "index.md"}] settings["plugins"]["literate-nav"] = {} config.write_text(yaml.safe_dump(settings), encoding="utf-8") (tmp_path / "content" / "SUMMARY.md").write_text( "- [Generated title](index.md)\n", encoding="utf-8" ) (tmp_path / "content" / "index.md").write_text( "---\ntitle: Source title\n---\n{{ page_title() }}\n", encoding="utf-8" ) (tmp_path / "main.py").write_text( dedent("""\ def define_env(env): @env.macro def page_title(): return env.page.title """), encoding="utf-8", ) zensical.build(str(config), {"clean": True, "strict": False}) # Generated navigation is resolved later; # discarded labels must not be exposed. html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") assert "Source title
" in html assert "Discarded title" not in html