"""Convert Markdown-it tokens to docutils nodes, including sphinx specific elements."""
from __future__ import annotations
import os
from pathlib import Path
from typing import cast
from urllib.parse import unquote
from uuid import uuid4
from docutils import nodes
from markdown_it.tree import SyntaxTreeNode
from sphinx import addnodes
from sphinx.domains.math import MathDomain
from sphinx.domains.std import StandardDomain
from sphinx.environment import BuildEnvironment
from sphinx.util import logging
from sphinx.util.nodes import clean_astext
from myst_parser.mdit_to_docutils.base import DocutilsRenderer
LOGGER = logging.getLogger(__name__)
def create_warning(
document: nodes.document,
message: str,
*,
line: int | None = None,
append_to: nodes.Element | None = None,
wtype: str = "myst",
subtype: str = "other",
) -> nodes.system_message | None:
"""Generate a warning, logging it if necessary.
If the warning type is listed in the ``suppress_warnings`` configuration,
then ``None`` will be returned and no warning logged.
"""
message = f"{message} [{wtype}.{subtype}]"
kwargs = {"line": line} if line is not None else {}
if logging.is_suppressed_warning(
wtype, subtype, document.settings.env.app.config.suppress_warnings
):
return None
msg_node = document.reporter.warning(message, **kwargs)
if append_to is not None:
append_to.append(msg_node)
return None
[docs]class SphinxRenderer(DocutilsRenderer):
"""A markdown-it-py renderer to populate (in-place) a `docutils.document` AST.
This is sub-class of `DocutilsRenderer` that handles sphinx specific aspects,
such as cross-referencing.
"""
@property
def doc_env(self) -> BuildEnvironment:
return self.document.settings.env
def create_warning(
self,
message: str,
*,
line: int | None = None,
append_to: nodes.Element | None = None,
wtype: str = "myst",
subtype: str = "other",
) -> nodes.system_message | None:
"""Generate a warning, logging it if necessary.
If the warning type is listed in the ``suppress_warnings`` configuration,
then ``None`` will be returned and no warning logged.
"""
return create_warning(
self.document,
message,
line=line,
append_to=append_to,
wtype=wtype,
subtype=subtype,
)
[docs] def render_internal_link(self, token: SyntaxTreeNode) -> None:
"""Render link token `[text](link "title")`,
where the link has not been identified as an external URL.
"""
destination = unquote(cast(str, token.attrGet("href") or ""))
# make the path relative to an "including" document
# this is set when using the `relative-docs` option of the MyST `include` directive
relative_include = self.md_env.get("relative-docs", None)
if relative_include is not None and destination.startswith(relative_include[0]):
source_dir, include_dir = relative_include[1:]
destination = os.path.relpath(
os.path.join(include_dir, os.path.normpath(destination)), source_dir
)
potential_path = (
Path(self.doc_env.doc2path(self.doc_env.docname)).parent / destination
if self.doc_env.srcdir # not set in some test situations
else None
)
if (
potential_path
and potential_path.is_file()
and not any(
destination.endswith(suffix)
for suffix in self.doc_env.config.source_suffix
)
):
wrap_node = addnodes.download_reference(
refdoc=self.doc_env.docname,
reftarget=destination,
reftype="myst",
refdomain=None, # Added to enable cross-linking
refexplicit=len(token.children or []) > 0,
refwarn=False,
)
classes = ["xref", "download", "myst"]
text = destination if not token.children else ""
else:
wrap_node = addnodes.pending_xref(
refdoc=self.doc_env.docname,
reftarget=destination,
reftype="myst",
refdomain=None, # Added to enable cross-linking
refexplicit=len(token.children or []) > 0,
refwarn=True,
)
classes = ["xref", "myst"]
text = ""
self.add_line_and_source_path(wrap_node, token)
title = token.attrGet("title")
if title:
wrap_node["title"] = title
self.current_node.append(wrap_node)
inner_node = nodes.inline("", text, classes=classes)
wrap_node.append(inner_node)
with self.current_node_context(inner_node):
self.render_children(token)
def render_heading(self, token: SyntaxTreeNode) -> None:
"""This extends the docutils method, to allow for the addition of heading ids.
These ids are computed by the ``markdown-it-py`` ``anchors_plugin``
as "slugs" which are unique to a document.
The approach is similar to ``sphinx.ext.autosectionlabel``
"""
super().render_heading(token)
if not isinstance(self.current_node, nodes.section):
return
# create the slug string
slug = cast(str, token.attrGet("id"))
if slug is None:
return
section = self.current_node
doc_slug = self.doc_env.doc2path(self.doc_env.docname, base=False) + "#" + slug
# save the reference in the standard domain, so that it can be handled properly
domain = cast(StandardDomain, self.doc_env.get_domain("std"))
if doc_slug in domain.labels:
other_doc = self.doc_env.doc2path(domain.labels[doc_slug][0])
self.create_warning(
f"duplicate label {doc_slug}, other instance in {other_doc}",
line=section.line,
subtype="anchor",
)
labelid = section["ids"][0]
domain.anonlabels[doc_slug] = self.doc_env.docname, labelid
domain.labels[doc_slug] = (
self.doc_env.docname,
labelid,
clean_astext(section[0]),
)
self.doc_env.metadata[self.doc_env.docname]["myst_anchors"] = True
section["myst-anchor"] = doc_slug
[docs] def render_math_block_label(self, token: SyntaxTreeNode) -> None:
"""Render math with referencable labels, e.g. ``$a=1$ (label)``."""
label = token.info
content = token.content
node = nodes.math_block(
content, content, nowrap=False, number=None, label=label
)
target = self.add_math_target(node)
self.add_line_and_source_path(target, token)
self.current_node.append(target)
self.add_line_and_source_path(node, token)
self.current_node.append(node)
def _random_label(self) -> str:
return str(uuid4())
def render_amsmath(self, token: SyntaxTreeNode) -> None:
"""Renderer for the amsmath extension."""
# environment = token.meta["environment"]
content = token.content
if token.meta["numbered"] != "*":
# TODO how to parse and reference labels within environment?
# for now we give create a unique hash, so the equation will be numbered
# but there will be no reference clashes
label = self._random_label()
node = nodes.math_block(
content,
content,
nowrap=True,
number=None,
classes=["amsmath"],
label=label,
)
target = self.add_math_target(node)
self.add_line_and_source_path(target, token)
self.current_node.append(target)
else:
node = nodes.math_block(
content, content, nowrap=True, number=None, classes=["amsmath"]
)
self.add_line_and_source_path(node, token)
self.current_node.append(node)
def add_math_target(self, node: nodes.math_block) -> nodes.target:
# Code mainly copied from sphinx.directives.patches.MathDirective
# register label to domain
domain = cast(MathDomain, self.doc_env.get_domain("math"))
domain.note_equation(self.doc_env.docname, node["label"], location=node)
node["number"] = domain.get_equation_number_for(node["label"])
node["docname"] = self.doc_env.docname
# create target node
node_id = nodes.make_id("equation-%s" % node["label"])
target = nodes.target("", "", ids=[node_id])
self.document.note_explicit_target(target)
return target