diff --git a/python/tests/unit/extensions/test_macros.py b/python/tests/unit/extensions/test_macros.py index c063fcc..b5b7f1d 100644 --- a/python/tests/unit/extensions/test_macros.py +++ b/python/tests/unit/extensions/test_macros.py @@ -23,12 +23,17 @@ from __future__ import annotations +import os from io import StringIO from typing import TYPE_CHECKING import pandas import pytest -from jinja2.exceptions import TemplateSyntaxError, UndefinedError +from jinja2.exceptions import ( + TemplateNotFound, + TemplateSyntaxError, + UndefinedError, +) from tests.unit.extensions.conftest import soup from zensical.extensions.context import ContextPreprocessor @@ -52,6 +57,18 @@ if TYPE_CHECKING: from pandas import DataFrame +_INCLUDE_CONFIG = { + "config": { + "markdown_extensions": { + "zensical.extensions.macros": { + "include_dir": "snippets", + "on_error_fail": True, + }, + }, + }, +} + + # --------------------------------------------------------------------------- # Filters # --------------------------------------------------------------------------- @@ -354,6 +371,47 @@ class TestPreprocessor: assert "Hello Ada!" in text assert "world" in text + @pytest.mark.parametrize( + "md", + [ + pytest.param( + { + "config": { + "markdown_extensions": { + "zensical.extensions.macros": { + "include_dir": "snippets", + "on_error_fail": True, + }, + }, + }, + }, + id="nested_include", + ), + ], + indirect=["md"], + ) + def test_renders_nested_include( + self, + md: Markdown, + tmp_path: Path, + ) -> None: + include_dir = tmp_path / "snippets" / "sub" + include_dir.mkdir(parents=True) + include_dir.joinpath("fragment.md").write_text( + "Included fragment text.", encoding="utf-8" + ) + + context = ContextPreprocessor.from_markdown(md) + assert context is not None + if os.name == "nt": + # Rust canonicalizes the configuration path before parsing it. + root = str(tmp_path) + if not root.startswith("\\\\?\\"): + context.config["root_dir"] = "\\\\?\\" + root + + html = soup(md.convert('{% include "sub/fragment.md" %}')) + assert html.get_text() == "Included fragment text." + @pytest.mark.parametrize( "md", [ @@ -494,6 +552,68 @@ class TestPreprocessor: assert code.select("span") +@pytest.mark.parametrize( + "md", + [pytest.param(_INCLUDE_CONFIG, id="include_dir")], + indirect=["md"], +) +class TestIncludeLoader: + def test_renders_nested_include_and_refreshes_changed_source( + self, + md: Markdown, + tmp_path: Path, + ) -> None: + include_dir = tmp_path / "snippets" / "sub" + include_dir.mkdir(parents=True) + fragment = include_dir / "fragment.md" + fragment.write_text("First fragment.", encoding="utf-8") + + template = '{% include "sub/fragment.md" %}' + assert soup(md.convert(template)).get_text() == "First fragment." + + fragment.write_text("Updated fragment.", encoding="utf-8") + assert soup(md.convert(template)).get_text() == "Updated fragment." + + def test_missing_include_raises_template_not_found( + self, + md: Markdown, + tmp_path: Path, + ) -> None: + (tmp_path / "snippets").mkdir() + + with pytest.raises(TemplateNotFound) as error: + md.convert('{% include "missing.md" %}') + + assert error.value.name == "missing.md" + + def test_parent_traversal_is_rejected( + self, + md: Markdown, + tmp_path: Path, + ) -> None: + (tmp_path / "snippets").mkdir() + (tmp_path / "outside.md").write_text("Outside.", encoding="utf-8") + + with pytest.raises(TemplateNotFound): + md.convert('{% include "../outside.md" %}') + + def test_template_error_reports_included_source( + self, + md: Markdown, + tmp_path: Path, + ) -> None: + include_dir = tmp_path / "snippets" + include_dir.mkdir() + fragment = include_dir / "invalid.md" + fragment.write_text("{% if %}", encoding="utf-8") + + with pytest.raises(TemplateSyntaxError) as error: + md.convert('{% include "invalid.md" %}') + + assert error.value.filename is not None + assert os.path.samefile(error.value.filename, fragment) + + # --------------------------------------------------------------------------- # Table helpers # --------------------------------------------------------------------------- diff --git a/python/zensical/extensions/macros.py b/python/zensical/extensions/macros.py index 597cbf2..f969277 100644 --- a/python/zensical/extensions/macros.py +++ b/python/zensical/extensions/macros.py @@ -41,6 +41,7 @@ from urllib.parse import urlparse import jinja2 import yaml from jinja2.exceptions import UndefinedError +from jinja2.loaders import split_template_path from markdown import Extension from markdown.preprocessors import Preprocessor @@ -300,7 +301,7 @@ class MacrosPreprocessor(Preprocessor): include_dir_path := project_root / self.config.include_dir ).exists() ): - env_kw["loader"] = jinja2.FileSystemLoader(include_dir_path) + env_kw["loader"] = _make_include_loader(include_dir_path) env = jinja2.Environment(**env_kw) # noqa: S701 @@ -407,6 +408,38 @@ def makeExtension(**kwargs: Any) -> MacrosExtension: # ----------------------------------------------------------------------------- +def _make_include_loader(root: Path) -> jinja2.FunctionLoader: + """Create a loader that joins template names with native separators. + + Jinja joins filesystem search paths with POSIX separators. Windows + extended-length paths require backslashes, so resolve each safe template + component with `Path` instead. + """ + + def load( + template: str, + ) -> tuple[str, str, Callable[[], bool]] | None: + path = root.joinpath(*split_template_path(template)) + try: + source = path.read_text(encoding="utf-8") + mtime = path.stat().st_mtime + except OSError: + return None + + def uptodate() -> bool: + try: + return path.stat().st_mtime == mtime + except OSError: + return False + + return source, str(path), uptodate + + return jinja2.FunctionLoader(load) + + +# ----------------------------------------------------------------------------- + + def _now() -> datetime: """Return current datetime (`datetime.now()`).""" return datetime.now() # noqa: DTZ005