# 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 os from pathlib import Path from typing import TYPE_CHECKING, Any import pytest import yaml from markdown import Markdown import zensical.config as cfg_module from zensical.config import ( ConfigurationError, get_builtin_theme_dir, get_theme_dir, parse_config, parse_mkdocs_config, ) from zensical.extensions.autorefs import AutorefsExtension from zensical.extensions.glightbox import GlightboxExtension from zensical.extensions.macros import MacrosExtension from zensical.extensions.media import MediaExtension from zensical.extensions.mkdocstrings import MkdocstringsExtension from zensical.extensions.table_reader import TableReaderExtension if TYPE_CHECKING: from collections.abc import Generator # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _write_toml_config(tmp_path: Path, **project_keys: Any) -> Path: """Write a minimal `zensical.toml` with the given project keys.""" docs_dir = project_keys.get("docs_dir", "docs") if docs_dir and not str(docs_dir).startswith(".."): tmp_path.joinpath(docs_dir).mkdir(exist_ok=True) lines = ["[project]", 'site_name = "test"'] for key, value in project_keys.items(): if isinstance(value, bool): formatted = "true" if value else "false" elif isinstance(value, (int, float)): formatted = str(value) else: formatted = f'"{value}"' lines.append(f"{key} = {formatted}") config_file = tmp_path / "zensical.toml" config_file.write_text("\n".join(lines) + "\n") return config_file def _minimal_yaml(**extra_keys: object) -> str: """Build a minimal mkdocs.yml YAML string with the required scaffolding.""" base: dict[str, Any] = { "site_name": "Test Site", "theme": {"name": "material"}, "extra": {}, "plugins": [], "mdx_configs": {}, } base.update(extra_keys) return yaml.dump(base) def _write_mkdocs_config(tmp_path: Path, yaml_str: str) -> Path: """Create the `docs` directory and write `mkdocs.yml` content.""" tmp_path.joinpath("docs").mkdir() config_file = tmp_path / "mkdocs.yml" config_file.write_text(yaml_str) return config_file # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _reset_config() -> Generator[None, None, None]: """Reset the global _CONFIG before and after each test.""" cfg_module._CONFIG = None yield cfg_module._CONFIG = None def test_site_dir_cant_be_empty(tmp_path: Path) -> None: config_file = _write_toml_config(tmp_path, site_dir="") with pytest.raises(ConfigurationError, match="empty"): parse_config(str(config_file)) def test_site_dir_cant_go_up(tmp_path: Path) -> None: config_file = _write_toml_config(tmp_path, site_dir="../site") with pytest.raises(ConfigurationError, match="within"): parse_config(str(config_file)) def test_docs_dir_cant_be_empty(tmp_path: Path) -> None: config_file = _write_toml_config(tmp_path, docs_dir="") with pytest.raises(ConfigurationError, match="empty"): parse_config(str(config_file)) def test_docs_dir_cant_go_up(tmp_path: Path) -> None: config_file = _write_toml_config(tmp_path, docs_dir="../docs") with pytest.raises(ConfigurationError, match="within"): parse_config(str(config_file)) def test_docs_dir_must_exist(tmp_path: Path) -> None: config_file = _write_toml_config(tmp_path, docs_dir="docs") # Remove the auto-created docs directory to trigger the existence check. tmp_path.joinpath("docs").rmdir() with pytest.raises(ConfigurationError, match="does not exist"): parse_config(str(config_file)) def test_site_dir_docs_dir_cant_be_equal(tmp_path: Path) -> None: config_file = _write_toml_config(tmp_path, site_dir="same", docs_dir="same") with pytest.raises(ConfigurationError, match="must be different"): parse_config(str(config_file)) @pytest.mark.parametrize( "watch_path", [ "mkdocs.yml", "./mkdocs.yml", "{root}/mkdocs.yml", ".", "aliases/config.yml", "aliases", ], ) def test_watch_excludes_config_file(tmp_path: Path, watch_path: str) -> None: # Keep another file with the same name in the extra watched files. extras = tmp_path / "extras" extras.mkdir() other_config = extras / "mkdocs.yml" other_config.write_text("site_name: Other\n", encoding="utf-8") # Cover symlinks as direct watch entries and inside watched directories. if watch_path.startswith("aliases"): aliases = tmp_path / "aliases" aliases.mkdir() try: (aliases / "config.yml").symlink_to(tmp_path / "mkdocs.yml") except OSError as error: pytest.skip(f"symbolic links unavailable: {error}") config_file = _write_mkdocs_config( tmp_path, _minimal_yaml( watch=["extras", watch_path.format(root=tmp_path.as_posix())] ), ) parsed = parse_config(str(config_file)) # The config is already watched by Rust; unrelated files must stay watched. assert {path for path, _ in parsed["watched_files"]} == { str(other_config.resolve()) } # --------------------------------------------------------------------------- # Markdown extensions # --------------------------------------------------------------------------- @pytest.mark.parametrize("value", [None, [], {}, ["abbr"], {"abbr": {}}]) @pytest.mark.parametrize( "source", ["```\n\n```", "- Example\n\n ```\n \n ```"], ) def test_superfences_is_always_enabled(value: Any, source: str) -> None: # Fenced code is supported with empty or unrelated extension settings. extensions, configs = cfg_module._convert_markdown_extensions(value) markdown = Markdown(extensions=extensions, extension_configs=configs) rendered = markdown.convert(source) # Both top-level and nested fences are rendered as escaped code blocks. assert extensions.count("pymdownx.superfences") == 1 assert configs["pymdownx.superfences"] == {} assert "
" in rendered
    assert "<tag>" in rendered


@pytest.mark.parametrize(
    "value",
    [
        [{"pymdownx.superfences": {"preserve_tabs": True}}],
        {"pymdownx.superfences": {"preserve_tabs": True}},
        {"pymdownx": {"superfences": {"preserve_tabs": True}}},
    ],
)
def test_explicit_superfences_configuration_is_preserved(value: Any) -> None:
    # SuperFences options can be supplied in YAML or either supported TOML form.
    extensions, configs = cfg_module._convert_markdown_extensions(value)

    # The extension is added once and its explicit options are retained.
    assert extensions.count("pymdownx.superfences") == 1
    assert configs["pymdownx.superfences"] == {"preserve_tabs": True}


def test_markdown_extensions_are_normalized_without_mutating_input() -> None:
    value = {
        "abbr": None,
        "pymdownx": {
            "highlight": {"linenums": True},
            "blocks": {"tab": None},
        },
        "zensical": {"extensions": {"preview": {}}},
    }

    extensions, configs = cfg_module._convert_markdown_extensions(value)

    assert extensions == [
        "toc",
        "tables",
        "pymdownx.superfences",
        "abbr",
        "pymdownx.highlight",
        "pymdownx.blocks.tab",
        "zensical.extensions.preview",
    ]
    assert configs["abbr"] == {}
    assert configs["pymdownx.highlight"] == {"linenums": True}
    assert value["pymdownx"]["blocks"] == {"tab": None}


@pytest.mark.parametrize(
    ("value", "message"),
    [
        (42, "Markdown extensions must be a list or mapping"),
        ([42], "Markdown extensions must be strings or mappings"),
        (
            [{"abbr": {}, "toc": {}}],
            "Markdown extension mappings must contain one entry",
        ),
        ({42: {}}, "Markdown extension names must be strings"),
        ({"abbr": []}, "Markdown extension configurations must be mappings"),
        ({"pymdownx": []}, "pymdownx Markdown extensions must be a mapping"),
        (
            {"pymdownx": {"blocks": []}},
            "pymdownx.blocks Markdown extensions must be a mapping",
        ),
        ({"zensical": []}, "zensical Markdown extensions must be a mapping"),
        (
            {"zensical": {"extensions": []}},
            "zensical.extensions Markdown extensions must be a mapping",
        ),
    ],
)
def test_markdown_extensions_reject_invalid_configuration(
    value: Any, message: str
) -> None:
    with pytest.raises(ConfigurationError, match=message):
        cfg_module._convert_markdown_extensions(value)


# ---------------------------------------------------------------------------
# Plugins to Markdown extensions
# ---------------------------------------------------------------------------


class TestPluginShimming:
    """Test that MkDocs plugin entries are shimmed into Markdown extensions."""

    def _parse_yaml(
        self, tmp_path: Path, **yaml_overrides: object
    ) -> dict[str, Any]:
        """Write a minimal mkdocs.yml with overrides and return config."""
        yaml_str = _minimal_yaml(**yaml_overrides)
        config_file = _write_mkdocs_config(tmp_path, yaml_str)
        parse_mkdocs_config(str(config_file))
        config = cfg_module._CONFIG
        assert config is not None
        return config

    def test_plugins_as_yaml_list(self, tmp_path: Path) -> None:
        config = self._parse_yaml(tmp_path, plugins=["glightbox"])
        assert GlightboxExtension.name in config["markdown_extensions"]

    def test_plugins_as_yaml_dict(self, tmp_path: Path) -> None:
        config = self._parse_yaml(tmp_path, plugins={"glightbox": {}})
        assert GlightboxExtension.name in config["markdown_extensions"]

    def test_ignored_plugin_settings_do_not_affect_hashes_or_shims(
        self, tmp_path: Path
    ) -> None:
        plugins: dict[str, dict[str, Any]] = {
            "callouts": {},
            "glightbox": {"auto": False},
            "mike": {"version_selector": False},
            "mkdocstrings": {"enabled": False},
            "search": {"separator": r"\s+"},
            "material/tags": {"enabled": False},
        }
        baseline = self._parse_yaml(tmp_path, plugins=plugins)
        for name, options in {
            "callouts": {"aliases": False, "breakless_lists": False},
            "glightbox": {"slide_effect": "fade"},
            "mike": {"javascript_dir": "scripts"},
            "mkdocstrings": {"watch": ["src"]},
            "search": {"lang": ["en", "fr"]},
            "material/tags": {"tags_file": "tags.md", "export_only": True},
        }.items():
            plugins[name].update(options)
        plugins["external"] = {"enabled": ["not", "a", "boolean"]}
        config_file = tmp_path / "mkdocs.yml"
        config_file.write_text(_minimal_yaml(plugins=plugins))

        configured = parse_config(str(config_file))

        for key in (
            "plugins",
            "plugins_hash",
            "markdown_extensions",
            "mdx_configs",
            "mdx_configs_hash",
            "watched_files",
        ):
            assert configured[key] == baseline[key]

    @pytest.mark.parametrize(
        "entry",
        ["meta", {"meta": None}, "material/meta", {"material/meta": None}],
    )
    def test_meta_presence_enables_defaults(
        self, tmp_path: Path, entry: object
    ) -> None:
        config = self._parse_yaml(tmp_path, plugins=[entry])
        assert config["plugins"]["meta"]["config"] == {
            "enabled": True,
            "meta_file": ".meta.yml",
        }

    def test_material_meta_plugin_is_normalized(self, tmp_path: Path) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins={"material/meta": {"meta_file": "defaults.yml"}},
        )
        assert "material/meta" not in config["plugins"]
        assert config["plugins"]["meta"]["config"] == {
            "enabled": True,
            "meta_file": "defaults.yml",
        }
        assert config["plugins_hash"] == cfg_module._hash(config["plugins"])

    def test_material_blog_instances_preserve_order_and_aliases(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins=[
                {"material/blog": {"blog_dir": "journal"}},
                {"blog": {"blog_dir": "news"}},
            ],
        )
        assert config["plugins"]["blogs"] == {
            "config": [
                {"name": "blog", "config": {"blog_dir": "journal"}},
                {"name": "blog", "config": {"blog_dir": "news"}},
            ]
        }

    def test_material_offline_alias_is_normalized(self, tmp_path: Path) -> None:
        config = self._parse_yaml(tmp_path, plugins=["material/offline"])
        assert "material/offline" not in config["plugins"]
        assert config["plugins"]["offline"]["config"]["enabled"] is True
        assert config["use_directory_urls"] is False

    @pytest.mark.parametrize(
        ("name", "canonical"),
        [
            ("material/meta", "meta"),
            ("redirects", "redirects"),
            ("minify", "minify"),
            ("literate-nav", "literate-nav"),
            ("awesome-nav", "awesome-nav"),
        ],
    )
    def test_native_plugin_configuration_must_be_a_mapping(
        self, tmp_path: Path, name: str, canonical: str
    ) -> None:
        with pytest.raises(
            cfg_module.ConfigurationError,
            match=rf"{canonical} configuration must be a mapping",
        ):
            self._parse_yaml(tmp_path, plugins={name: []})

    def test_redirects_plugin_is_normalized(self, tmp_path: Path) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins={"redirects": {"redirect_maps": {"old.md": "new.md"}}},
        )
        assert config["plugins"]["redirects"]["config"] == {
            "enabled": True,
            "redirect_maps": {"old.md": "new.md"},
        }

    def test_redirects_plugin_is_disabled_by_default(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(tmp_path, plugins=[])
        assert config["plugins"]["redirects"]["config"] == {
            "enabled": False,
            "redirect_maps": {},
        }

    @pytest.mark.parametrize("entry", ["literate-nav", {"literate-nav": None}])
    def test_literate_nav_presence_enables_defaults(
        self, tmp_path: Path, entry: object
    ) -> None:
        config = self._parse_yaml(tmp_path, plugins=[entry])
        plugin = config["plugins"]["literate_nav"]["config"]
        assert plugin == {
            "enabled": True,
            "nav_file": "SUMMARY.md",
            "implicit_index": False,
            "tab_length": 4,
            "markdown_extensions": [],
            "mdx_configs": {},
        }

    def test_literate_nav_is_disabled_when_absent(self, tmp_path: Path) -> None:
        config = self._parse_yaml(tmp_path, plugins=[])
        assert config["plugins"]["literate_nav"]["config"]["enabled"] is False

    def test_literate_nav_normalizes_local_markdown_extensions(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins={
                "literate-nav": {
                    "nav_file": "NAV.md",
                    "implicit_index": True,
                    "tab_length": 2,
                    "markdown_extensions": [
                        "abbr",
                        {"toc": {"permalink": False}},
                    ],
                }
            },
        )
        plugin = config["plugins"]["literate_nav"]["config"]
        assert plugin["nav_file"] == "NAV.md"
        assert plugin["implicit_index"] is True
        assert plugin["tab_length"] == 2
        assert plugin["markdown_extensions"] == ["abbr", "toc"]
        assert plugin["mdx_configs"] == {
            "abbr": {},
            "toc": {"permalink": False},
        }

    @pytest.mark.parametrize("entry", ["awesome-nav", {"awesome-nav": None}])
    def test_awesome_nav_presence_enables_defaults(
        self, tmp_path: Path, entry: object
    ) -> None:
        config = self._parse_yaml(tmp_path, plugins=[entry])
        assert config["plugins"]["awesome_nav"]["config"] == {
            "enabled": True,
            "filename": ".nav.yml",
            "logs": {
                "nav_override": None,
                "root_title": None,
                "root_hide": None,
                "no_matches": None,
            },
        }

    def test_awesome_nav_is_disabled_when_absent(self, tmp_path: Path) -> None:
        plugin = self._parse_yaml(tmp_path, plugins=[])["plugins"]
        assert plugin["awesome_nav"]["config"]["enabled"] is False

    def test_awesome_nav_normalizes_filename_and_logs(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins={
                "awesome-nav": {
                    "filename": "awesome.yml",
                    "logs": {"no_matches": "error"},
                }
            },
        )
        plugin = config["plugins"]["awesome_nav"]["config"]
        assert plugin["filename"] == "awesome.yml"
        assert plugin["logs"]["no_matches"] == "error"
        assert plugin["logs"]["root_title"] is None

    @pytest.mark.parametrize(
        ("plugin", "message"),
        [
            ({"unknown": True}, "unknown awesome-nav option"),
            ({"filename": 42}, "filename must be a non-empty string"),
            ({"filename": ""}, "filename must be a non-empty string"),
            ({"logs": "warning"}, "logs must be a mapping"),
            ({"logs": {"unknown": "info"}}, "unknown awesome-nav log"),
            (
                {"logs": {"no_matches": "debug"}},
                "must be info, warning or error",
            ),
        ],
    )
    def test_awesome_nav_rejects_invalid_plugin_options(
        self, tmp_path: Path, plugin: object, message: str
    ) -> None:
        with pytest.raises(cfg_module.ConfigurationError, match=message):
            self._parse_yaml(tmp_path, plugins={"awesome-nav": plugin})

    def test_minify_plugin_is_normalized(self, tmp_path: Path) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins={
                "minify": {
                    "minify_html": True,
                    "minify_inline_js": True,
                    "js_files": "assets/app.js",
                    "css_files": ["assets/app.css"],
                    "htmlmin_opts": {
                        "remove_comments": True,
                        "pre_tags": ["pre", "textarea", "code"],
                    },
                }
            },
        )
        assert config["plugins"]["minify"]["config"] == {
            "enabled": True,
            "minify_html": True,
            "minify_js": False,
            "minify_css": False,
            "minify_inline_js": True,
            "minify_inline_css": False,
            "js_files": ["assets/app.js"],
            "css_files": ["assets/app.css"],
            "htmlmin_opts": {
                "remove_comments": True,
                "remove_empty_space": False,
                "remove_all_empty_space": False,
                "reduce_empty_attributes": True,
                "reduce_boolean_attributes": False,
                "remove_optional_attribute_quotes": True,
                "convert_charrefs": True,
                "keep_pre": False,
                "pre_tags": ["pre", "textarea", "code"],
                "pre_attr": "pre",
            },
            "cache_safe": False,
        }

    def test_minify_plugin_is_disabled_by_default(self, tmp_path: Path) -> None:
        config = self._parse_yaml(tmp_path, plugins=[])
        plugin = config["plugins"]["minify"]["config"]
        assert plugin["enabled"] is False
        assert plugin["minify_html"] is False
        assert plugin["htmlmin_opts"]["pre_tags"] == ["pre", "textarea"]

    def test_tags_plugin_instances_are_preserved_for_rust_normalization(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins=[
                {"tags": {"listings_directive": "$tags"}},
                {
                    "material/tags": {
                        "filters": {"include": ["private/**"]},
                        "tags_name_property": "labels",
                    }
                },
            ],
        )
        instances = config["plugins"]["tags"]["config"]
        assert [instance["name"] for instance in instances] == [
            "tags",
            "tags",
        ]
        assert instances[0]["config"]["listings_directive"] == "$tags"
        assert instances[1]["config"]["filters"] == {"include": ["private/**"]}
        assert instances[1]["config"]["tags_name_property"] == "labels"
        assert "tags_slugify" not in instances[0]["config"]

    def test_mike_plugin_defaults_with_versioned_build(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        monkeypatch.setenv("MIKE_DOCS_VERSION", "0.3")
        config = self._parse_yaml(
            tmp_path,
            site_url="https://example.com",
            plugins=["mike"],
        )
        assert config["site_url"] == "https://example.com/0.3"
        assert config["plugins"]["mike"]["config"] == {
            "alias_type": "symlink",
            "redirect_template": None,
            "deploy_prefix": "",
            "canonical_version": None,
            "version_selector": True,
        }

    def test_disabled_mike_does_not_adjust_versioned_site_url(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        # A disabled plugin must stay inactive even during a mike deployment.
        monkeypatch.setenv("MIKE_DOCS_VERSION", "0.3")

        config = self._parse_yaml(
            tmp_path,
            site_url="https://example.com/docs/",
            plugins={"mike": {"enabled": False, "version_selector": False}},
        )

        assert config["site_url"] == "https://example.com/docs/"
        assert "mike" not in config["plugins"]

    def test_glightbox_adds_extension_and_forwards_config(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins={"glightbox": {"width": "80%", "slide_effect": "fade"}},
        )
        assert GlightboxExtension.name in config["markdown_extensions"]
        assert config["mdx_configs"][GlightboxExtension.name] == {
            "width": "80%"
        }

    @pytest.mark.parametrize(
        ("plugin", "section", "other"),
        [
            ("mkdocs-video", "video", "audio"),
            ("mkdocs-audio", "audio", "video"),
        ],
    )
    def test_media_plugin_enables_only_its_section(
        self, tmp_path: Path, plugin: str, section: str, other: str
    ) -> None:
        config = self._parse_yaml(
            tmp_path, plugins={plugin: {"mark": "custom"}}
        )

        assert config["markdown_extensions"].count(MediaExtension.name) == 1
        media = config["mdx_configs"][MediaExtension.name]
        assert media[section] == {"enabled": True, "mark": "custom"}
        assert media[other] == {"enabled": False}

    def test_both_media_plugins_share_one_extension(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins=[
                {"mkdocs-video": {"is_video": True}},
                {"mkdocs-audio": {"audio_loop": True}},
            ],
        )

        assert config["markdown_extensions"].count(MediaExtension.name) == 1
        media = config["mdx_configs"][MediaExtension.name]
        assert media["order"] == ["video", "audio"]
        assert media["video"] == {"enabled": True, "is_video": True}
        assert media["audio"] == {"enabled": True, "audio_loop": True}

    def test_media_extension_settings_override_plugin_defaults(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            markdown_extensions={
                MediaExtension.name: {
                    "video": {"enabled": False, "mark": "direct"},
                    "audio": {"enabled": True},
                }
            },
            plugins={"mkdocs-video": {"mark": "plugin", "video_loop": True}},
        )

        assert config["markdown_extensions"].count(MediaExtension.name) == 1
        media = config["mdx_configs"][MediaExtension.name]
        assert media["video"] == {
            "enabled": False,
            "mark": "direct",
            "video_loop": True,
        }
        assert media["audio"] == {"enabled": True}

    def test_disabled_media_plugin_does_not_enable_other_section(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path, plugins={"mkdocs-video": {"enabled": False}}
        )
        media = config["mdx_configs"][MediaExtension.name]
        assert media == {
            "video": {"enabled": False},
            "audio": {"enabled": False},
        }

    @pytest.mark.parametrize(
        ("plugin", "options"),
        [
            (
                "callouts",
                {
                    "aliases": False,
                    "breakless_lists": False,
                    "title_from_first_bold": True,
                },
            ),
            ("gh-admonitions", {}),
        ],
    )
    def test_callout_plugins_enable_quotes_callouts(
        self, tmp_path: Path, plugin: str, options: dict[str, Any]
    ) -> None:
        # Plugin configuration enables callouts on an existing quotes extension.
        config = self._parse_yaml(
            tmp_path,
            markdown_extensions=[{"pymdownx.quotes": {"callouts": False}}],
            plugins={plugin: options},
        )

        assert config["markdown_extensions"].count("pymdownx.quotes") == 1
        assert config["mdx_configs"]["pymdownx.quotes"] == {"callouts": True}
        assert config["plugins"][plugin]["config"] == {}

    @pytest.mark.parametrize("plugin", ["callouts", "gh-admonitions"])
    def test_disabled_callout_plugins_do_not_enable_quotes(
        self, tmp_path: Path, plugin: str
    ) -> None:
        config = self._parse_yaml(
            tmp_path, plugins={plugin: {"enabled": False}}
        )

        assert "pymdownx.quotes" not in config["markdown_extensions"]

    @pytest.mark.parametrize(
        ("callouts_enabled", "gh_admonitions_enabled"),
        [(True, True), (False, True), (True, False)],
    )
    def test_callout_plugins_share_quotes_extension(
        self,
        tmp_path: Path,
        callouts_enabled: bool,
        gh_admonitions_enabled: bool,
    ) -> None:
        # Either enabled plugin must suffice when both entries are present.
        config = self._parse_yaml(
            tmp_path,
            plugins={
                "callouts": {"enabled": callouts_enabled},
                "gh-admonitions": {"enabled": gh_admonitions_enabled},
            },
        )

        assert config["markdown_extensions"].count("pymdownx.quotes") == 1
        assert config["mdx_configs"]["pymdownx.quotes"] == {"callouts": True}

    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"]

    @pytest.mark.parametrize("enabled", [False, True])
    def test_macros_watch_local_module_and_default_include_directory(
        self, tmp_path: Path, enabled: bool
    ) -> None:
        config_file = _write_mkdocs_config(
            tmp_path,
            _minimal_yaml(
                plugins={
                    "macros": {
                        "enabled": enabled,
                        "module_name": "custom_macros",
                    }
                }
            ),
        )
        template = tmp_path / "docs" / "fragment.j2"
        template.write_text("Included text", encoding="utf-8")
        module = tmp_path / "custom_macros.py"
        module.write_text("def define_env(env):\n    pass\n", encoding="utf-8")

        config = parse_config(str(config_file))

        # Macro dependencies are watched only while the extension is enabled.
        watched = {path for path, _ in config["watched_files"]}
        assert watched == ({str(module), str(template)} if enabled else set())

    @pytest.mark.parametrize("enabled", [False, True])
    def test_macros_report_missing_installed_modules_during_configuration(
        self, tmp_path: Path, enabled: bool
    ) -> None:
        options = {
            "enabled": enabled,
            "modules": ["unavailable_config_macros_pluglet"],
            "render_by_default": False,
        }

        # Imports are checked even when pages must opt in to rendering.
        if enabled:
            with pytest.raises(
                ModuleNotFoundError, match="unavailable_config_macros_pluglet"
            ):
                self._parse_yaml(tmp_path, plugins={"macros": options})
        else:
            config = self._parse_yaml(tmp_path, plugins={"macros": options})

            assert MacrosExtension.name not in config["markdown_extensions"]

    @pytest.mark.parametrize(
        ("option", "value"),
        [("force_render_paths", "guides/\n!guides/drafts/"), ("verbose", True)],
    )
    def test_macros_settings_forwarded_and_hashed(
        self, tmp_path: Path, option: str, value: str | bool
    ) -> None:
        baseline = self._parse_yaml(tmp_path, plugins={"macros": {}})
        config_file = tmp_path / "mkdocs.yml"
        config_file.write_text(
            _minimal_yaml(plugins={"macros": {option: value}})
        )
        configured = parse_config(str(config_file))

        assert configured["mdx_configs"][MacrosExtension.name][option] == value
        assert configured["plugins_hash"] != baseline["plugins_hash"]

    def test_table_reader_plugin_shimmed(self, tmp_path: Path) -> None:
        config = self._parse_yaml(tmp_path, plugins={"table-reader": {}})
        assert TableReaderExtension.name in config["markdown_extensions"]
        assert MacrosExtension.name not in config["markdown_extensions"]
        assert config["mdx_configs"][TableReaderExtension.name] == {}

    def test_table_reader_options_forwarded(self, tmp_path: Path) -> None:
        plugin = {
            "data_path": "tables",
            "allow_missing_files": True,
            "select_readers": ["read_csv", "read_raw"],
        }
        config = self._parse_yaml(tmp_path, plugins={"table-reader": plugin})
        assert config["mdx_configs"][TableReaderExtension.name] == plugin

    def test_table_reader_shimmed_from_zensical_toml(
        self, tmp_path: Path
    ) -> None:
        (tmp_path / "docs").mkdir()
        config_file = tmp_path / "zensical.toml"
        config_file.write_text(
            "[project]\n"
            'site_name = "Test Site"\n'
            "[project.plugins.table-reader]\n"
            'data_path = "tables"\n'
            "allow_missing_files = true\n"
            'select_readers = ["read_csv", "read_raw"]\n',
            encoding="utf-8",
        )
        config = parse_config(str(config_file))
        assert TableReaderExtension.name in config["markdown_extensions"]
        assert config["plugins"]["table-reader"]["config"] == {
            "data_path": "tables",
            "allow_missing_files": True,
            "select_readers": ["read_csv", "read_raw"],
        }

    def test_disabled_table_reader_not_added(self, tmp_path: Path) -> None:
        config = self._parse_yaml(
            tmp_path, plugins={"table-reader": {"enabled": False}}
        )
        assert TableReaderExtension.name not in config["markdown_extensions"]
        assert MacrosExtension.name not in config["markdown_extensions"]

    def test_table_reader_reuses_macros_extension(self, tmp_path: Path) -> None:
        config = self._parse_yaml(
            tmp_path,
            plugins={"macros": {}, "table-reader": {}},
        )
        assert MacrosExtension.name in config["markdown_extensions"]
        assert TableReaderExtension.name not in config["markdown_extensions"]

    def test_table_reader_data_path_outside_project_rejected(
        self, tmp_path: Path
    ) -> None:
        with pytest.raises(
            ConfigurationError,
            match="data_path must be within project root",
        ):
            self._parse_yaml(
                tmp_path,
                plugins={"table-reader": {"data_path": "../tables"}},
            )

    def test_autorefs_standalone(self, tmp_path: Path) -> None:
        options = {
            "resolve_closest": True,
            "link_titles": "external",
            "strip_title_tags": False,
        }
        config = self._parse_yaml(tmp_path, plugins={"autorefs": options})
        assert AutorefsExtension.name in config["markdown_extensions"]
        assert config["plugins"]["autorefs"]["config"] == options

    @pytest.mark.parametrize(
        ("option", "value"),
        [
            ("resolve_closest", True),
            ("link_titles", False),
            ("strip_title_tags", True),
        ],
    )
    def test_autorefs_settings_affect_rebuild_hash(
        self, tmp_path: Path, option: str, value: bool
    ) -> None:
        baseline = self._parse_yaml(tmp_path, plugins={"autorefs": {}})
        config_file = tmp_path / "mkdocs.yml"
        config_file.write_text(
            _minimal_yaml(plugins={"autorefs": {option: value}})
        )
        configured = parse_config(str(config_file))
        assert configured["plugins_hash"] != baseline["plugins_hash"]

    def test_autorefs_disabled_not_added(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        monkeypatch.setattr("zensical.config.find_spec", lambda _name: True)
        config = self._parse_yaml(
            tmp_path,
            plugins={
                "autorefs": {"enabled": False},
                "mkdocstrings": {"enabled": True},
            },
        )
        assert AutorefsExtension.name not in config["markdown_extensions"]

    def test_mkdocstrings_disabled_neither_added(self, tmp_path: Path) -> None:
        config = self._parse_yaml(
            tmp_path, plugins={"mkdocstrings": {"enabled": False}}
        )
        assert AutorefsExtension.name not in config["markdown_extensions"]
        assert MkdocstringsExtension.name not in config["markdown_extensions"]

    def test_mkdocstrings_disabled_extension_is_not_added(
        self, tmp_path: Path
    ) -> None:
        config = self._parse_yaml(
            tmp_path,
            markdown_extensions=[
                {MkdocstringsExtension.name: {"enabled": False}}
            ],
        )
        assert AutorefsExtension.name not in config["markdown_extensions"]
        assert MkdocstringsExtension.name not in config["markdown_extensions"]

    @pytest.mark.parametrize("options", [{}, {"backlinks": False}])
    def test_mkdocstrings_without_backlinks_does_not_record(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
        options: dict[str, object],
    ) -> None:
        monkeypatch.setattr("zensical.config.find_spec", lambda _name: True)
        config = self._parse_yaml(
            tmp_path,
            plugins={
                "mkdocstrings": {"handlers": {"python": {"options": options}}}
            },
        )

        assert AutorefsExtension.name in config["markdown_extensions"]
        assert MkdocstringsExtension.name in config["markdown_extensions"]
        assert not config["mdx_configs"][AutorefsExtension.name][
            "record_backlinks"
        ]

    def test_disabled_autorefs_extension_stays_disabled_with_backlinks(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Mkdocstrings must not reactivate an explicitly disabled extension."""
        monkeypatch.setattr("zensical.config.find_spec", lambda _name: True)
        config = self._parse_yaml(
            tmp_path,
            markdown_extensions=[{AutorefsExtension.name: {"enabled": False}}],
            plugins={
                "mkdocstrings": {
                    "handlers": {"python": {"options": {"backlinks": "tree"}}}
                }
            },
        )

        assert AutorefsExtension.name not in config["markdown_extensions"]
        assert not config["mdx_configs"][AutorefsExtension.name].get(
            "record_backlinks", False
        )

    @pytest.mark.parametrize(
        ("config_key", "name"),
        [
            pytest.param("plugins", "mkdocstrings", id="plugin"),
            pytest.param(
                "markdown_extensions",
                MkdocstringsExtension.name,
                id="extension",
            ),
        ],
    )
    @pytest.mark.parametrize("backlinks", ["flat", "tree", False])
    def test_mkdocstrings_backlinks_control_collection(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
        config_key: str,
        name: str,
        backlinks: str | bool,
    ) -> None:
        monkeypatch.setattr("zensical.config.find_spec", lambda _name: True)
        options = {"backlinks": backlinks}
        settings = {"handlers": {"python": {"options": options}}}
        configuration = {config_key: {name: settings}}

        config = self._parse_yaml(tmp_path, **configuration)

        # Preserve the rendering mode and collect backlinks only when enabled.
        assert config["mdx_configs"][MkdocstringsExtension.name] == settings
        assert AutorefsExtension.name in config["markdown_extensions"]
        autorefs = config["mdx_configs"][AutorefsExtension.name]
        assert autorefs["record_backlinks"] is (backlinks is not False)

    @pytest.mark.parametrize("value", [True, False, None])
    def test_mkdocstrings_inventory_setting_forwarded_and_hashed(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
        value: bool | None,
    ) -> None:
        monkeypatch.setattr("zensical.config.find_spec", lambda _name: True)
        baseline = self._parse_yaml(tmp_path, plugins={"mkdocstrings": {}})
        config_file = tmp_path / "mkdocs.yml"
        config_file.write_text(
            _minimal_yaml(plugins={"mkdocstrings": {"enable_inventory": value}})
        )
        configured = parse_config(str(config_file))
        assert (
            configured["mdx_configs"][MkdocstringsExtension.name][
                "enable_inventory"
            ]
            is value
        )
        assert configured["plugins_hash"] != baseline["plugins_hash"]

    def test_mkdocstrings_not_installed_raises(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
    ) -> None:
        monkeypatch.setattr("zensical.config.find_spec", lambda _name: None)
        yaml_str = _minimal_yaml(plugins={"mkdocstrings": {}})
        config_file = _write_mkdocs_config(tmp_path, yaml_str)
        with pytest.raises(ConfigurationError):
            parse_mkdocs_config(str(config_file))


# ---------------------------------------------------------------------------
# Getting theme information
# ---------------------------------------------------------------------------


class TestGetThemeDir:
    """Test theme directory resolution."""

    def test_builtin_theme_dir_exists(self) -> None:
        assert Path(get_builtin_theme_dir()).exists()

    def test_get_theme_dir_material(self) -> None:
        assert get_theme_dir("material") == get_builtin_theme_dir()

    def test_get_theme_dir_zensical(self) -> None:
        assert get_theme_dir("zensical") == get_builtin_theme_dir()

    def test_get_theme_dir_unknown_raises(self) -> None:
        with pytest.raises(ConfigurationError):
            get_theme_dir("definitely-not-a-real-theme-xyzzy")

    @pytest.mark.skipif(
        os.name != "nt",
        reason="Windows-specific path normalization",
    )
    def test_custom_theme_dir_normalizes_posix_separators(
        self, tmp_path: Path
    ) -> None:
        custom_dir = tmp_path / "a" / "overrides"
        custom_dir.mkdir(parents=True)

        theme_dir = cfg_module.get_custom_theme_dir(
            "a/overrides", str(tmp_path / "mkdocs.yml")
        )

        assert theme_dir.endswith(r"a\overrides")
        assert theme_dir == str(custom_dir)