# 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. from __future__ import annotations import subprocess import sys import time from typing import TYPE_CHECKING, Any import pytest import yaml from markdown import Markdown import zensical if TYPE_CHECKING: from pathlib import Path def _write_config( root: Path, options: dict[str, Any], configuration: str, **settings: Any ) -> Path: """Configure includes through the plugin or the Markdown extension.""" project = {"site_name": "Test", **settings} if configuration == "plugin": project["plugins"] = {"include-markdown": options} else: project["markdown_extensions"] = { "zensical.extensions.include_markdown": options } if configuration == "override": project["plugins"] = { "include-markdown": { "opening_tag": "{!", "closing_tag": "!}", "directives": {"include": "other"}, } } config = root / "mkdocs.yml" config.write_text(yaml.safe_dump(project), encoding="utf-8") return config @pytest.fixture( params=[ {}, { "opening_tag": "[(", "closing_tag": ")]", "directives": { "include": "read+file", "include-markdown": "read.md", }, }, ], ids=["default", "custom"], ) def syntax(request: pytest.FixtureRequest) -> dict[str, Any]: return request.param @pytest.mark.parametrize("configuration", ["plugin", "extension", "override"]) def test_rebuilds_included_content_and_caches_other_pages( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, syntax: dict[str, Any], configuration: str, ) -> None: docs = tmp_path / "docs" docs.mkdir() opening = syntax.get("opening_tag", "{%") closing = syntax.get("closing_tag", "%}") directive = syntax.get("directives", {}).get( "include-markdown", "include-markdown" ) (docs / "index.md").write_text( f'# Home\n\n{opening} {directive} "../shared.txt" {closing}\n', encoding="utf-8", ) ordinary_markdown = "# Ordinary page\n\nContent without includes." if syntax: # The default syntax and similar names are inactive with custom tags. ordinary_markdown += ( '\n\n{% include-markdown "unused.md" %}' '\n\n[( readXmd "unused.md" )]' ) (docs / "ordinary.md").write_text(ordinary_markdown, encoding="utf-8") included = tmp_path / "shared.txt" included.write_text("First **inclusion**", encoding="utf-8") config = _write_config(tmp_path, syntax, configuration) ordinary_renders = [] convert = Markdown.convert def record_render(md: Markdown, source: str) -> str: if source.strip() == ordinary_markdown: ordinary_renders.append(source) return convert(md, source) monkeypatch.setattr(Markdown, "convert", record_render) zensical.build(str(config), {"clean": False, "strict": False}) output = tmp_path / "site" / "index.html" assert "First inclusion" in output.read_text( encoding="utf-8" ) assert len(ordinary_renders) == 1 # Only the included file is changed; the page and configuration are kept. included.write_text("Updated **inclusion**", encoding="utf-8") zensical.build(str(config), {"clean": False, "strict": False}) html = output.read_text(encoding="utf-8") assert "Updated inclusion" in html assert "First inclusion" not in html assert len(ordinary_renders) == 1 def test_serve_rebuilds_multiline_include_tags( tmp_path: Path, syntax: dict[str, Any] ) -> None: docs = tmp_path / "docs" docs.mkdir() included = docs / "shared.txt" included.write_text("First inclusion", encoding="utf-8") opening = syntax.get("opening_tag", "{%") closing = syntax.get("closing_tag", "%}") directive = syntax.get("directives", {}).get("include", "include") (docs / "index.md").write_text( f'{opening}\n {directive}\n "shared.txt"\n{closing}', encoding="utf-8" ) config = _write_config(tmp_path, syntax, "plugin", dev_addr="127.0.0.1:0") 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", "--config-file", str(config), ], cwd=tmp_path, stdout=log, stderr=subprocess.STDOUT, ) def wait_for_content(expected: str) -> None: deadline = time.monotonic() + 10 while time.monotonic() < deadline and process.poll() is None: if output.exists() and expected in output.read_text( encoding="utf-8" ): return time.sleep(0.02) log.seek(0) raise AssertionError( f"Included content was not rebuilt: {log.read()}" ) try: wait_for_content("First inclusion") # A source change must refresh the retained including page. included.write_text("Updated inclusion", encoding="utf-8") wait_for_content("Updated inclusion") finally: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) @pytest.mark.parametrize("configuration", ["plugin", "extension"]) def test_disabled_includes_keep_pages_cached( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, configuration: str ) -> None: docs = tmp_path / "docs" docs.mkdir() source = '{% include-markdown "missing.md" %}' (docs / "index.md").write_text(source, encoding="utf-8") config = _write_config(tmp_path, {"enabled": False}, configuration) rendered = [] convert = Markdown.convert def record_render(md: Markdown, content: str) -> str: if content.strip() == source: rendered.append(content) return convert(md, content) monkeypatch.setattr(Markdown, "convert", record_render) zensical.build(str(config), {"clean": False, "strict": False}) zensical.build(str(config), {"clean": False, "strict": False}) assert len(rendered) == 1 assert source in (tmp_path / "site" / "index.html").read_text( encoding="utf-8" ) def test_converted_site_matches_plain_snippets( tmp_path: Path, syntax: dict[str, Any] ) -> None: docs = tmp_path / "docs" shared = docs / "shared" shared.mkdir(parents=True) opening = syntax.get("opening_tag", "{%") closing = syntax.get("closing_tag", "%}") directive = syntax.get("directives", {}).get("include", "include") index = docs / "index.md" outer = shared / "outer.txt" inner = shared / "inner.txt" index.write_text( f"# Home\n\n{{{{ 1 + 2 }}}}\n\n {opening} {directive} " f'"./shared/outer.txt" {closing}', encoding="utf-8", ) outer.write_text( f' {opening} {directive} "./inner.txt" {closing}', encoding="utf-8" ) inner.write_text( " Included **content**\n\n {{ 2 + 3 }}\n", encoding="utf-8" ) options = {**syntax, "dedent": True, "preserve_includer_indent": False} config = _write_config( tmp_path, options, "plugin", markdown_extensions=["zensical.extensions.macros"], ) zensical.build(str(config), {"clean": False, "strict": False}) output = tmp_path / "site" / "index.html" included_html = output.read_text(encoding="utf-8") assert "Included content" in included_html assert "{{ 2 + 3 }}" in included_html # Every tag, nested path, and mapped setting is converted before removal. index.write_text( '# Home\n\n{{ 1 + 2 }}\n\n--8<-- "shared/outer.txt:1"', encoding="utf-8" ) outer.write_text('--8<-- "shared/inner.txt:1"', encoding="utf-8") project = { "site_name": "Test", "markdown_extensions": { "zensical.extensions.macros": {}, "pymdownx.snippets": { "base_path": [str(docs)], "restrict_base_path": False, "check_paths": True, "url_download": True, "dedent_subsections": True, }, }, } config.write_text(yaml.safe_dump(project), encoding="utf-8") zensical.build(str(config), {"clean": False, "strict": False}) assert output.read_text(encoding="utf-8") == included_html