"""Directives that can be applied to both Sphinx and docutils."""
from __future__ import annotations
import re
import typing as t
from docutils import nodes
from docutils.transforms import Transform
from docutils.transforms.references import Footnotes
from markdown_it.common.normalize_url import normalizeLink
from myst_parser._compat import findall
from myst_parser.mdit_to_docutils.base import clean_astext
from myst_parser.warnings_ import MystWarnings, create_warning
[docs]
class AddSlugIds(Transform):
"""Emit each heading's anchor slug as an additional (secondary) id.
This makes the anchor actually exist in published HTML output.
It must run only after *all* other id assignment — docutils'
``PropagateTargets`` (260) and sphinx's ``SortIds`` (261) included —
so that a slug can neither claim an id another element would otherwise
receive, nor become a section's primary id: primary ids (used by
tocs, permalinks and ``objects.inv``) are unchanged by this transform.
Slugs are registered in ``document.ids`` directly (not via ``set_id``),
deliberately bypassing ``id_prefix``: the raw slug is the anchor.
"""
default_priority = 700 # after all id assignment, before ResolveAnchorIds
[docs]
def apply(self, **kwargs: t.Any) -> None:
"""Apply the transform."""
if not getattr(self.document.settings, "myst_heading_anchors_html_ids", True):
return
for node in findall(self.document)(nodes.Element):
slug = node.get("slug")
if (
slug
# a custom slug_func may produce whitespace,
# which is invalid in an HTML id
and not re.search(r"\s", slug)
and slug not in self.document.ids
):
node["ids"].append(slug)
self.document.ids[slug] = node
[docs]
class PrioritiseExplicitIds(Transform):
"""Reorder ``section["ids"]`` so an explicitly named target's id is first.
Docutils' ``PropagateTargets`` (priority 260) appends propagated target
ids *after* the section's implicit id, so themes, tocs, permalinks and
``objects.inv`` pick up the (unstable) implicit id. This moves the
explicitly named id earliest in the id list (for multiple ``(name)=``
targets, that is the one nearest the heading) to the front; the implicit
id remains in the list, as a secondary anchor, so previously published
fragments keep working.
"""
# strictly after docutils' PropagateTargets (260) and sphinx's SortIds
# (261), so the ordering does not depend on transform insertion order
default_priority = 262
[docs]
def apply(self, **kwargs: t.Any) -> None:
"""Apply the transform."""
explicit_ids = {
self.document.nameids[name]
for name, is_explicit in self.document.nametypes.items()
if is_explicit and self.document.nameids.get(name)
}
for section in findall(self.document)(nodes.section):
ids = section["ids"]
first = next((id_ for id_ in ids if id_ in explicit_ids), None)
if first is not None and ids[0] != first:
ids.remove(first)
ids.insert(0, first)
[docs]
class ResolveAnchorIds(Transform):
"""Transform for resolving `[name](#id)` type links."""
default_priority = 879 # this is the same as Sphinx's StandardDomain.process_doc
[docs]
def apply(self, **kwargs: t.Any) -> None:
"""Apply the transform."""
# gather the implicit heading slugs
# name -> (line, slug, title)
slugs: dict[str, tuple[int, str, str]] = getattr(
self.document, "myst_slugs", {}
)
# gather explicit references
# this follows the same logic as Sphinx's StandardDomain.process_doc
explicit: dict[str, tuple[str, None | str]] = {}
for name, is_explicit in self.document.nametypes.items():
if not is_explicit:
continue
labelid = self.document.nameids[name]
if labelid is None:
continue
node = self.document.ids[labelid]
if isinstance(node, nodes.target) and "refid" in node:
# indirect hyperlink targets
node = self.document.ids.get(node["refid"])
labelid = node["names"][0]
if (
node.tagname == "footnote"
or "refuri" in node
or node.tagname.startswith("desc_")
):
# ignore footnote labels, labels automatically generated from a
# link and object descriptions
continue
implicit_title = None
if node.tagname == "rubric":
implicit_title = clean_astext(node)
if implicit_title is None:
# handle sections and and other captioned elements
for subnode in node:
if isinstance(subnode, nodes.caption | nodes.title):
implicit_title = clean_astext(subnode)
break
if implicit_title is None:
# handle definition lists and field lists
if (
isinstance(node, nodes.definition_list | nodes.field_list)
and node.children
):
node = node[0]
if (
isinstance(node, nodes.field | nodes.definition_list_item)
and node.children
):
node = node[0]
if isinstance(node, nodes.term | nodes.field_name):
implicit_title = clean_astext(node)
explicit[name] = (labelid, implicit_title)
for refnode in findall(self.document)(nodes.reference):
if not refnode.get("id_link"):
continue
target = refnode["refuri"][1:]
del refnode["refuri"]
# search explicit first
if target in explicit:
ref_id, implicit_title = explicit[target]
refnode["refid"] = ref_id
if not refnode.children and implicit_title:
refnode += nodes.inline(
implicit_title, implicit_title, classes=["std", "std-ref"]
)
elif not refnode.children:
refnode += nodes.inline(
"#" + target, "#" + target, classes=["std", "std-ref"]
)
continue
# now search implicit
if target in slugs:
_, sect_id, implicit_title = slugs[target]
refnode["refid"] = sect_id
if not refnode.children and implicit_title:
refnode += nodes.inline(
implicit_title, implicit_title, classes=["std", "std-ref"]
)
continue
# candidate implicit local anchor: covers e.g. headings not
# assigned a slug (beyond the `heading_anchors` depth), whose
# anchors nonetheless exist in the output
labelid = self.document.nameids.get(target) or (
target if target in self.document.ids else None
)
node = self.document.ids.get(labelid) if labelid else None
if node is None or (
node.tagname == "footnote"
or "refuri" in node
or node.tagname.startswith("desc_")
):
labelid = None
# in docutils (single-document) mode, resolve to the local
# anchor directly (previously these links warned);
# in sphinx mode it is only recorded on the pending_xref, as a
# last-resort fallback after project-wide resolution, so that
# the precedence of existing reference resolution is unchanged
if labelid and not hasattr(self.document.settings, "env"):
refnode["refid"] = labelid
if not refnode.children:
implicit_title = None
for subnode in node or []:
if isinstance(subnode, nodes.caption | nodes.title):
implicit_title = clean_astext(subnode)
break
text = implicit_title or ("#" + target)
refnode += nodes.inline(text, text, classes=["std", "std-ref"])
continue
# if still not found, and using sphinx, then create a pending_xref
if hasattr(self.document.settings, "env"):
from sphinx import addnodes
pending = addnodes.pending_xref(
refdoc=self.document.settings.env.docname,
refdomain=None,
reftype="myst",
reftarget=target,
refexplicit=bool(refnode.children),
)
if labelid:
pending["reflocalid"] = labelid
inner_node = nodes.inline(
"", "", classes=["xref", "myst"] + refnode["classes"]
)
for attr in ("ids", "names", "dupnames"):
inner_node[attr] = refnode[attr]
inner_node += refnode.children
pending += inner_node
refnode.parent.replace(refnode, pending)
continue
# if still not found, and using docutils, then create a warning
# and simply output as a url
create_warning(
self.document,
f"'myst' reference target not found: {target!r}",
MystWarnings.XREF_MISSING,
line=refnode.line,
append_to=refnode,
)
refnode["refid"] = normalizeLink(target)
if not refnode.children:
refnode += nodes.inline(
"#" + target, "#" + target, classes=["std", "std-ref"]
)