diff --git a/python/tests/integration/test_callouts.py b/python/tests/integration/test_callouts.py new file mode 100644 index 0000000..dda848e --- /dev/null +++ b/python/tests/integration/test_callouts.py @@ -0,0 +1,61 @@ +# 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 zensical + +if TYPE_CHECKING: + from pathlib import Path + + +_BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": True} + + +def test_callouts_plugin_renders_obsidian_callout(tmp_path: Path) -> None: + """The plugin entry enables PyMdown's callout syntax.""" + docs = tmp_path / "docs" + docs.mkdir() + (docs / "index.md").write_text( + "# Callouts\n\n> [!NOTE] Native title\n> Rendered body.\n", + encoding="utf-8", + ) + config = tmp_path / "mkdocs.yml" + config.write_text( + "site_name: Callouts\n" + "plugins:\n" + " - callouts:\n" + " aliases: false\n" + " breakless_lists: false\n" + " title_from_first_bold: true\n", + encoding="utf-8", + ) + + zensical.build(str(config), _BUILD_OPTIONS) + + html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") + assert '
Native title
' in html + assert "Rendered body." in html diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index e437e49..1dc2317 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -280,6 +280,7 @@ class TestPluginShimming: ) -> None: plugins: dict[str, dict[str, Any]] = { "autorefs": {}, + "callouts": {}, "glightbox": {"auto": False}, "macros": {"render_by_default": False}, "mike": {"version_selector": False}, @@ -290,6 +291,7 @@ class TestPluginShimming: baseline = self._parse_yaml(tmp_path, plugins=plugins) for name, options in { "autorefs": {"link_titles": "external"}, + "callouts": {"aliases": False, "breakless_lists": False}, "glightbox": {"slide_effect": "fade"}, "macros": {"force_render_paths": "guides/**"}, "mike": {"javascript_dir": "scripts"}, @@ -587,6 +589,32 @@ class TestPluginShimming: "width": "80%" } + def test_callouts_enables_quotes_callouts(self, tmp_path: Path) -> None: + config = self._parse_yaml( + tmp_path, + markdown_extensions=[{"pymdownx.quotes": {"callouts": False}}], + plugins={ + "callouts": { + "aliases": False, + "breakless_lists": False, + "title_from_first_bold": True, + } + }, + ) + + assert config["markdown_extensions"].count("pymdownx.quotes") == 1 + assert config["mdx_configs"]["pymdownx.quotes"] == {"callouts": True} + assert config["plugins"]["callouts"]["config"] == {} + + def test_disabled_callouts_does_not_enable_quotes( + self, tmp_path: Path + ) -> None: + config = self._parse_yaml( + tmp_path, plugins={"callouts": {"enabled": False}} + ) + + assert "pymdownx.quotes" not in config["markdown_extensions"] + def test_macros_plugin_shimmed(self, tmp_path: Path) -> None: config = self._parse_yaml(tmp_path, plugins={"macros": {}}) assert MacrosExtension.name in config["markdown_extensions"] diff --git a/python/tests/unit/test_plugin_config.py b/python/tests/unit/test_plugin_config.py index 0a37682..3fe6c84 100644 --- a/python/tests/unit/test_plugin_config.py +++ b/python/tests/unit/test_plugin_config.py @@ -40,6 +40,7 @@ PYTHON_PLUGINS = ( "offline", "mike", "autorefs", + "callouts", "markdown-exec", "mkdocstrings", "glightbox", @@ -49,6 +50,7 @@ PYTHON_PLUGINS = ( SHIM_PLUGINS = ( "autorefs", + "callouts", "markdown-exec", "mkdocstrings", "glightbox", @@ -221,6 +223,9 @@ def test_ignores_unsupported_plugin_names(name: str) -> None: ("autorefs", "resolve_closest"), ("autorefs", "link_titles"), ("autorefs", "strip_title_tags"), + ("callouts", "aliases"), + ("callouts", "breakless_lists"), + ("callouts", "title_from_first_bold"), ("glightbox", "touchNavigation"), ("glightbox", "loop"), ("glightbox", "effect"), @@ -332,6 +337,7 @@ def test_normalizes_null_shim_configuration(name: str) -> None: ("name", "config"), [ pytest.param("autorefs", {"enabled": False}, id="autorefs"), + pytest.param("callouts", {"enabled": False}, id="callouts"), pytest.param( "markdown-exec", {"enabled": False, "ansi": "off", "languages": ["python"]}, @@ -472,6 +478,7 @@ def test_silently_discards_unsupported_autorefs_options( "version_selector must be a boolean", ), ("autorefs", {"enabled": "yes"}, "enabled must be a boolean"), + ("callouts", {"enabled": "yes"}, "enabled must be a boolean"), ("markdown-exec", {"ansi": "sometimes"}, "ansi must be"), ( "markdown-exec", diff --git a/python/zensical/config.py b/python/zensical/config.py index 90df0e3..8ab2bb1 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -120,6 +120,11 @@ _PLUGIN_UNSUPPORTED_OPTIONS = { "strip_title_tags", ), "awesome-nav": (), + "callouts": ( + "aliases", + "breakless_lists", + "title_from_first_bold", + ), "glightbox": ( "touchNavigation", "loop", @@ -780,6 +785,7 @@ def _apply_defaults(config: dict, path: str) -> dict: # Map plugins configuration to Markdown extensions _shim_autorefs(config) + _shim_callouts(config) _shim_markdown_exec(config) _shim_mkdocstrings(config) _shim_glightbox(config) @@ -940,6 +946,21 @@ def _shim_autorefs(config: dict[str, Any]) -> None: config["markdown_extensions"].append(AutorefsExtension.name) +def _shim_callouts(config: dict[str, Any]) -> None: + """Enable callout blockquotes for an enabled callouts plugin.""" + if "callouts" not in config["plugins"]: + return + + plugin = config["plugins"]["callouts"]["config"] + if not plugin.get("enabled", True): + return + + extension = "pymdownx.quotes" + if extension not in config["markdown_extensions"]: + config["markdown_extensions"].append(extension) + config["mdx_configs"].setdefault(extension, {})["callouts"] = True + + def _shim_markdown_exec(config: dict[str, Any]) -> None: # Map markdown-exec plugin configuration to superfences configuration if "markdown-exec" in config["plugins"]: @@ -1790,6 +1811,11 @@ def _convert_plugins(value: Any, config: dict) -> dict: _reject_unknown_options("autorefs", autorefs, {"enabled"}) _validate_boolean_options("autorefs", autorefs, ("enabled",)) + if "callouts" in plugins: + callouts = plugins["callouts"] + _reject_unknown_options("callouts", callouts, {"enabled"}) + _validate_boolean_options("callouts", callouts, ("enabled",)) + if "markdown-exec" in plugins: markdown_exec = plugins["markdown-exec"] _reject_unknown_options(