Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ repos:
- types-urllib3
- sphinx~=8.2
- markdown-it-py~=4.2
- mdit-py-plugins~=0.6.0
- mdit-py-plugins~=0.7.0
files: >
(?x)^(
myst_parser/.*py|
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### ✨ New Features

- ✨ Add `"section_ref"` syntax extension for section-sign references (e.g. `§1`, `§1.1`), which resolve to internal links to the correspondingly numbered heading in the document, see [](syntax/section-ref) by <gh-user:chrisjsewell> in <gh-pr:1170>
This extension requires `mdit-py-plugins >= 0.7`.

## 5.1.0 - 2026-05-13

### ✨ New Features
Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ linkify
replacements
: Automatically convert some common typographic texts

section_ref
: Resolve section-sign references such as `§1.1` to internal links, [see here](syntax/section-ref) for details

smartquotes
: Automatically convert standard quotations to their opening/closing variants

Expand Down
45 changes: 45 additions & 0 deletions docs/syntax/optional.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ myst_enable_extensions = [
"html_image",
"linkify",
"replacements",
"section_ref",
"smartquotes",
"strikethrough",
"substitution",
Expand Down Expand Up @@ -325,6 +326,50 @@ Key differences:
- **`gfm_autolink`** only matches `www.`-prefixed URLs, `http(s)://`/`mailto:`/`xmpp:` URLs, and email addresses. It has no extra dependency and follows GFM URL boundary rules exactly.
:::

(syntax/section-ref)=
## Section references

```{versionadded} 5.2.0
```

Adding `"section_ref"` to `myst_enable_extensions` (in the {{ confpy }}) will recognise section-sign references such as `§1`, `§1.1` and `§2.3.4`, and turn each into an internal link to the correspondingly numbered heading in the current document.

For example, with the following document:

```md
# Title

See §1 and §1.1, and also §2.

## Section One

### Sub One One

## Section Two
```

`§1` links to *Section One*, `§1.1` to *Sub One One*, and `§2` to *Section Two*, each link also carrying the target heading's title as a hover tooltip.

The numbering is **document-local** and purely **structural**, computed from the heading hierarchy (independent of any `toctree` `:numbered:` numbering):

- If the document has a single top-level heading (the common `# Title` layout), its subsections are numbered `§1`, `§2`, … in order.
- Otherwise the top-level headings themselves are numbered `§1`, `§2`, ….
- Deeper levels are appended with dots, so the second subsection of the first section is `§1.2`, and so on.

A `§` must be immediately followed by digits (e.g. `§1.2`); no spaces are allowed, and a trailing dot is not consumed (so `see §1.` references `§1`).

References that appear **inside a heading or inside link text** are intentionally left as styled text rather than converted to links, since a link there would nest inside another link (a heading reference is also copied into navigation and table-of-contents entries).

:::{note}
References resolve to positions **in the current document only**.
If a reference cannot be resolved to a numbered heading (for example `§9.9` in a document with fewer sections), the reference is left as plain, `section-ref`-styled text and a `myst.section_ref` warning is emitted.
This warning can be suppressed *via* [`suppress_warnings`](sphinx/config-options), e.g. `suppress_warnings = ["myst.section_ref"]`.

In single-page docutils output, a document consisting solely of a `# Title` and one `## Section` promotes both to the document title and subtitle (standard docutils behaviour), leaving no numbered sections to reference.

Cross-document section references may be supported in a future release.
:::

(syntax/substitutions)=

## Substitutions (with Jinja2)
Expand Down
1 change: 1 addition & 0 deletions myst_parser/config/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def check_extensions(inst: "MdParserConfig", field: dc.Field, value: Any) -> Non
"html_image",
"linkify",
"replacements",
"section_ref",
"smartquotes",
"strikethrough",
"substitution",
Expand Down
7 changes: 7 additions & 0 deletions myst_parser/mdit_to_docutils/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,13 @@ def render_span(self, token: SyntaxTreeNode) -> None:
with self.current_node_context(node, append=True):
self.render_children(token)

def render_section_ref(self, token: SyntaxTreeNode) -> None:
"""Render a section reference (§1.1), for later resolution by ``ResolveSectionRefs``."""
node = nodes.inline(token.content, token.content, classes=["section-ref"])
node["section_numbers"] = token.meta["numbers"]
self.add_line_and_source_path(node, token)
self.current_node.append(node)

def render_front_matter(self, token: SyntaxTreeNode) -> None:
"""Pass document front matter data."""
position = token_line(token, default=0)
Expand Down
119 changes: 119 additions & 0 deletions myst_parser/mdit_to_docutils/transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,122 @@ def apply(self, **kwargs: t.Any) -> None:
refnode += nodes.inline(
"#" + target, "#" + target, classes=["std", "std-ref"]
)


def _child_sections(node: nodes.Element) -> list[nodes.section]:
"""Return the direct child sections of an element, in document order."""
return [child for child in node.children if isinstance(child, nodes.section)]


def _number_sections(
sections: list[nodes.section],
prefix: tuple[int, ...],
number_map: dict[tuple[int, ...], nodes.section],
) -> None:
"""Recursively assign structural numbers to sections, populating ``number_map``.

The i-th (1-based) section at a level is numbered ``prefix + (i,)``, and the
numbering recurses into each section's direct child sections.
"""
for i, section in enumerate(sections, start=1):
number = prefix + (i,)
number_map[number] = section
_number_sections(_child_sections(section), number, number_map)


def _leave_section_ref_inert(node: nodes.Element) -> bool:
"""Whether a section-reference marker (see ``render_section_ref``) should be left as inert styled text.

A marker is left untouched (no link, no warning) when it sits inside:

- a ``reference`` or ``pending_xref`` — converting it would nest an ``<a>``
inside another ``<a>``, which is invalid HTML; or
- a ``title`` — a reference here would be copied into navigation/toc entries
(sphinx toctree entry links, docutils contents entries), again nesting
``<a>`` in those navs.
"""
parent = node.parent
while parent is not None:
if (
isinstance(parent, nodes.reference | nodes.title)
or parent.tagname == "pending_xref"
):
return True
parent = parent.parent
return False


class ResolveSectionRefs(Transform):
"""Resolve ``§1.1`` section references to the target section's anchor.

Runs at priority 878, before ``ResolveAnchorIds`` (879) and before sphinx's
``DoctreeReadEvent`` (880), so that doctree-read consumers (``env.titles``,
the toctree collector) see the resolved references rather than the raw
markers. By this point any doctitle promotion (320) has happened and every
section id exists (docutils' ``PropagateTargets`` (260)/sphinx's ``SortIds``
(261) and the later id transforms have all run). Numbering is
document-local and purely structural (independent of any ``:numbered:``
toctree), so the same references resolve identically in docutils and sphinx.
"""

default_priority = 878 # before ResolveAnchorIds (879)/DoctreeReadEvent (880)

def apply(self, **kwargs: t.Any) -> None:
"""Apply the transform."""
# gather the section-reference markers emitted by ``render_section_ref``;
# these can only exist when the ``section_ref`` extension was enabled at
# parse time, so their presence self-gates the transform
markers = [
node
for node in findall(self.document)(nodes.inline)
if "section_numbers" in node
]
if not markers:
return

# build a document-local, structural map of section numbers to sections
roots = _child_sections(self.document)
# with a single top-level section (the common ``# Title`` layout, which
# docutils doctitle promotion and sphinx local numbering both assume),
# number its child sections; otherwise number the top-level sections
top_sections = _child_sections(roots[0]) if len(roots) == 1 else roots
number_map: dict[tuple[int, ...], nodes.section] = {}
_number_sections(top_sections, (), number_map)

for node in markers:
# a reference nested inside a link or heading would produce invalid
# nested anchors, so such markers are left as inert styled text
if _leave_section_ref_inert(node):
continue
content = node.astext()
number = tuple(node["section_numbers"])
section = number_map.get(number)
if section is not None and section["ids"]:
ref = nodes.reference(
"",
"",
internal=True,
refid=section["ids"][0],
classes=["section-ref"],
)
ref += node.children
# add the target's title as a hover tooltip (``title="..."`` in
# sphinx HTML); the section's first child is its ``title``
title_node = next(
(
child
for child in section.children
if isinstance(child, nodes.title)
),
None,
)
if title_node is not None:
ref["reftitle"] = clean_astext(title_node)
node.parent.replace(node, ref)
else:
create_warning(
self.document,
f"Section reference target not found: {content!r}",
MystWarnings.SECTION_REF,
line=node.line,
)
2 changes: 2 additions & 0 deletions myst_parser/parsers/docutils_.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
CollectFootnotes,
PrioritiseExplicitIds,
ResolveAnchorIds,
ResolveSectionRefs,
SortFootnotes,
UnreferencedFootnotesDetector,
)
Expand Down Expand Up @@ -260,6 +261,7 @@ def get_transforms(self):
AddSlugIds,
PrioritiseExplicitIds,
ResolveAnchorIds,
ResolveSectionRefs,
]

def parse(self, inputstring: str, document: nodes.document) -> None:
Expand Down
3 changes: 3 additions & 0 deletions myst_parser/parsers/mdit.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from mdit_py_plugins.gfm_autolink import gfm_autolink_plugin
from mdit_py_plugins.myst_blocks import myst_block_plugin
from mdit_py_plugins.myst_role import myst_role_plugin
from mdit_py_plugins.section_ref import section_ref_plugin
from mdit_py_plugins.substitution import substitution_plugin
from mdit_py_plugins.wordcount import wordcount_plugin

Expand Down Expand Up @@ -84,6 +85,8 @@ def create_md_parser(
linkify_enabled = True
if "gfm_autolink" in config.enable_extensions:
md.use(gfm_autolink_plugin)
if "section_ref" in config.enable_extensions:
md.use(section_ref_plugin)
if "strikethrough" in config.enable_extensions:
md.enable("strikethrough")
md.options["strikethrough_single_tilde"] = config.strikethrough_single_tilde
Expand Down
2 changes: 2 additions & 0 deletions myst_parser/parsers/sphinx_.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
CollectFootnotes,
PrioritiseExplicitIds,
ResolveAnchorIds,
ResolveSectionRefs,
SortFootnotes,
)
from myst_parser.parsers.mdit import create_md_parser
Expand Down Expand Up @@ -58,6 +59,7 @@ def get_transforms(self):
AddSlugIds,
PrioritiseExplicitIds,
ResolveAnchorIds,
ResolveSectionRefs,
]

def parse(self, inputstring: str, document: nodes.document) -> None:
Expand Down
2 changes: 2 additions & 0 deletions myst_parser/warnings_.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class MystWarnings(Enum):
"""Invalid attribute value."""
SUBSTITUTION = "substitution"
"""Substitution could not be resolved."""
SECTION_REF = "section_ref"
"""A section reference could not be resolved."""


def _is_suppressed_warning(
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dependencies = [
"docutils>=0.20,<0.24",
"jinja2", # required for substitutions, but let sphinx choose version
"markdown-it-py~=4.2",
"mdit-py-plugins~=0.6,>=0.6.1",
"mdit-py-plugins~=0.7",
"pyyaml",
"sphinx>=8,<10",
]
Expand Down Expand Up @@ -89,7 +89,7 @@ mypy = [
"types-urllib3",
"sphinx~=8.2",
"markdown-it-py~=4.2",
"mdit-py-plugins~=0.6.0",
"mdit-py-plugins~=0.7.0",
]
ruff = ["ruff==0.15.20"]

Expand Down
Loading
Loading