mirror of
https://github.com/zensical/zensical.git
synced 2026-10-16 01:19:19 +00:00
feature: support include-markdown plugin
Signed-off-by: Timothée Mazzucotelli <dev@pawamoy.fr>
This commit is contained in:
1 parent
cf88075b90
commit
db5f22e7bf
8 files changed
+1497
-28
No files matched your search
+289
@@ -0,0 +1,289 @@
|
||||
# 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 <strong>inclusion</strong>" 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 <strong>inclusion</strong>" in html
|
||||
assert "First <strong>inclusion</strong>" 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 <strong>content</strong>" 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
|
||||
@@ -0,0 +1,637 @@
|
||||
# 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="<!--\n\tdébut 雪\n-->"',
|
||||
"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 == "<p><strong>Included</strong></p>"
|
||||
|
||||
|
||||
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 == "<p>Nested <strong>content</strong></p>"
|
||||
|
||||
# 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 == "<h1>Heading</h1>"
|
||||
|
||||
|
||||
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 == "<p>Included</p>\n<p>After</p>"
|
||||
|
||||
|
||||
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 == "<p>café</p>"
|
||||
|
||||
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 == "<p>3</p>\n<p>{{ 2 + 3 }}</p>"
|
||||
|
||||
|
||||
@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 ("<pre><code>" 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 == "<p>Included</p>"
|
||||
|
||||
|
||||
@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}')
|
||||
== "<p>Included</p>"
|
||||
)
|
||||
|
||||
# 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}')
|
||||
== "<p>Included</p>"
|
||||
)
|
||||
|
||||
|
||||
@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("<sample>", 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 == "<p>Remote content</p>"
|
||||
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 "<pre><code>" 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 == "<p>Selected</p>"
|
||||
|
||||
|
||||
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"] == {}
|
||||
+2
@@ -52,6 +52,7 @@ PYTHON_PLUGINS = (
|
||||
"macros",
|
||||
"markdownextradata",
|
||||
"table-reader",
|
||||
"include-markdown",
|
||||
)
|
||||
|
||||
SHIM_PLUGINS = (
|
||||
@@ -64,6 +65,7 @@ SHIM_PLUGINS = (
|
||||
"macros",
|
||||
"markdownextradata",
|
||||
"table-reader",
|
||||
"include-markdown",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ from zensical.compat.mkdocstrings import _python_handler_has_backlinks
|
||||
from zensical.extensions.autorefs import AutorefsExtension
|
||||
from zensical.extensions.emoji import to_svg, twemoji
|
||||
from zensical.extensions.glightbox import GlightboxExtension
|
||||
from zensical.extensions.include_markdown import (
|
||||
IncludeMarkdownConfig,
|
||||
IncludeMarkdownExtension,
|
||||
)
|
||||
from zensical.extensions.macros import MacrosExtension
|
||||
from zensical.extensions.markdownextradata import (
|
||||
MarkdownExtraDataExtension,
|
||||
@@ -145,6 +149,19 @@ _PLUGIN_UNSUPPORTED_OPTIONS = {
|
||||
"background",
|
||||
"shadow",
|
||||
),
|
||||
"include-markdown": (
|
||||
"trailing_newlines",
|
||||
"comments",
|
||||
"rewrite_relative_urls",
|
||||
"heading_offset",
|
||||
"order",
|
||||
"start",
|
||||
"end",
|
||||
"exclude",
|
||||
"cache",
|
||||
"cache_dir",
|
||||
"recursive",
|
||||
),
|
||||
"literate-nav": (),
|
||||
"llmstxt": ("preprocess",),
|
||||
"macros": (),
|
||||
@@ -833,6 +850,7 @@ def _apply_defaults(config: dict, path: str) -> dict:
|
||||
_shim_macros(config)
|
||||
_shim_markdownextradata(config)
|
||||
_shim_table_reader(config)
|
||||
_shim_include_markdown(config)
|
||||
|
||||
# List files along with their hashes, so we can rebuild when they change
|
||||
watched_files = (
|
||||
@@ -1207,6 +1225,49 @@ def _shim_table_reader(config: dict[str, Any]) -> None:
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _shim_include_markdown(config: dict[str, Any]) -> None:
|
||||
"""Map include-markdown 7.3.0 configuration to Snippets."""
|
||||
name = IncludeMarkdownExtension.name
|
||||
extensions = config["markdown_extensions"]
|
||||
if name not in extensions:
|
||||
plugin = config["plugins"].get("include-markdown", {}).get("config")
|
||||
if plugin is None or not plugin.get("enabled", True):
|
||||
return
|
||||
extensions.append(name)
|
||||
config["mdx_configs"][name] = plugin
|
||||
|
||||
options = config["mdx_configs"].get(name, {})
|
||||
if not options.get("enabled", True):
|
||||
return
|
||||
|
||||
# Direct extension options are validated before the Rust detector is built.
|
||||
try:
|
||||
IncludeMarkdownConfig(
|
||||
**{key: value for key, value in options.items() if key != "enabled"}
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ConfigurationError(str(error)) from error
|
||||
|
||||
snippets = "pymdownx.snippets"
|
||||
if snippets not in extensions:
|
||||
extensions.append(snippets)
|
||||
settings = config["mdx_configs"].setdefault(snippets, {})
|
||||
defaults = {
|
||||
"base_path": [str(Path(config["root_dir"]) / config["docs_dir"])],
|
||||
"restrict_base_path": False,
|
||||
"check_paths": True,
|
||||
"encoding": options.get("encoding", "utf-8"),
|
||||
"url_download": options.get("include_from_url", True),
|
||||
"dedent_subsections": options.get("dedent", False),
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
settings.setdefault(key, value)
|
||||
|
||||
# The adapter is registered after Snippets so its configuration is reused.
|
||||
extensions.remove(name)
|
||||
extensions.append(name)
|
||||
|
||||
|
||||
def _ignore_directory(dirpath: str) -> bool:
|
||||
"""Determine whether to ignore a folder based on its name."""
|
||||
return dirpath == "__pycache__" or dirpath.startswith(".venv")
|
||||
@@ -2712,6 +2773,64 @@ def _convert_plugins(value: Any, config: dict) -> dict:
|
||||
"markdownextradata jinja_options must be a mapping"
|
||||
)
|
||||
|
||||
if "include-markdown" in plugins:
|
||||
include_markdown = plugins["include-markdown"]
|
||||
_reject_unknown_options(
|
||||
"include-markdown",
|
||||
include_markdown,
|
||||
{
|
||||
"enabled",
|
||||
"opening_tag",
|
||||
"closing_tag",
|
||||
"encoding",
|
||||
"preserve_includer_indent",
|
||||
"dedent",
|
||||
"directives",
|
||||
"include_from_url",
|
||||
},
|
||||
)
|
||||
_validate_boolean_options(
|
||||
"include-markdown",
|
||||
include_markdown,
|
||||
(
|
||||
"enabled",
|
||||
"preserve_includer_indent",
|
||||
"dedent",
|
||||
"include_from_url",
|
||||
),
|
||||
)
|
||||
_validate_string_options(
|
||||
"include-markdown",
|
||||
include_markdown,
|
||||
("opening_tag", "closing_tag", "encoding"),
|
||||
)
|
||||
for option in ("opening_tag", "closing_tag", "encoding"):
|
||||
if option in include_markdown and not include_markdown[option]:
|
||||
raise ConfigurationError(
|
||||
f"include-markdown {option} must not be empty"
|
||||
)
|
||||
if "directives" in include_markdown:
|
||||
directives = include_markdown["directives"]
|
||||
if not isinstance(directives, dict):
|
||||
raise ConfigurationError(
|
||||
"include-markdown directives must be a mapping"
|
||||
)
|
||||
_reject_unknown_options(
|
||||
"include-markdown directives",
|
||||
directives,
|
||||
{"include", "include-markdown"},
|
||||
)
|
||||
if any(
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or any(character.isspace() for character in value)
|
||||
for value in directives.values()
|
||||
):
|
||||
raise ConfigurationError(
|
||||
"include-markdown directives must contain "
|
||||
"nonempty names without whitespace"
|
||||
)
|
||||
|
||||
if "table-reader" in plugins:
|
||||
table_reader = plugins["table-reader"]
|
||||
_reject_unknown_options(
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# 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 re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from markdown import Extension
|
||||
from markdown.preprocessors import Preprocessor
|
||||
from pymdownx.snippets import SnippetExtension, SnippetPreprocessor
|
||||
|
||||
from zensical.extensions.context import ContextPreprocessor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from markdown import Markdown
|
||||
|
||||
|
||||
@dataclass
|
||||
class IncludeMarkdownConfig:
|
||||
"""Configure the include-markdown tag adapter."""
|
||||
|
||||
opening_tag: str = "{%"
|
||||
closing_tag: str = "%}"
|
||||
encoding: str = "utf-8"
|
||||
preserve_includer_indent: bool = True
|
||||
dedent: bool = False
|
||||
include_from_url: bool = True
|
||||
directives: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for option in ("opening_tag", "closing_tag"):
|
||||
value = getattr(self, option)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(
|
||||
f"include-markdown {option} must be a nonempty string"
|
||||
)
|
||||
if not isinstance(self.directives, dict):
|
||||
raise TypeError("include-markdown directives must be a mapping")
|
||||
for name, value in self.directives.items():
|
||||
if name not in {"include", "include-markdown"}:
|
||||
raise ValueError(f"unknown include-markdown directive: {name}")
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value
|
||||
or any(character.isspace() for character in value)
|
||||
):
|
||||
raise ValueError(
|
||||
"include-markdown directives must contain "
|
||||
"nonempty names without whitespace"
|
||||
)
|
||||
|
||||
|
||||
_QUOTED = r"""(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')"""
|
||||
_OPTIONS = re.compile(rf"\s+(?P<name>[a-z][a-z-]*)\s*=\s*(?:{_QUOTED}|[^\s]+)")
|
||||
_OPTION_NAMES = {
|
||||
"start",
|
||||
"end",
|
||||
"preserve-includer-indent",
|
||||
"dedent",
|
||||
"exclude",
|
||||
"trailing-newlines",
|
||||
"recursive",
|
||||
"order",
|
||||
"encoding",
|
||||
"rewrite-relative-urls",
|
||||
"comments",
|
||||
"heading-offset",
|
||||
}
|
||||
|
||||
|
||||
class IncludeMarkdownPreprocessor(SnippetPreprocessor):
|
||||
"""Translate include tags before Snippets reads each document."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
md: Markdown,
|
||||
config: IncludeMarkdownConfig,
|
||||
snippets: dict[str, Any],
|
||||
) -> None:
|
||||
super().__init__(snippets, md)
|
||||
self.md = md
|
||||
self.config = config
|
||||
names = {
|
||||
"include": "include",
|
||||
"include-markdown": "include-markdown",
|
||||
**config.directives,
|
||||
}
|
||||
directives = "|".join(
|
||||
re.escape(name) for name in sorted(set(names.values()))
|
||||
)
|
||||
self.tags = re.compile(
|
||||
rf"{re.escape(config.opening_tag)}\s*(?:{directives})(?=\s)"
|
||||
rf"(?P<body>(?:{_QUOTED}|(?!{re.escape(config.closing_tag)})[^\"'])*?)"
|
||||
rf"{re.escape(config.closing_tag)}[ \t]*",
|
||||
re.DOTALL,
|
||||
)
|
||||
self.arguments = re.compile(
|
||||
rf"\s*(?P<path>{_QUOTED})(?P<options>.*)", re.DOTALL
|
||||
)
|
||||
|
||||
def translate_tags(
|
||||
self,
|
||||
lines: list[str],
|
||||
file_name: str | None = None,
|
||||
is_url: bool = False, # noqa: FBT002
|
||||
) -> list[str]:
|
||||
"""Translate include tags into standalone native Snippets directives.
|
||||
|
||||
Leave file reading and content processing to Snippets so converted
|
||||
documents produce the same output after users remove this adapter.
|
||||
"""
|
||||
context = ContextPreprocessor.from_markdown(self.md)
|
||||
project = context.config if context else {}
|
||||
docs_dir = Path(project.get("root_dir", ".")) / project.get(
|
||||
"docs_dir", "docs"
|
||||
)
|
||||
page_path = context.page.path if context else "index.md"
|
||||
source = file_name or str(docs_dir / page_path)
|
||||
text = "\n".join(lines)
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
arguments = self.arguments.fullmatch(match["body"])
|
||||
if arguments is None:
|
||||
raise ValueError("include-markdown requires a quoted file path")
|
||||
|
||||
# Only known literal options are accepted; their values are ignored.
|
||||
options = arguments["options"].rstrip()
|
||||
position = 0
|
||||
while position < len(options):
|
||||
option = _OPTIONS.match(options, position)
|
||||
if option is None or option["name"] not in _OPTION_NAMES:
|
||||
raise ValueError(
|
||||
f"invalid include-markdown options: {options}"
|
||||
)
|
||||
position = option.end()
|
||||
|
||||
quoted = arguments["path"]
|
||||
path = (
|
||||
quoted[1:-1]
|
||||
.replace("\\" + quoted[0], quoted[0])
|
||||
.replace("\\\\", "\\")
|
||||
)
|
||||
if not path or "\n" in path or "\r" in path:
|
||||
raise ValueError(
|
||||
"include-markdown requires a nonempty, "
|
||||
"single-line file path"
|
||||
)
|
||||
if is_url:
|
||||
path = urljoin(source, path)
|
||||
elif not path.lower().startswith(("https://", "http://")):
|
||||
base = (
|
||||
Path(source).parent
|
||||
if path.startswith(("./", "../"))
|
||||
else docs_dir
|
||||
)
|
||||
path = str((base / path).resolve())
|
||||
|
||||
# Each directive must be placed on its own line for Snippets.
|
||||
line_start = text.rfind("\n", 0, match.start()) + 1
|
||||
line_end = text.find("\n", match.end())
|
||||
if line_end < 0:
|
||||
line_end = len(text)
|
||||
indent = text[line_start : match.start()]
|
||||
if indent.strip() or text[match.end() : line_end].strip():
|
||||
raise ValueError(
|
||||
"include-markdown tags must be on their own lines"
|
||||
)
|
||||
if not self.config.preserve_includer_indent:
|
||||
indent = ""
|
||||
|
||||
# Quotes in paths are preserved by block notation. Dedenting is
|
||||
# applied to the whole file, and colon suffixes are kept literal.
|
||||
return f"--8<--\n{indent}{path}:1\n--8<--"
|
||||
|
||||
return self.tags.sub(replace, text).split("\n")
|
||||
|
||||
def parse_snippets(
|
||||
self,
|
||||
lines: list[str],
|
||||
file_name: str | None = None,
|
||||
is_url: bool = False, # noqa: FBT002
|
||||
is_section: bool = False, # noqa: FBT002
|
||||
) -> list[str]:
|
||||
"""Translate nested tags and delegate file inclusion to Snippets."""
|
||||
return super().parse_snippets(
|
||||
self.translate_tags(lines, file_name, is_url),
|
||||
file_name,
|
||||
is_url,
|
||||
is_section,
|
||||
)
|
||||
|
||||
|
||||
class IncludeMarkdownTagPreprocessor(Preprocessor):
|
||||
"""Translate page tags before macros without expanding included files."""
|
||||
|
||||
def __init__(
|
||||
self, md: Markdown, snippets: IncludeMarkdownPreprocessor
|
||||
) -> None:
|
||||
super().__init__(md)
|
||||
self.snippets = snippets
|
||||
|
||||
def run(self, lines: list[str]) -> list[str]:
|
||||
"""Translate page tags and leave file expansion to native Snippets."""
|
||||
return self.snippets.translate_tags(lines)
|
||||
|
||||
|
||||
class IncludeMarkdownExtension(Extension):
|
||||
"""Adapt include-markdown 7.3.0 syntax to PyMdown Snippets."""
|
||||
|
||||
name = "zensical.extensions.include_markdown"
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self._enabled = kwargs.pop("enabled", True)
|
||||
self._config = IncludeMarkdownConfig(**kwargs)
|
||||
super().__init__()
|
||||
|
||||
def extendMarkdown(self, md: Markdown) -> None:
|
||||
"""Register a Snippets preprocessor with include-tag translation."""
|
||||
if not self._enabled:
|
||||
return
|
||||
snippets = next(
|
||||
(
|
||||
extension
|
||||
for extension in md.registeredExtensions
|
||||
if isinstance(extension, SnippetExtension)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if snippets is None:
|
||||
snippets = SnippetExtension(
|
||||
base_path=["."],
|
||||
restrict_base_path=False,
|
||||
check_paths=True,
|
||||
encoding=self._config.encoding,
|
||||
url_download=self._config.include_from_url,
|
||||
dedent_subsections=self._config.dedent,
|
||||
)
|
||||
self._processor = IncludeMarkdownPreprocessor(
|
||||
md, self._config, snippets.getConfigs()
|
||||
)
|
||||
md.registerExtension(self)
|
||||
# PyMdown's "snippet" preprocessor is replaced with this subclass.
|
||||
# Native snippet processing is retained, and include tags are translated
|
||||
# during recursive inclusion. A second processing pass and duplicate
|
||||
# auto_append content are avoided by reusing the name. Registration must
|
||||
# follow Snippets.
|
||||
#
|
||||
# Page tags are translated before macros so include tags are not parsed
|
||||
# as Jinja statements. File expansion is kept at Snippets' native
|
||||
# priority (32), after macros, so conversion to native notation
|
||||
# preserves the order of Markdown processing.
|
||||
md.preprocessors.register(
|
||||
IncludeMarkdownTagPreprocessor(md, self._processor),
|
||||
"include_markdown",
|
||||
36,
|
||||
)
|
||||
md.preprocessors.register(self._processor, "snippet", 32)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear downloaded snippets between Markdown conversions."""
|
||||
self._processor.download.cache_clear()
|
||||
|
||||
|
||||
def makeExtension(**kwargs: Any) -> IncludeMarkdownExtension:
|
||||
"""Create the include-markdown compatibility extension."""
|
||||
return IncludeMarkdownExtension(**kwargs)
|
||||
Reference in new issue
Block a user