Source code for sphinx_immaterial.custom_admonitions
"""This module inherits from the generic ``admonition`` directive and makes thetitle optional."""importrefromabcimportABCfrompathlibimportPurePathfromtypingimportAny,Dict,List,Optional,Tuple,Type,castimportjinja2importpydanticimportsphinximportsphinx.addnodesimportsphinx.ext.todofromdocutilsimportnodesfromdocutils.parsers.rstimportDirective,directivesfrompydantic_extra_types.colorimportColorfromsphinx.applicationimportSphinxfromsphinx.configimportConfigfromsphinx.domains.changesetimportVersionChangefromsphinx.environmentimportBuildEnvironmentfromsphinx.localeimport_,admonitionlabelsfromsphinx.util.loggingimportgetLoggerfromsphinx.writers.html5importHTML5Translatorfrom.importhtml_translator_mixinfrom.css_and_javascript_bundlesimportadd_global_cssfrom.inline_iconsimportget_custom_icons,load_svg_into_builder_envlogger=getLogger(__name__)# treat the todo directive from the sphinx extension as a built-in directiveadmonitionlabels["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-stylingVERSION_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":[]},}ifsphinx.version_info>=(7,3):# re-use deprecated style for versionremoved directive except with different iconVERSION_DIR_STYLE["versionremoved"]={"icon":"material/close","color":(203,70,83),"classes":[],}
[docs]classCustomAdmonitionConfig(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")@classmethoddefvalidate_name(cls,val):illegal=re.findall(r"([^a-zA-Z0-9\-_])",val)ifillegal:raiseValueError(f"The following characters are illegal for directive names: {illegal}")returnval@pydantic.field_validator("title")@classmethoddefvalidate_title(cls,val,info:pydantic.ValidationInfo):ifvalisNone:val=" ".join(re.split(r"[\-_]+",cast(str,info.data.get("name")))).title()returnval@pydantic.field_validator("classes")@classmethoddefvalidate_classes(cls,val):validated=[]forcinval:validated.append(nodes.make_id(c))returnvalidated
defvisit_collapsible(self:HTML5Translator,node:nodes.Element,flag:str):tag_extra_args:Dict[str,Any]={"CLASS":"admonition"}ifflag.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"))forchildintitle.children:child.walkabout(self)self.body.append("</summary>")delnode[0]defpatch_visit_admonition():orig_func=HTML5Translator.visit_admonitiondefvisit_admonition(self:HTML5Translator,node:nodes.Element,name:str=""):collapsible:Optional[str]=node.get("collapsible",None)ifcollapsibleisnotNone:assertisinstance(node[0],nodes.title)visit_collapsible(self,node,collapsible)else:orig_func(self,node,name)HTML5Translator.visit_admonition=visit_admonition# type: ignore[assignment]defpatch_depart_admonition():orig_func=HTML5Translator.depart_admonitiondefdepart_admonition(self:HTML5Translator,node:Optional[nodes.Element]=None):ifnodeisNoneornode.get("collapsible",None)isNone:orig_func(self,node)else:self.body.append("</details>\n")HTML5Translator.depart_admonition=depart_admonition# type: ignore[assignment]@html_translator_mixin.overridedefvisit_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 Sphinxassert(len(node)>=1andisinstance(node[0],nodes.paragraph)andnode.get("type",None)isnotNoneandnode["type"]inVERSION_DIR_STYLE)ifVERSION_DIR_STYLE[node["type"]]["classes"]:node["classes"].extend(VERSION_DIR_STYLE[node["type"]]["classes"])ifnode["type"]notinnode["classes"]:node["classes"].append(node["type"])collapsible:Optional[str]=node.get("collapsible",None)ifcollapsibleisnotNone:visit_collapsible(self,node,collapsible)else:# similar to what the OG visitor does but with an added admonition classself.body.append(self.starttag(node,"div",CLASSES=["admonition"]))# add admonition-title class to first paragraphnode[0]["classes"].append("admonition-title")@html_translator_mixin.overridedefdepart_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)ifcollapsibleisnotNone:self.body.append("</details>")else:super_func(self,node)patch_visit_admonition()patch_depart_admonition()classCustomVersionChange(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,}defrun(self):ret=super().run()assertlen(ret)andisinstance(ret[0],sphinx.addnodes.versionmodified)if"collapsible"inself.options:iflen(self.arguments)<2:raiseself.error("Expected 2 arguments before content in %s directive"%self.name)self.assert_has_content()ret[0]["collapsible"]=self.options["collapsible"]if"class"inself.options:ret[0]["classes"].extend(self.options["class"])self.add_name(ret[0])returnretclassCustomAdmonitionDirective(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.admonitionoptional_arguments:intfinal_argument_whitespace:bool=Truehas_content=Truedefault_title:str=""classes:List[str]=[]option_spec={"class":directives.class_option,"name":directives.unchanged,"collapsible":directives.unchanged,"no-title":directives.flag,"title":directives.unchanged,}defrun(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"inself.options:if"classes"notinself.options:self.options["classes"]=[]self.options["classes"].extend(self.options["class"])delself.options["class"]title_text=""ifnotself.argumentselseself.arguments[0]if"title"inself.options:# this option can be combined with the directive argument used as a title.title_text+=(" "iftitle_textelse"")+self.options["title"]# don't auto-assert `:no-title:` if value is blank; just use defaultifnottitle_text:# title_text must be an explicit string for renderers like MySTtitle_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)ifisinstance(admonition_node,sphinx.ext.todo.todo_node):# todo admonitions need extra info for the todolist directiveadmonition_node["docname"]=admonition_node.sourceself.state.document.note_explicit_target(admonition_node)else:self.add_name(admonition_node)if"collapsible"inself.options:admonition_node["collapsible"]=self.options["collapsible"]if"no-title"inself.optionsand"collapsible"inself.options:logger.error("title is needed for collapsible admonitions",location=admonition_node,)delself.options["no-title"]# force-disable optiontextnodes,messages=self.state.inline_text(title_text,self.lineno)if"no-title"notinself.optionsandtitle_text:title=nodes.title(title_text,"",*textnodes)title.source,title.line=self.state_machine.get_source_and_line(self.lineno)admonition_node+=titleadmonition_node+=messagesadmonition_node["classes"]+=self.classesself.state.nested_parse(self.content,self.content_offset,admonition_node)return[admonition_node]defget_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 sphinxclass_list=[nodes.make_id(name)]ifclasses:class_list.extend(classes)# make sphinx/docutils admonitions compatible with# supported admonition types in upstream v9+ifnamein("caution","attention"):class_list.append("warning")elifname=="error":class_list.append("danger")elifnamein("important","hint"):class_list.append("tip")elifname=="todo":class_list.append("info")classCustomizedAdmonition(CustomAdmonitionDirective):default_title=titleclasses=class_listoptional_arguments=int(namenotinadmonitionlabels)node_class=cast(Type[nodes.admonition],nodes.admonitionifname!="todo"elsesphinx.ext.todo.todo_node,)returnCustomizedAdmonitiondefon_builder_inited(app:Sphinx):"""register the directives for the custom admonitions and build the CSS."""custom_admonitions=[x.model_copy()forxincast(List[CustomAdmonitionConfig],getattr(app.config,"sphinx_immaterial_custom_admonitions"),)]builtin_css_classes=list(admonitionlabels.keys())+list(INHERITED_ADMONITIONS)custom_admonition_names=[]foradmonitionincustom_admonitions:custom_admonition_names.append(admonition.name)ifadmonition.nameinVERSION_DIR_STYLE:# if specific to version directivesinheriting_style=any(filter(lambdax:xinbuiltin_css_classes,admonition.classesor[]))ifadmonition.classes:cast(List[str],VERSION_DIR_STYLE[admonition.name]["classes"]).extend(admonition.classes)ifnotinheriting_styleoradmonition.icon:admonition.icon=load_svg_into_builder_env(app.builder,admonition.iconorcast(str,VERSION_DIR_STYLE[admonition.name]["icon"]),)ifadmonition.colorisNoneandnotinheriting_style:admonition.color=Color(cast(Tuple[int,int,int],VERSION_DIR_STYLE[admonition.name]["color"],))continue# don't override the version directivesapp.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 directivesadmonition.name=nodes.make_id(admonition.name)ifadmonition.iconisnotNone:admonition.icon=load_svg_into_builder_env(app.builder,admonition.icon)# add styles for sphinx directives versionadded, versionchanged, and deprecatedforname,styleinVERSION_DIR_STYLE.items():ifnameincustom_admonition_names:continue# already handled above# add entries for default style of version directivesversion_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)# Handle custom page status identifiershtml_theme_options=getattr(app.config,"html_theme_options",None)custom_status={}ifhtml_theme_optionsisnotNone:foridentifier,propsinhtml_theme_options.get("status",{}).items():custom_status[identifier]=load_svg_into_builder_env(app.builder,cast(str,props["icon"]))setattr(app.builder.env,"sphinx_immaterial_custom_status",custom_status)defon_config_inited(app:Sphinx,config:Config):"""Add admonitions based on CSS classes inherited from mkdocs-material theme."""# override the generic admonition directiveifgetattr(config,"sphinx_immaterial_override_generic_admonitions"):app.add_directive("admonition",get_directive_class("admonition",""),True)# generate directives for inherited admonitions from upstream CSSifgetattr(config,"sphinx_immaterial_generate_extra_admonitions"):foradmonitioninINHERITED_ADMONITIONS:app.add_directive(admonition,get_directive_class(admonition,_(admonition.title())),)confval_name="sphinx_immaterial_custom_admonitions"# validate user defined config for custom admonitionsuser_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.namefordirectiveinuser_defined_admonitions]# override the specific admonitions defined in sphinx and docutils# these are the admonitions that have translated titles in sphinx.localeifgetattr(config,"sphinx_immaterial_override_builtin_admonitions"):foradmonition,titleinadmonitionlabels.items():ifadmonitioninuser_defined_dir_names:continueapp.add_directive(admonition,get_directive_class(admonition,title),True)ifgetattr(config,"sphinx_immaterial_override_version_directives"):# override original version directives with custom derivativesforname,__inVERSION_DIR_STYLE.items():app.add_directive(name,CustomVersionChange,override=True)defadd_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)status=getattr(app.builder.env,"sphinx_immaterial_custom_status")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,status=status,)# append the generated CSS for icons and admonitionsadd_global_css(app,generated.replace("\n",""))defsetup(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,}