summaryrefslogtreecommitdiffstats
path: root/myst_parser/mdit_to_docutils/sphinx_.py
diff options
context:
space:
mode:
Diffstat (limited to 'myst_parser/mdit_to_docutils/sphinx_.py')
-rw-r--r--myst_parser/mdit_to_docutils/sphinx_.py245
1 files changed, 245 insertions, 0 deletions
diff --git a/myst_parser/mdit_to_docutils/sphinx_.py b/myst_parser/mdit_to_docutils/sphinx_.py
new file mode 100644
index 0000000..3c1bc23
--- /dev/null
+++ b/myst_parser/mdit_to_docutils/sphinx_.py
@@ -0,0 +1,245 @@
+"""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
+
+
+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,
+ )
+
+ 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
+
+ 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