Source code for click_extra.mkdocs

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""MkDocs plugin for ANSI color rendering in code blocks.

Patches the formatter classes of
[pymdownx.highlight](https://facelessuser.github.io/pymdown-extensions/extensions/highlight/)
so that `Token.Ansi.*` tokens produced by Click Extra's
{doc}`Pygments lexers <pygments>` are decomposed into individual CSS classes and
styled with the correct colors.

Decomposing tokens into classes only strips the raw escape codes: the classes carry no
color until a matching stylesheet is present. Unlike Sphinx's `PygmentsBridge`, which
regenerates `pygments.css` from the active formatter on every build, MkDocs never emits
one for these classes. The plugin therefore writes the ANSI rules to a dedicated
stylesheet, {data}`ANSI_STYLESHEET`, and registers it in `extra_css` so every page
links it.

When [mkdocs-click](https://pypi.org/project/mkdocs-click/) is installed, the plugin
also patches its code-block generators to use the `ansi-output` lexer instead of plain
`text`, so that CLI help text with ANSI escape codes renders with colors.

```{note}
This is the MkDocs counterpart of the Sphinx integration in
{mod}`click_extra.sphinx`, which achieves the same result by replacing
`sphinx.highlighting.PygmentsBridge.html_formatter`.
```
"""

from __future__ import annotations

from functools import wraps
from pathlib import Path

from ._utils import missing_extra_message

try:
    from mkdocs.plugins import BasePlugin
except ImportError as err:
    raise ImportError(missing_extra_message("mkdocs", subject="This module")) from err

import pymdownx.highlight

from .color import forced_color
from .pygments import AnsiHtmlFormatter

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Iterator

    from mkdocs.config.defaults import MkDocsConfig


ANSI_OUTPUT_FENCE = "```ansi-output"
"""Fenced code-block opening that triggers the ANSI-aware Pygments lexer."""

TEXT_FENCE = "```text"
"""Fenced code-block opening used by `mkdocs-click` for CLI help output."""

ANSI_STYLESHEET = "assets/click-extra/ansi.css"
"""Site-relative path of the generated ANSI color stylesheet.

Registered in the MkDocs config's `extra_css` by
{meth}`AnsiColorPlugin.on_config` so every page links it, and written into the build
output by {meth}`AnsiColorPlugin.on_post_build`.
"""


def _wrap_ansi(orig: Callable[..., Iterator[str]]) -> Callable[..., Iterator[str]]:
    """Wrap an `mkdocs-click` line generator to capture its help in color.

    `mkdocs-click` builds help from `ctx.make_formatter()` rather than running
    the command, so the environment override of
    {func}`~click_extra.color.forced_color` is the only way to color it. The
    generator is drained inside that override, so the escape codes are produced
    while it is active, not once the caller iterates and the environment is
    restored. Its `text` fences are swapped for {data}`ANSI_OUTPUT_FENCE`.
    """

    @wraps(orig)
    def ansi_lines(*args, **kwargs) -> Iterator[str]:
        with forced_color():
            lines = [
                ANSI_OUTPUT_FENCE if line == TEXT_FENCE else line
                for line in orig(*args, **kwargs)
            ]
        return iter(lines)

    return ansi_lines


def _patch_mkdocs_click() -> None:
    """Patch `mkdocs-click` code blocks to use the `ansi-output` lexer.

    Wraps `_make_usage` and `_make_plain_options` with {func}`_wrap_ansi`, so
    the help they capture renders in color. Does nothing when `mkdocs-click` is
    not installed, and nothing more when called twice.
    """
    try:
        # Deferred import: mkdocs-click is optional, even with the mkdocs extra.
        from mkdocs_click import _docs
    except ImportError:
        return

    if getattr(_docs, "_click_extra_patched", False):
        return

    _docs._make_usage = _wrap_ansi(_docs._make_usage)
    _docs._make_plain_options = _wrap_ansi(_docs._make_plain_options)
    _docs._click_extra_patched = True  # type: ignore[attr-defined]


def _ansi_stylesheet() -> str:
    """Build the CSS that colors Click Extra's `-Ansi-*` token classes.

    Delegates to {meth}`~click_extra.pygments.AnsiHtmlFormatter.get_ansi_style_defs`,
    which returns only the ANSI-specific rules: the named and 256-color palette, the
    SGR text-attribute declarations, the blink keyframes, and the OSC 8 hyperlink rule.
    The standard Pygments token rules are dropped on purpose. Dumping the full style
    would override the theme's own syntax-highlighting colors (and, on themes like
    Material, their light and dark variants) for every code block. The ANSI rules are
    additive and theme-agnostic, so they layer safely on top.

    Scoped under the formatter's default `cssclass` (`.highlight`), the wrapper
    `pymdownx.highlight` and the Material theme both emit.
    """
    formatter = AnsiHtmlFormatter()
    return formatter.get_ansi_style_defs(f".{formatter.cssclass}") + "\n"


[docs] class AnsiColorPlugin(BasePlugin): """MkDocs plugin that adds ANSI color support to Pygments code blocks. Monkey-patches `pymdownx.highlight`'s block and inline formatter classes to inherit from {class}`~click_extra.pygments.AnsiHtmlFormatter`. This gives every code block in the MkDocs site full ANSI color rendering: compound tokens like `Token.Ansi.Bold.Cyan` are decomposed into individual CSS classes, and a generated stylesheet (written by {meth}`on_post_build` and registered in `extra_css` by {meth}`on_config`) supplies the color rules for the 256-color indexed palette and all SGR text attributes. When `mkdocs-click` is installed, its code-block generators are also patched to use the `ansi-output` lexer so that CLI help text renders with colors. """
[docs] def on_config(self, config: MkDocsConfig) -> MkDocsConfig: """Patch pymdownx.highlight formatters and register the ANSI stylesheet. Runs before page processing begins. Appending {data}`ANSI_STYLESHEET` to `extra_css` makes MkDocs link it from every page (resolving the relative path per page, including under `use_directory_urls`). The file itself is written later, in {meth}`on_post_build`. """ for name in ("BlockHtmlFormatter", "InlineHtmlFormatter"): formatter = getattr(pymdownx.highlight, name) if not issubclass(formatter, AnsiHtmlFormatter): setattr( pymdownx.highlight, name, type(f"Ansi{name}", (AnsiHtmlFormatter, formatter), {}), ) if ANSI_STYLESHEET not in config["extra_css"]: config["extra_css"].append(ANSI_STYLESHEET) _patch_mkdocs_click() return config
[docs] def on_post_build(self, config: MkDocsConfig) -> None: """Write the ANSI color stylesheet into the built site. Without it, the `-Ansi-*` classes the formatters patched in {meth}`on_config` emit carry no color. Runs after the theme copied its own assets, so the file survives the site-directory cleanup. """ css_path = Path(config["site_dir"]) / ANSI_STYLESHEET css_path.parent.mkdir(parents=True, exist_ok=True) css_path.write_text(_ansi_stylesheet(), encoding="UTF-8")