Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Change Log

## Unreleased

- ✨ NEW: Add section reference plugin (`section_ref`) (#144)

The `section_ref` plugin captures section-sign references — the syntax LLMs
commonly use to point at numbered headings — into dedicated `section_ref` tokens,
so downstream renderers (e.g. MyST-Parser) can resolve them to heading cross-references:

```markdown
See §1, §1.1 and §2.3.4 for details.
```

A `§` must be immediately followed by ASCII digits (dot-separated for nested
levels, no spaces); the parsed section number is stored on the token's
`meta["numbers"]` as a list of ints (e.g. `[1, 1]` for `§1.1`).

**Requires markdown-it-py >= 4.1.0.**

## 0.6.1 - 2026-05-13

- 🐛 FIX: Nested field lists incorrectly nesting inside parent containers (#139)
Expand Down
6 changes: 6 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ html_string = md.render("some *Markdown*")
.. autofunction:: mdit_py_plugins.superscript.superscript_plugin
```

## Section References

```{eval-rst}
.. autofunction:: mdit_py_plugins.section_ref.section_ref_plugin
```

## MyST plugins

`myst_blocks` and `myst_role` plugins are also available, for utilisation by the [MyST renderer](https://myst-parser.readthedocs.io/en/latest/using/syntax.html)
Expand Down
12 changes: 12 additions & 0 deletions mdit_py_plugins/section_ref/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Section reference plugin for markdown-it-py.
Captures section-sign references such as ``§1``, ``§1.1`` and ``§2.3.4``
into dedicated ``section_ref`` tokens, so downstream renderers
(e.g. MyST-Parser) can resolve them to numbered heading cross-references.
Requires markdown-it-py >= 4.1.0.
"""

from .index import section_ref_plugin

__all__ = ("section_ref_plugin",)
97 changes: 97 additions & 0 deletions mdit_py_plugins/section_ref/index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Section reference inline rule.

A single inline scanner is registered on the character ``§``:

- **section_ref** (char ``§``): section-sign references like ``§1.2``.

``§`` is *not* one of markdown-it-py's default text-rule terminator
characters, so the text rule would otherwise swallow it mid-paragraph and
the inline rule would never fire. ``add_terminator_char("§")`` makes the
text rule interrupt at every ``§``, giving this rule a chance to run. That
API is only available in markdown-it-py >= 4.1.0.

Requires markdown-it-py >= 4.1.0.
"""

from collections.abc import Sequence
import re
from typing import TYPE_CHECKING

from markdown_it import MarkdownIt
from markdown_it.common.utils import escapeHtml
from markdown_it.rules_inline import StateInline

if TYPE_CHECKING:
from markdown_it.renderer import RendererProtocol
from markdown_it.token import Token
from markdown_it.utils import EnvType, OptionsDict

# ASCII digits only (``[0-9]``, never ``\d`` which also matches unicode digits).
_SECTION_REF_PATTERN = re.compile(r"§([0-9]+(?:\.[0-9]+)*)")


def section_ref_plugin(md: MarkdownIt) -> None:
"""Parse section-sign references such as ``§1``, ``§1.1`` and ``§2.3.4``.

A reference is a ``§`` immediately followed by ASCII digits, optionally
grouped into further ``.``-separated levels (no spaces are allowed).
A trailing dot is not consumed, so ``see §1.`` captures ``§1``. A ``§``
directly followed by an ASCII letter (e.g. ``§1a``) is *not* a reference,
but a following non-ASCII letter does not block a match, so ``见§3章``
still captures ``§3``. Following CommonMark backslash-escape behaviour,
``\\§1`` stays literal text, while ``\\\\§1`` renders a literal backslash
followed by a live reference.

Each reference becomes a ``section_ref`` token, with the matched text
(e.g. ``"§1.1"``) as its ``content``, ``markup`` set to ``§`` and
``meta["numbers"]`` holding the parsed section number as a list of ints
(e.g. ``[1, 1]`` for ``§1.1``), so that downstream renderers can resolve
it to a heading target without re-parsing. Leading zeros are normalized
(``§01.02`` gives ``[1, 2]``); the original text remains in ``content``.

The default rendering is ``<span class="section-ref">§1.1</span>``.
Override it with ``md.add_render_rule("section_ref", ...)``.

.. versionadded:: 0.7.0

Requires markdown-it-py >= 4.1.0.
"""
if not hasattr(md.inline, "add_terminator_char"):
raise RuntimeError("section_ref_plugin requires markdown-it-py >= 4.1.0")

md.inline.add_terminator_char("§")
md.inline.ruler.push("section_ref", _section_ref_rule)
md.add_render_rule("section_ref", render_section_ref)


def _section_ref_rule(state: StateInline, silent: bool) -> bool:
match = _SECTION_REF_PATTERN.match(state.src, state.pos, state.posMax)
if not match:
return False

# reject when directly followed by an ASCII letter (e.g. "§1a", "§1.2b"),
# which indicates the text is not a plain section-number reference
# (a following non-ASCII letter such as CJK must not block the match)
end = match.end()
if end < state.posMax and state.src[end].isascii() and state.src[end].isalpha():
return False

if not silent:
token = state.push("section_ref", "", 0)
token.content = match.group(0)
token.markup = "§"
token.meta = {"numbers": [int(part) for part in match.group(1).split(".")]}

state.pos = end
return True


def render_section_ref(
self: "RendererProtocol",
tokens: Sequence["Token"],
idx: int,
options: "OptionsDict",
env: "EnvType",
) -> str:
token = tokens[idx]
return f'<span class="section-ref">{escapeHtml(token.content)}</span>'
164 changes: 164 additions & 0 deletions tests/fixtures/section_ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@

Basic single-level:
.
See §1 for details.
.
<p>See <span class="section-ref">§1</span> for details.</p>
.

Nested number:
.
See §1.2.3 for details.
.
<p>See <span class="section-ref">§1.2.3</span> for details.</p>
.

At start and end of a paragraph:
.
§1 opens and closes §2
.
<p><span class="section-ref">§1</span> opens and closes <span class="section-ref">§2</span></p>
.

Multiple refs and a range:
.
Compare §1.1-§1.4 and also §2
.
<p>Compare <span class="section-ref">§1.1</span>-<span class="section-ref">§1.4</span> and also <span class="section-ref">§2</span></p>
.

Adjacent refs:
.
§1§2
.
<p><span class="section-ref">§1</span><span class="section-ref">§2</span></p>
.

Trailing period:
.
See §1.
.
<p>See <span class="section-ref">§1</span>.</p>
.

Parenthesised:
.
(§2)
.
<p>(<span class="section-ref">§2</span>)</p>
.

Trailing comma:
.
§3, and more
.
<p><span class="section-ref">§3</span>, and more</p>
.

Trailing possessive apostrophe:
.
§4.3's rules
.
<p><span class="section-ref">§4.3</span>'s rules</p>
.

Inside emphasis:
.
*important: §2.1*
.
<p><em>important: <span class="section-ref">§2.1</span></em></p>
.

Inside link text:
.
[see §1](https://example.com)
.
<p><a href="https://example.com">see <span class="section-ref">§1</span></a></p>
.

Inside inline code (not captured):
.
`§1`
.
<p><code>§1</code></p>
.

Inside fenced code block (not captured):
.
```
§1
```
.
<pre><code>§1
</code></pre>
.

Bare section sign (not captured):
.
just a § here
.
<p>just a § here</p>
.

Space between sign and number (not captured):
.
§ 1
.
<p>§ 1</p>
.

Followed by ASCII letter, single level (not captured):
.
§1a
.
<p>§1a</p>
.

Followed by ASCII letter, nested (not captured):
.
§1.2b
.
<p>§1.2b</p>
.

Backslash-escaped (not captured):
.
\§1
.
<p>\§1</p>
.

Escaped backslash before ref (captured):
.
\\§1
.
<p>\<span class="section-ref">§1</span></p>
.

Followed by non-digit, non-space char (not captured):
.
§x and §.5
.
<p>§x and §.5</p>
.

Leading zeros preserved in content (meta numbers are normalized):
.
§01.02
.
<p><span class="section-ref">§01.02</span></p>
.

In a heading:
.
## About §2
.
<h2>About <span class="section-ref">§2</span></h2>
.

Non-ASCII letter after ref does not block match:
.
见§3章
.
<p>见<span class="section-ref">§3</span>章</p>
.
Loading
Loading