diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-29 04:23:02 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-29 04:23:02 +0000 |
commit | 943e3dc057eca53e68ddec51529bd6a1279ebd8e (patch) | |
tree | 61fb7bac619a56dfbcdcbdb7b0d4d6535fc36fe9 /myst_parser/sphinx_ext/myst_refs.py | |
parent | Initial commit. (diff) | |
download | myst-parser-upstream/0.18.1.tar.xz myst-parser-upstream/0.18.1.zip |
Adding upstream version 0.18.1.upstream/0.18.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'myst_parser/sphinx_ext/myst_refs.py')
-rw-r--r-- | myst_parser/sphinx_ext/myst_refs.py | 282 |
1 files changed, 282 insertions, 0 deletions
diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py new file mode 100644 index 0000000..f364345 --- /dev/null +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -0,0 +1,282 @@ +"""A post-transform for overriding the behaviour of sphinx reference resolution. + +This is applied to MyST type references only, such as ``[text](target)``, +and allows for nested syntax +""" +import os +from typing import Any, List, Optional, Tuple, cast + +from docutils import nodes +from docutils.nodes import Element, document +from sphinx import addnodes, version_info +from sphinx.addnodes import pending_xref +from sphinx.domains.std import StandardDomain +from sphinx.locale import __ +from sphinx.transforms.post_transforms import ReferencesResolver +from sphinx.util import docname_join, logging +from sphinx.util.nodes import clean_astext, make_refnode + +from myst_parser._compat import findall + +try: + from sphinx.errors import NoUri +except ImportError: + # sphinx < 2.1 + from sphinx.environment import NoUri # type: ignore + +logger = logging.getLogger(__name__) + + +class MystReferenceResolver(ReferencesResolver): + """Resolves cross-references on doctrees. + + Overrides default sphinx implementation, to allow for nested syntax + """ + + default_priority = 9 # higher priority than ReferencesResolver (10) + + def run(self, **kwargs: Any) -> None: + self.document: document + for node in findall(self.document)(addnodes.pending_xref): + if node["reftype"] != "myst": + continue + + contnode = cast(nodes.TextElement, node[0].deepcopy()) + newnode = None + + target = node["reftarget"] + refdoc = node.get("refdoc", self.env.docname) + domain = None + + try: + newnode = self.resolve_myst_ref(refdoc, node, contnode) + if newnode is None: + # no new node found? try the missing-reference event + # but first we change the the reftype to 'any' + # this means it is picked up by extensions like intersphinx + node["reftype"] = "any" + try: + newnode = self.app.emit_firstresult( + "missing-reference", + self.env, + node, + contnode, + **( + {"allowed_exceptions": (NoUri,)} + if version_info[0] > 2 + else {} + ), + ) + finally: + node["reftype"] = "myst" + # still not found? warn if node wishes to be warned about or + # we are in nit-picky mode + if newnode is None: + node["refdomain"] = "" + # TODO ideally we would override the warning message here, + # to show the [ref.myst] for suppressing warning + self.warn_missing_reference( + refdoc, node["reftype"], target, node, domain + ) + except NoUri: + newnode = contnode + + node.replace_self(newnode or contnode) + + def resolve_myst_ref( + self, refdoc: str, node: pending_xref, contnode: Element + ) -> Element: + """Resolve reference generated by the "myst" role; ``[text](reference)``. + + This builds on the sphinx ``any`` role to also resolve: + + - Document references with extensions; ``[text](./doc.md)`` + - Document references with anchors with anchors; ``[text](./doc.md#target)`` + - Nested syntax for explicit text with std:doc and std:ref; + ``[**nested**](reference)`` + + """ + target = node["reftarget"] # type: str + results = [] # type: List[Tuple[str, Element]] + + res_anchor = self._resolve_anchor(node, refdoc) + if res_anchor: + results.append(("std:doc", res_anchor)) + else: + # if we've already found an anchored doc, + # don't search in the std:ref/std:doc (leads to duplication) + + # resolve standard references + res = self._resolve_ref_nested(node, refdoc) + if res: + results.append(("std:ref", res)) + + # resolve doc names + res = self._resolve_doc_nested(node, refdoc) + if res: + results.append(("std:doc", res)) + + # get allowed domains for referencing + ref_domains = self.env.config.myst_ref_domains + + assert self.app.builder + + # next resolve for any other standard reference objects + if ref_domains is None or "std" in ref_domains: + stddomain = cast(StandardDomain, self.env.get_domain("std")) + for objtype in stddomain.object_types: + key = (objtype, target) + if objtype == "term": + key = (objtype, target.lower()) + if key in stddomain.objects: + docname, labelid = stddomain.objects[key] + domain_role = "std:" + stddomain.role_for_objtype(objtype) + ref_node = make_refnode( + self.app.builder, refdoc, docname, labelid, contnode + ) + results.append((domain_role, ref_node)) + + # finally resolve for any other type of allowed reference domain + for domain in self.env.domains.values(): + if domain.name == "std": + continue # we did this one already + if ref_domains is not None and domain.name not in ref_domains: + continue + try: + results.extend( + domain.resolve_any_xref( + self.env, refdoc, self.app.builder, target, node, contnode + ) + ) + except NotImplementedError: + # the domain doesn't yet support the new interface + # we have to manually collect possible references (SLOW) + if not (getattr(domain, "__module__", "").startswith("sphinx.")): + logger.warning( + f"Domain '{domain.__module__}::{domain.name}' has not " + "implemented a `resolve_any_xref` method [myst.domains]", + type="myst", + subtype="domains", + once=True, + ) + for role in domain.roles: + res = domain.resolve_xref( + self.env, refdoc, self.app.builder, role, target, node, contnode + ) + if res and len(res) and isinstance(res[0], nodes.Element): + results.append((f"{domain.name}:{role}", res)) + + # now, see how many matches we got... + if not results: + return None + if len(results) > 1: + + def stringify(name, node): + reftitle = node.get("reftitle", node.astext()) + return f":{name}:`{reftitle}`" + + candidates = " or ".join(stringify(name, role) for name, role in results) + logger.warning( + __( + f"more than one target found for 'myst' cross-reference {target}: " + f"could be {candidates} [myst.ref]" + ), + location=node, + type="myst", + subtype="ref", + ) + + res_role, newnode = results[0] + # Override "myst" class with the actual role type to get the styling + # approximately correct. + res_domain = res_role.split(":")[0] + if len(newnode) > 0 and isinstance(newnode[0], nodes.Element): + newnode[0]["classes"] = newnode[0].get("classes", []) + [ + res_domain, + res_role.replace(":", "-"), + ] + + return newnode + + def _resolve_anchor( + self, node: pending_xref, fromdocname: str + ) -> Optional[Element]: + """Resolve doc with anchor.""" + if self.env.config.myst_heading_anchors is None: + # no target anchors will have been created, so we don't look for them + return None + target = node["reftarget"] # type: str + if "#" not in target: + return None + # the link may be a heading anchor; we need to first get the relative path + rel_path, anchor = target.rsplit("#", 1) + rel_path = os.path.normpath(rel_path) + if rel_path == ".": + # anchor in the same doc as the node + doc_path = self.env.doc2path(node.get("refdoc", fromdocname), base=False) + else: + # anchor in a different doc from the node + doc_path = os.path.normpath( + os.path.join(node.get("refdoc", fromdocname), "..", rel_path) + ) + return self._resolve_ref_nested(node, fromdocname, doc_path + "#" + anchor) + + def _resolve_ref_nested( + self, node: pending_xref, fromdocname: str, target=None + ) -> Optional[Element]: + """This is the same as ``sphinx.domains.std._resolve_ref_xref``, + but allows for nested syntax, rather than converting the inner node to raw text. + """ + stddomain = cast(StandardDomain, self.env.get_domain("std")) + target = target or node["reftarget"].lower() + + if node["refexplicit"]: + # reference to anonymous label; the reference uses + # the supplied link caption + docname, labelid = stddomain.anonlabels.get(target, ("", "")) + sectname = node.astext() + innernode = nodes.inline(sectname, "") + innernode.extend(node[0].children) + else: + # reference to named label; the final node will + # contain the section name after the label + docname, labelid, sectname = stddomain.labels.get(target, ("", "", "")) + innernode = nodes.inline(sectname, sectname) + + if not docname: + return None + + assert self.app.builder + return make_refnode(self.app.builder, fromdocname, docname, labelid, innernode) + + def _resolve_doc_nested( + self, node: pending_xref, fromdocname: str + ) -> Optional[Element]: + """This is the same as ``sphinx.domains.std._resolve_doc_xref``, + but allows for nested syntax, rather than converting the inner node to raw text. + + It also allows for extensions on document names. + """ + # directly reference to document by source name; can be absolute or relative + refdoc = node.get("refdoc", fromdocname) + docname = docname_join(refdoc, node["reftarget"]) + + if docname not in self.env.all_docs: + # try stripping known extensions from doc name + if os.path.splitext(docname)[1] in self.env.config.source_suffix: + docname = os.path.splitext(docname)[0] + if docname not in self.env.all_docs: + return None + + if node["refexplicit"]: + # reference with explicit title + caption = node.astext() + innernode = nodes.inline(caption, "", classes=["doc"]) + innernode.extend(node[0].children) + else: + # TODO do we want nested syntax for titles? + caption = clean_astext(self.env.titles[docname]) + innernode = nodes.inline(caption, caption, classes=["doc"]) + + assert self.app.builder + return make_refnode(self.app.builder, fromdocname, docname, "", innernode) |