# 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 from typing import TYPE_CHECKING, Any import pytest import yaml from markdown import Markdown from pymdownx.snippets import SnippetMissingError, SnippetPreprocessor import zensical.config as config_module from zensical.config import ConfigurationError, parse_config from zensical.extensions.context import ContextExtension, Page from zensical.extensions.include_markdown import IncludeMarkdownExtension if TYPE_CHECKING: from pathlib import Path def _markdown( root: Path, options: dict[str, Any] | None = None, snippets: dict[str, Any] | None = None, *, page: str = "guides/index.md", ) -> Markdown: """Configure the plugin and page context for a temporary project.""" config = { "root_dir": str(root), "docs_dir": "docs", "markdown_extensions": ["pymdownx.snippets", "pymdownx.superfences"], "mdx_configs": {"pymdownx.snippets": snippets or {}}, "plugins": {"include-markdown": {"config": options or {}}}, } config_module._shim_include_markdown(config) return Markdown( extensions=[ ContextExtension( config=config, page=Page(path=page, url="guides/") ), *config["markdown_extensions"], ], extension_configs=config["mdx_configs"], ) def _native_markdown( root: Path, settings: dict[str, Any] | None = None ) -> Markdown: """Configure plain Snippets without the include-markdown adapter.""" snippets = { "base_path": [str(root / "docs")], "restrict_base_path": False, "check_paths": True, "url_download": True, **(settings or {}), } return Markdown( extensions=["pymdownx.snippets", "pymdownx.superfences"], extension_configs={"pymdownx.snippets": snippets}, ) @pytest.mark.parametrize("directive", ["include", "include-markdown"]) @pytest.mark.parametrize( "filename", ["shared.md", "shared file.md", "shared.md:section", "both'\"quotes.md"], ) @pytest.mark.parametrize( ("options", "settings", "include_indent", "snippet_indent"), [ ({}, {}, "", ""), ({}, {}, " ", " "), ({"preserve_includer_indent": False}, {}, " ", ""), ({"dedent": True}, {"dedent_subsections": True}, " ", " "), ], ) def test_include_tags_match_plain_snippets( tmp_path: Path, directive: str, filename: str, options: dict[str, Any], settings: dict[str, Any], include_indent: str, snippet_indent: str, ) -> None: # Paths, indentation, and mapped settings must survive # conversion to Snippets. (tmp_path / "docs").mkdir() (tmp_path / "docs" / filename).write_text( " Included\n content\n\n", encoding="utf-8" ) md = _markdown(tmp_path, options) native = _native_markdown(tmp_path, settings) escaped = filename.replace('"', '\\"') include_source = f'{include_indent}{{% {directive} "{escaped}" %}}' snippet_source = f"--8<--\n{snippet_indent}{filename}:1\n--8<--" included_lines = md.preprocessors["snippet"].run(include_source.split("\n")) snippet_lines = native.preprocessors["snippet"].run( snippet_source.split("\n") ) # Raw lines are compared so whitespace differences cannot be hidden by HTML. assert included_lines == snippet_lines assert md.convert(include_source) == native.convert(snippet_source) @pytest.mark.parametrize( "options", [ 'start="BEGIN" end="END"', 'start="missing"', 'end="missing"', 'start="" end=""', r'start=""', "preserve-includer-indent=false dedent=true", "encoding=ascii trailing-newlines=false recursive=false", 'exclude="*.md" order="name"', "rewrite-relative-urls=true comments=true heading-offset=2", ], ) def test_unsupported_options_match_plain_whole_file_snippets( tmp_path: Path, options: str ) -> None: (tmp_path / "docs").mkdir() content = "# Heading\n\nbeforeBEGINcaféENDafter\n\n[Link](./target.md)\n\n" (tmp_path / "docs" / "shared.md").write_text(content, encoding="utf-8") md = _markdown(tmp_path) native = _native_markdown(tmp_path) include_source = f' {{% include "shared.md" {options} %}}' snippet_source = "--8<--\n shared.md:1\n--8<--" included_lines = md.preprocessors["snippet"].run(include_source.split("\n")) snippet_lines = native.preprocessors["snippet"].run( snippet_source.split("\n") ) # Unsupported arguments cannot introduce behavior beyond native Snippets. assert included_lines == snippet_lines assert md.convert(include_source) == native.convert(snippet_source) @pytest.mark.parametrize("directive", ["include", "include-markdown"]) @pytest.mark.parametrize("quote", ["'", '"']) def test_includes_docs_relative_paths( tmp_path: Path, directive: str, quote: str ) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "shared file.md").write_text( "**Included**", encoding="utf-8" ) md = _markdown(tmp_path) html = md.convert(f"{{% {directive} {quote}shared file.md{quote} %}}") assert html == "

Included

" def test_nested_includes_resolve_from_each_source(tmp_path: Path) -> None: (tmp_path / "docs" / "guides").mkdir(parents=True) (tmp_path / "docs" / "shared").mkdir() (tmp_path / "docs" / "shared" / "outer.md").write_text( '{% include-markdown "./inner.md" %}', encoding="utf-8" ) (tmp_path / "docs" / "shared" / "inner.md").write_text( "Nested **content**", encoding="utf-8" ) md = _markdown(tmp_path) html = md.convert('{% include-markdown "../shared/outer.md" %}') assert html == "

Nested content

" # Nested paths must be converted relative to Snippets' configured base path. (tmp_path / "docs" / "shared" / "outer.md").write_text( '--8<-- "shared/inner.md:1"', encoding="utf-8" ) native = _native_markdown(tmp_path) assert html == native.convert('--8<-- "shared/outer.md:1"') def test_repeated_and_circular_includes(tmp_path: Path) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "shared.md").write_text( 'Content\n\n{% include "./shared.md" %}', encoding="utf-8" ) md = _markdown(tmp_path) html = md.convert('{% include "shared.md" %}\n\n{% include "shared.md" %}') assert html.count("Content") == 2 assert "{%" not in html # The same recursion guard must apply after every tag has been converted. (tmp_path / "docs" / "shared.md").write_text( 'Content\n\n--8<-- "shared.md:1"', encoding="utf-8" ) native = _native_markdown(tmp_path) assert html == native.convert( '--8<-- "shared.md:1"\n\n--8<-- "shared.md:1"' ) def test_multiline_tags_and_per_inclusion_options(tmp_path: Path) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "shared.md").write_text("# Heading", encoding="utf-8") md = _markdown(tmp_path) html = md.convert( '{%\n include-markdown "shared.md"' '\n heading-offset=2\n start="%} ignored"\n%}' ) assert html == "

Heading

" def test_trailing_whitespace_does_not_break_generated_snippets( tmp_path: Path, ) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "shared.md").write_text("Included", encoding="utf-8") md = _markdown(tmp_path) html = md.convert('{% include "shared.md" %} \t\n\nAfter') assert html == "

Included

\n

After

" def test_quoted_absolute_paths_and_global_encoding(tmp_path: Path) -> None: (tmp_path / "docs").mkdir() included = tmp_path / "both'\"quotes.txt" included.write_text("café", encoding="latin-1") md = _markdown(tmp_path, {"encoding": "latin-1"}) escaped = str(included).replace('"', '\\"') html = md.convert(f'{{% include "{escaped}" %}}') assert html == "

café

" native = _native_markdown(tmp_path, {"encoding": "latin-1"}) assert html == native.convert(f"--8<--\n{included}:1\n--8<--") def test_macros_and_file_expansion_match_plain_snippets(tmp_path: Path) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "shared.md").write_text( "{{ 2 + 3 }}", encoding="utf-8" ) md = _markdown(tmp_path) md.registerExtensions(["zensical.extensions.macros"], {}) native = _native_markdown(tmp_path) native.registerExtensions(["zensical.extensions.macros"], {}) html = md.convert('{{ 1 + 2 }}\n\n{% include "shared.md" %}') native_html = native.convert('{{ 1 + 2 }}\n\n--8<-- "shared.md:1"') # Macros are processed before file expansion, as with native Snippets. assert html == native_html == "

3

\n

{{ 2 + 3 }}

" @pytest.mark.parametrize("preserve", [True, False]) def test_global_indentation_and_dedenting( tmp_path: Path, preserve: bool ) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "shared.md").write_text( " included\n content", encoding="utf-8" ) md = _markdown( tmp_path, {"dedent": True, "preserve_includer_indent": preserve} ) html = md.convert(' {% include "shared.md" %}') assert ("
" in html) is preserve
    assert "included\ncontent" in html


def test_explicit_default_delimiters_and_directives(tmp_path: Path) -> None:
    (tmp_path / "docs").mkdir()
    (tmp_path / "docs" / "shared.md").write_text("Included", encoding="utf-8")
    options = {
        "opening_tag": "{%",
        "closing_tag": "%}",
        "directives": {
            "include": "include",
            "include-markdown": "include-markdown",
        },
    }
    config = {"extra": {"polyfills": []}, "root_dir": str(tmp_path)}
    plugin = config_module._convert_plugins(
        {"include-markdown": options}, config
    )["include-markdown"]["config"]
    md = _markdown(tmp_path, plugin)

    html = md.convert('{% include-markdown "shared.md" %}')

    assert html == "

Included

" @pytest.mark.parametrize( "options", [ {"opening_tag": "{!"}, {"closing_tag": "!}"}, {"directives": {"include": "replace"}}, {"directives": {"include-markdown": "include-md"}}, { "opening_tag": "[(", "closing_tag": ")]", "directives": { "include": "read+file", "include-markdown": "read.md", }, }, ], ) def test_custom_syntax_in_plugin_and_extension( tmp_path: Path, options: dict[str, Any] ) -> None: docs = tmp_path / "docs" docs.mkdir() included = docs / "shared.md" included.write_text("Included", encoding="utf-8") config = {"extra": {"polyfills": []}, "root_dir": str(tmp_path)} plugin = config_module._convert_plugins( {"include-markdown": options}, config )["include-markdown"]["config"] opening = options.get("opening_tag", "{%") closing = options.get("closing_tag", "%}") names = { "include": "include", "include-markdown": "include-markdown", **options.get("directives", {}), } md = _markdown(tmp_path, plugin) for directive in names.values(): assert ( md.convert(f'{opening}\n {directive} "shared.md"\n{closing}') == "

Included

" ) # The same options are also accepted without a plugin configuration. md = Markdown( extensions=[IncludeMarkdownExtension.name], extension_configs={IncludeMarkdownExtension.name: options}, ) assert ( md.convert(f'{opening} {names["include"]} "{included}" {closing}') == "

Included

" ) @pytest.mark.parametrize( "options", [{"opening_tag": ""}, {"closing_tag": ""}, {"directives": {"include": ""}}], ) def test_rejects_empty_syntax_in_direct_extension_configuration( tmp_path: Path, options: dict[str, Any] ) -> None: (tmp_path / "docs").mkdir() config_file = tmp_path / "mkdocs.yml" config_file.write_text( yaml.safe_dump( { "site_name": "Test", "markdown_extensions": {IncludeMarkdownExtension.name: options}, } ), encoding="utf-8", ) with pytest.raises(ConfigurationError, match="include-markdown"): parse_config(str(config_file)) def test_includes_inside_fenced_code(tmp_path: Path) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "example.txt").write_text("", encoding="utf-8") md = _markdown(tmp_path) html = md.convert('```text\n{% include "example.txt" %}\n```') assert "<sample>" in html assert "{%" not in html def test_native_snippets_and_auto_append_run_once(tmp_path: Path) -> None: docs = tmp_path / "docs" docs.mkdir() (docs / "shared.md").write_text("Included", encoding="utf-8") (docs / "footer.md").write_text("Footer", encoding="utf-8") md = _markdown(tmp_path, snippets={"auto_append": ["footer.md"]}) html = md.convert('--8<-- "shared.md"\n\n;--8<-- "escaped.md"') assert html.count("Included") == 1 assert html.count("Footer") == 1 assert '"escaped.md"' in html native = _native_markdown(tmp_path, {"auto_append": ["footer.md"]}) assert html == native.convert('--8<-- "shared.md"\n\n;--8<-- "escaped.md"') def test_remote_nested_includes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: downloads = [] outer = '{% include "./inner.md" %}' def download(_self: SnippetPreprocessor, url: str) -> list[str]: downloads.append(url) return [outer] if url.endswith("outer.md") else ["Remote content"] # Network access is replaced after Snippets initializes its download cache. md = _markdown(tmp_path) native = _native_markdown(tmp_path) monkeypatch.setattr(SnippetPreprocessor, "download", download) html = md.convert('{% include "https://example.com/docs/outer.md" %}') assert html == "

Remote content

" assert downloads == [ "https://example.com/docs/outer.md", "https://example.com/docs/inner.md", ] # Native Snippets requires complete URLs in downloaded snippets. outer = '--8<-- "https://example.com/docs/inner.md:1"' downloads.clear() native_html = native.convert('--8<-- "https://example.com/docs/outer.md:1"') assert html == native_html assert downloads == [ "https://example.com/docs/outer.md", "https://example.com/docs/inner.md", ] def test_explicit_snippets_dedenting_survives_adapter_removal( tmp_path: Path, ) -> None: (tmp_path / "docs").mkdir() (tmp_path / "docs" / "shared.md").write_text( " Included\n content", encoding="utf-8" ) md = _markdown( tmp_path, {"dedent": True}, snippets={"dedent_subsections": False} ) native = _native_markdown(tmp_path, {"dedent_subsections": False}) html = md.convert('{% include "shared.md" %}') native_html = native.convert('--8<-- "shared.md:1"') # Explicit native settings take precedence over mapped plugin defaults. assert html == native_html assert "
" in html


@pytest.mark.parametrize("selection", ["2:3", "named"])
def test_native_line_ranges_and_sections_remain_unchanged(
    tmp_path: Path, selection: str
) -> None:
    (tmp_path / "docs").mkdir()
    (tmp_path / "docs" / "shared.md").write_text(
        "Before\n--8<-- [start:named]\nSelected\n--8<-- [end:named]\nAfter",
        encoding="utf-8",
    )
    md = _markdown(tmp_path)
    native = _native_markdown(tmp_path)
    source = f'--8<-- "shared.md:{selection}"'

    html = md.convert(source)
    native_html = native.convert(source)

    assert html == native_html == "

Selected

" def test_missing_files_fail(tmp_path: Path) -> None: md = _markdown(tmp_path) with pytest.raises(SnippetMissingError): md.convert('{% include "missing.md" %}') def test_remote_includes_can_be_disabled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: md = _markdown(tmp_path, {"include_from_url": False}) def download(_self: SnippetPreprocessor, _url: str) -> list[str]: pytest.fail("Downloads must remain disabled") monkeypatch.setattr(SnippetPreprocessor, "download", download) with pytest.raises(SnippetMissingError): md.convert('{% include "https://example.com/file.md" %}') @pytest.mark.parametrize( "tag", [ "{% include filename %}", '{% include "file.md" unknown=true %}', 'Before {% include "file.md" %} after', ], ) def test_invalid_or_inline_tags_fail(tmp_path: Path, tag: str) -> None: md = _markdown(tmp_path) with pytest.raises(ValueError, match="include-markdown"): md.convert(tag) def test_explicit_snippets_settings_take_precedence(tmp_path: Path) -> None: (tmp_path / "docs").mkdir() (tmp_path / "outside.md").write_text("Outside", encoding="utf-8") md = _markdown(tmp_path, snippets={"restrict_base_path": True}) with pytest.raises(SnippetMissingError): md.convert('{% include "../../outside.md" %}') @pytest.mark.parametrize("config_format", ["toml", "yaml"]) def test_plugin_shim_from_project_configuration( tmp_path: Path, config_format: str ) -> None: (tmp_path / "docs").mkdir() if config_format == "toml": config_file = tmp_path / "zensical.toml" config_file.write_text( '[project]\nsite_name = "Test"\n' '[project.plugins.include-markdown]\nencoding = "ascii"\n', encoding="utf-8", ) else: config_file = tmp_path / "mkdocs.yml" config_file.write_text( "site_name: Test\nplugins:\n - include-markdown:" "\n encoding: ascii\n", encoding="utf-8", ) config = parse_config(str(config_file)) assert IncludeMarkdownExtension.name in config["markdown_extensions"] assert config["mdx_configs"]["pymdownx.snippets"]["encoding"] == "ascii" def test_disabled_plugin_leaves_tags_untouched(tmp_path: Path) -> None: md = _markdown(tmp_path, {"enabled": False}) html = md.convert('{% include "missing.md" %}') assert '{% include "missing.md" %}' in html @pytest.mark.parametrize( ("option", "value"), [ ("enabled", "yes"), ("dedent", 1), ("include_from_url", "true"), ("preserve_includer_indent", None), ("encoding", 42), ("opening_tag", ""), ("closing_tag", []), ("directives", []), ("directives", {"unknown": "name"}), ("directives", {"include": ""}), ("directives", {"include": "two words"}), ], ) def test_rejects_invalid_configuration(option: str, value: Any) -> None: with pytest.raises(ConfigurationError, match="include-markdown"): config_module._convert_plugins( {"include-markdown": {option: value}}, {"extra": {"polyfills": []}, "root_dir": "."}, ) @pytest.mark.parametrize( "option", [ "trailing_newlines", "comments", "rewrite_relative_urls", "heading_offset", "order", "start", "end", "exclude", "cache", "cache_dir", "recursive", ], ) def test_unsupported_global_options_are_discarded(option: str) -> None: config = {"extra": {"polyfills": []}, "root_dir": "."} plugins = config_module._convert_plugins( {"include-markdown": {option: object()}}, config ) assert plugins["include-markdown"]["config"] == {}