Source code for sphinx_immaterial.custom_admonitions

"""This module inherits from the generic ``admonition`` directive and makes the
title optional."""

from abc import ABC
from pathlib import PurePath
import re
from typing import List, Dict, Any, Tuple, Optional, Type, cast
from docutils import nodes
from docutils.parsers.rst import directives, Directive
import jinja2
import pydantic
from pydantic_extra_types.color import Color
import sphinx
import sphinx.addnodes
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.domains.changeset import VersionChange
from sphinx.environment import BuildEnvironment
import sphinx.ext.todo
from sphinx.locale import admonitionlabels, _
from sphinx.util.logging import getLogger
from sphinx.writers.html5 import HTML5Translator
from .css_and_javascript_bundles import add_global_css
from .inline_icons import load_svg_into_builder_env, get_custom_icons
from . import html_translator_mixin

logger = getLogger(__name__)

# treat the todo directive from the sphinx extension as a built-in directive
admonitionlabels["todo"] = _("Todo")

INHERITED_ADMONITIONS = (
    "abstract",
    "info",
    "success",
    "question",
    "failure",
    "bug",
    "example",
    "quote",
)

_CUSTOM_ADMONITIONS_KEY = "sphinx_immaterial_custom_admonitions"


# defaults used for version directives re-styling
VERSION_DIR_STYLE = {
    "versionadded": {
        "icon": "material/alert-circle",
        "color": (72, 138, 87),
        "classes": [],
    },
    "versionchanged": {
        "icon": "material/alert-circle",
        "color": (238, 144, 64),
        "classes": [],
    },
    "deprecated": {"icon": "material/delete", "color": (203, 70, 83), "classes": []},
}
if sphinx.version_info >= (7, 3):
    # re-use deprecated style for versionremoved directive except with different icon
    VERSION_DIR_STYLE["versionremoved"] = {
        "icon": "material/close",
        "color": (203, 70, 83),
        "classes": [],
    }


[docs] class CustomAdmonitionConfig(pydantic.BaseModel): """This data class validates the user's configuration value(s) in :confval:`sphinx_immaterial_custom_admonitions`. Each validated object corresponds to a generated custom :rst:dir:`admonition` directive tailored with theme specific options. """ name: str """The required name of the directive. This will be also used as a CSS class name. This value shall have characters that match the regular expression pattern ``[a-zA-Z0-9_-]``.""" title: Optional[str] = pydantic.Field(default=None, validate_default=True) """The default title to use when rendering the custom admonition. If this is not specified, then the `name` value is converted and used.""" icon: Optional[str] = None """The relative path to an icon that will be used in the admonition's title. If specified, this path shall be relative to - a SVG file placed in the documentation project's :confval:`sphinx_immaterial_icon_path` (this takes precedence). - the ``.icons`` folder that has `all of the icons bundled with this theme <https://github.com/squidfunk/mkdocs-material/tree/master/material/.icons>`_.""" color: Optional[Color] = None """The base color to be used for the admonition's styling. If specified, this must be defined via: - `name <http://www.w3.org/TR/SVG11/types.html#ColorKeywords>`_ (e.g. :python:`"Black"`, :python:`"azure"`) - `hexadecimal value <https://en.wikipedia.org/wiki/Web_colors#Hex_triplet>`_ (e.g. :python:`"0x000"`, :python:`"#FFFFFF"`, :python:`"7fffd4"`) - RGB/RGBA tuples (e.g. :python:`(255, 255, 255)`, :python:`(255, 255, 255, 0.5)`) - `RGB/RGBA strings <https://developer.mozilla.org/en-US/docs/Web/CSS/color_value>`_ (e.g. :python:`"rgb(255, 255, 255)"`, :python:`"rgba(255, 255, 255, 0.5)"`) - `HSL strings <https://developer.mozilla.org/en-US/docs/Web/CSS/color_value>`_ (e.g. :python:`"hsl(270, 60%, 70%)"`, :python:`"hsl(270, 60%, 70%, .5)"`) .. note:: Any specified transparency (alpha value) is ignored. """ classes: List[str] = [] """If specified, this list of qualified names will be added to every rendered admonition (specific to the generated directive) element's ``class`` attribute. To adopt the styling of pre-existing admonition, include the desired admonition directive's name in this list. .. code-block:: python :caption: Adopting the style of an :dutree:`hint` admonition: sphinx_immaterial_custom_admonitions = [ { "name": "my-admonition", "classes": ["hint"], }, ] """ override: bool = False """Can be used to override an existing directive (default is :python:`False`). Only set this to :python:`True` if the directive being overridden is an existing admonition :ref:`defined by rST and Sphinx <predefined_admonitions>` or an admonition :ref:`inherited from the mkdocs-material theme <inherited_admonitions>`. """ @pydantic.field_validator("name", mode="before") @classmethod def validate_name(cls, val): illegal = re.findall(r"([^a-zA-Z0-9\-_])", val) if illegal: raise ValueError( f"The following characters are illegal for directive names: {illegal}" ) return val @pydantic.field_validator("title") @classmethod def validate_title(cls, val, info: pydantic.ValidationInfo): if val is None: val = " ".join( re.split(r"[\-_]+", cast(str, info.data.get("name"))) ).title() return val @pydantic.field_validator("classes") @classmethod def validate_classes(cls, val): validated = [] for c in val: validated.append(nodes.make_id(c)) return validated
def visit_collapsible(self: HTML5Translator, node: nodes.Element, flag: str): tag_extra_args: Dict[str, Any] = {"CLASS": "admonition"} if flag.lower() == "open": tag_extra_args["open"] = "" self.body.append(self.starttag(node, "details", **tag_extra_args)) title = cast(nodes.Element, node[0]) self.body.append( self.starttag(title, "summary", suffix="", CLASS="admonition-title") ) for child in title.children: child.walkabout(self) self.body.append("</summary>") del node[0] def patch_visit_admonition(): orig_func = HTML5Translator.visit_admonition def visit_admonition(self: HTML5Translator, node: nodes.Element, name: str = ""): collapsible: Optional[str] = node.get("collapsible", None) if collapsible is not None: assert isinstance(node[0], nodes.title) visit_collapsible(self, node, collapsible) else: orig_func(self, node, name) HTML5Translator.visit_admonition = visit_admonition # type: ignore[assignment] def patch_depart_admonition(): orig_func = HTML5Translator.depart_admonition def depart_admonition(self: HTML5Translator, node: Optional[nodes.Element] = None): if node is None or node.get("collapsible", None) is None: orig_func(self, node) else: self.body.append("</details>\n") HTML5Translator.depart_admonition = depart_admonition # type: ignore[assignment] @html_translator_mixin.override def visit_versionmodified( self: html_translator_mixin.HTMLTranslatorMixin, node: sphinx.addnodes.versionmodified, super_func: html_translator_mixin.BaseVisitCallback[ sphinx.addnodes.versionmodified ], ) -> None: # do compatibility check for changes in Sphinx assert ( len(node) >= 1 and isinstance(node[0], nodes.paragraph) and node.get("type", None) is not None and node["type"] in VERSION_DIR_STYLE ) if VERSION_DIR_STYLE[node["type"]]["classes"]: node["classes"].extend(VERSION_DIR_STYLE[node["type"]]["classes"]) if node["type"] not in node["classes"]: node["classes"].append(node["type"]) collapsible: Optional[str] = node.get("collapsible", None) if collapsible is not None: visit_collapsible(self, node, collapsible) else: # similar to what the OG visitor does but with an added admonition class self.body.append(self.starttag(node, "div", CLASSES=["admonition"])) # add admonition-title class to first paragraph node[0]["classes"].append("admonition-title") @html_translator_mixin.override def depart_versionmodified( self: html_translator_mixin.HTMLTranslatorMixin, node: sphinx.addnodes.versionmodified, super_func: html_translator_mixin.BaseVisitCallback[ sphinx.addnodes.versionmodified ], ) -> None: collapsible: Optional[str] = node.get("collapsible", None) if collapsible is not None: self.body.append("</details>") else: super_func(self, node) patch_visit_admonition() patch_depart_admonition() class CustomVersionChange(VersionChange): """Derivative of the original version directives to add theme-specific admonition options""" option_spec = { # type: ignore[misc] "collapsible": directives.unchanged, "class": directives.class_option, "name": directives.unchanged, } def run(self): ret = super().run() assert len(ret) and isinstance(ret[0], sphinx.addnodes.versionmodified) if "collapsible" in self.options: if len(self.arguments) < 2: raise self.error( "Expected 2 arguments before content in %s directive" % self.name ) self.assert_has_content() ret[0]["collapsible"] = self.options["collapsible"] if "class" in self.options: ret[0]["classes"].extend(self.options["class"]) self.add_name(ret[0]) return ret class CustomAdmonitionDirective(Directive, ABC): """A base class to define custom admonition directives. .. warning:: Do not instantiate an object directly from this class as it could mess up the argument parsing for other derivative classes. """ node_class: Type[nodes.admonition] = nodes.admonition optional_arguments: int final_argument_whitespace: bool = True has_content = True default_title: str = "" classes: List[str] = [] option_spec = { "class": directives.class_option, "name": directives.unchanged, "collapsible": directives.unchanged, "no-title": directives.flag, "title": directives.unchanged, } def run(self): # docutils.parsers.rst.roles.set_classes() is deprecated & # its replacement is not available in older versions, so # manually convert key from "class" to "classes" if "class" in self.options: if "classes" not in self.options: self.options["classes"] = [] self.options["classes"].extend(self.options["class"]) del self.options["class"] title_text = "" if not self.arguments else self.arguments[0] if "title" in self.options: # this option can be combined with the directive argument used as a title. title_text += (" " if title_text else "") + self.options["title"] # don't auto-assert `:no-title:` if value is blank; just use default if not title_text: # title_text must be an explicit string for renderers like MyST title_text = str(self.default_title) self.assert_has_content() admonition_node = self.node_class("\n".join(self.content), **self.options) # type: ignore[call-arg] ( admonition_node.source, # type: ignore[attr-defined] admonition_node.line, # type: ignore[attr-defined] ) = self.state_machine.get_source_and_line(self.lineno) if isinstance(admonition_node, sphinx.ext.todo.todo_node): # todo admonitions need extra info for the todolist directive admonition_node["docname"] = admonition_node.source self.state.document.note_explicit_target(admonition_node) else: self.add_name(admonition_node) if "collapsible" in self.options: admonition_node["collapsible"] = self.options["collapsible"] if "no-title" in self.options and "collapsible" in self.options: logger.error( "title is needed for collapsible admonitions", location=admonition_node, ) del self.options["no-title"] # force-disable option textnodes, messages = self.state.inline_text(title_text, self.lineno) if "no-title" not in self.options and title_text: title = nodes.title(title_text, "", *textnodes) title.source, title.line = self.state_machine.get_source_and_line( self.lineno ) admonition_node += title admonition_node += messages admonition_node["classes"] += self.classes self.state.nested_parse(self.content, self.content_offset, admonition_node) return [admonition_node] def get_directive_class(name, title, classes=None) -> Type[CustomAdmonitionDirective]: """A helper function to produce a admonition directive's class.""" # alias upstream-deprecated CSS classes for pre-defined admonitions in sphinx class_list = [nodes.make_id(name)] if classes: class_list.extend(classes) # uncomment this block when we merge v9.x from upstream # if name in ("caution", "attention"): # class_list.append("warning") # elif name == "error": # class_list.append("danger") # elif name in ("important", "hint"): # class_list.append("tip") # elif name == "todo": # class_list.append("info") class CustomizedAdmonition(CustomAdmonitionDirective): default_title = title classes = class_list optional_arguments = int(name not in admonitionlabels) node_class = cast( Type[nodes.admonition], nodes.admonition if name != "todo" else sphinx.ext.todo.todo_node, ) return CustomizedAdmonition def on_builder_inited(app: Sphinx): """register the directives for the custom admonitions and build the CSS.""" custom_admonitions = [ x.model_copy() for x in cast( List[CustomAdmonitionConfig], getattr(app.config, "sphinx_immaterial_custom_admonitions"), ) ] builtin_css_classes = list(admonitionlabels.keys()) + list(INHERITED_ADMONITIONS) custom_admonition_names = [] for admonition in custom_admonitions: custom_admonition_names.append(admonition.name) if admonition.name in VERSION_DIR_STYLE: # if specific to version directives inheriting_style = any( filter(lambda x: x in builtin_css_classes, admonition.classes or []) ) if admonition.classes: cast(List[str], VERSION_DIR_STYLE[admonition.name]["classes"]).extend( admonition.classes ) if not inheriting_style or admonition.icon: admonition.icon = load_svg_into_builder_env( app.builder, admonition.icon or cast(str, VERSION_DIR_STYLE[admonition.name]["icon"]), ) if admonition.color is None and not inheriting_style: admonition.color = Color( cast( Tuple[int, int, int], VERSION_DIR_STYLE[admonition.name]["color"], ) ) continue # don't override the version directives app.add_directive( name=admonition.name, cls=get_directive_class( admonition.name, admonition.title, admonition.classes ), override=admonition.override, ) # set variables for CSS template to match HTML output from generated directives admonition.name = nodes.make_id(admonition.name) if admonition.icon is not None: admonition.icon = load_svg_into_builder_env(app.builder, admonition.icon) # add styles for sphinx directives versionadded, versionchanged, and deprecated for name, style in VERSION_DIR_STYLE.items(): if name in custom_admonition_names: continue # already handled above # add entries for default style of version directives version_dir_style = CustomAdmonitionConfig( name=name, icon=load_svg_into_builder_env(app.builder, cast(str, style["icon"])), color=style["color"], ) custom_admonitions.append(version_dir_style) setattr(app.builder.env, "sphinx_immaterial_custom_admonitions", custom_admonitions) def on_config_inited(app: Sphinx, config: Config): """Add admonitions based on CSS classes inherited from mkdocs-material theme.""" # override the generic admonition directive if getattr(config, "sphinx_immaterial_override_generic_admonitions"): app.add_directive("admonition", get_directive_class("admonition", ""), True) # generate directives for inherited admonitions from upstream CSS if getattr(config, "sphinx_immaterial_generate_extra_admonitions"): for admonition in INHERITED_ADMONITIONS: app.add_directive( admonition, get_directive_class(admonition, _(admonition.title())), ) confval_name = "sphinx_immaterial_custom_admonitions" # validate user defined config for custom admonitions user_defined_admonitions: List[CustomAdmonitionConfig] = pydantic.TypeAdapter( List[CustomAdmonitionConfig] ).validate_python(getattr(config, confval_name)) setattr(config, confval_name, user_defined_admonitions) user_defined_dir_names = [directive.name for directive in user_defined_admonitions] # override the specific admonitions defined in sphinx and docutils # these are the admonitions that have translated titles in sphinx.locale if getattr(config, "sphinx_immaterial_override_builtin_admonitions"): for admonition, title in admonitionlabels.items(): if admonition in user_defined_dir_names: continue app.add_directive(admonition, get_directive_class(admonition, title), True) if getattr(config, "sphinx_immaterial_override_version_directives"): # override original version directives with custom derivatives for name, __ in VERSION_DIR_STYLE.items(): app.add_directive(name, CustomVersionChange, override=True) def add_admonition_and_icon_css(app: Sphinx, env: BuildEnvironment): """Generates the CSS for icons and admonitions, then appends that to the theme's bundled CSS.""" custom_admonitions = getattr(env, _CUSTOM_ADMONITIONS_KEY) custom_icons = get_custom_icons(env) jinja_env = jinja2.Environment( loader=jinja2.FileSystemLoader(str(PurePath(__file__).parent)) ) template = jinja_env.get_template("custom_admonitions.css") generated = template.render( icons=custom_icons, admonitions=custom_admonitions, ) # append the generated CSS for icons and admonitions add_global_css(app, generated.replace("\n", "")) def setup(app: Sphinx): """register our custom directive.""" app.add_config_value( name="sphinx_immaterial_custom_admonitions", default=[], rebuild="env", types=[List[CustomAdmonitionConfig]], ) app.add_config_value( name="sphinx_immaterial_generate_extra_admonitions", default=True, rebuild="html", types=bool, ) app.add_config_value( name="sphinx_immaterial_override_generic_admonitions", default=True, rebuild="html", types=bool, ) app.add_config_value( name="sphinx_immaterial_override_builtin_admonitions", default=True, rebuild="html", types=bool, ) app.add_config_value( name="sphinx_immaterial_override_version_directives", default=True, rebuild="env", types=bool, ) app.connect("builder-inited", on_builder_inited) app.connect("env-check-consistency", add_admonition_and_icon_css) app.connect("config-inited", on_config_inited) return { "parallel_read_safe": True, "parallel_write_safe": True, }

Last update: Dec 16, 2024