From 5de9b76849f09dc3e9931cecab5b7e410539fe05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:44:56 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20NEW:=20Add=20section=20referenc?= =?UTF-8?q?e=20plugin=20(section=5Fref)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures section-sign references such as §1, §1.1 and §2.3.4 — the syntax LLMs commonly use to refer to numbered headings — into dedicated `section_ref` inline tokens (meta["number"] holds the number string), so downstream renderers such as MyST-Parser can resolve them to heading cross-references. Default rendering is a ``. Uses `md.inline.add_terminator_char("§")` (markdown-it-py >= 4.1.0), since § is not a default text-rule terminator character. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LwRkZLzpbHdb3pgmctmn6H --- CHANGELOG.md | 17 +++ docs/index.md | 6 + mdit_py_plugins/section_ref/__init__.py | 12 ++ mdit_py_plugins/section_ref/index.py | 95 ++++++++++++++ tests/fixtures/section_ref.md | 157 ++++++++++++++++++++++++ tests/test_section_ref.py | 128 +++++++++++++++++++ 6 files changed, 415 insertions(+) create mode 100644 mdit_py_plugins/section_ref/__init__.py create mode 100644 mdit_py_plugins/section_ref/index.py create mode 100644 tests/fixtures/section_ref.md create mode 100644 tests/test_section_ref.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af9c77..4bfbbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Change Log +## Unreleased + +- ✨ NEW: Add section reference plugin (`section_ref`) + + 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 number string is stored on the token's `meta["number"]`. + + **Requires markdown-it-py >= 4.1.0.** + ## 0.6.1 - 2026-05-13 - 🐛 FIX: Nested field lists incorrectly nesting inside parent containers (#139) diff --git a/docs/index.md b/docs/index.md index 15cacf1..123ab6a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/mdit_py_plugins/section_ref/__init__.py b/mdit_py_plugins/section_ref/__init__.py new file mode 100644 index 0000000..2fa196f --- /dev/null +++ b/mdit_py_plugins/section_ref/__init__.py @@ -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",) diff --git a/mdit_py_plugins/section_ref/index.py b/mdit_py_plugins/section_ref/index.py new file mode 100644 index 0000000..ec73a52 --- /dev/null +++ b/mdit_py_plugins/section_ref/index.py @@ -0,0 +1,95 @@ +"""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["number"]`` holding the number string (e.g. ``"1.1"``), so that + downstream renderers can resolve it to a heading target. + + The default rendering is ``§1.1``. + 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 = {"number": match.group(1)} + + 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'{escapeHtml(token.content)}' diff --git a/tests/fixtures/section_ref.md b/tests/fixtures/section_ref.md new file mode 100644 index 0000000..392eaf0 --- /dev/null +++ b/tests/fixtures/section_ref.md @@ -0,0 +1,157 @@ + +Basic single-level: +. +See §1 for details. +. +

See §1 for details.

+. + +Nested number: +. +See §1.2.3 for details. +. +

See §1.2.3 for details.

+. + +At start and end of a paragraph: +. +§1 opens and closes §2 +. +

§1 opens and closes §2

+. + +Multiple refs and a range: +. +Compare §1.1-§1.4 and also §2 +. +

Compare §1.1-§1.4 and also §2

+. + +Adjacent refs: +. +§1§2 +. +

§1§2

+. + +Trailing period: +. +See §1. +. +

See §1.

+. + +Parenthesised: +. +(§2) +. +

(§2)

+. + +Trailing comma: +. +§3, and more +. +

§3, and more

+. + +Inside emphasis: +. +*important: §2.1* +. +

important: §2.1

+. + +Inside link text: +. +[see §1](https://example.com) +. +

see §1

+. + +Inside inline code (not captured): +. +`§1` +. +

§1

+. + +Inside fenced code block (not captured): +. +``` +§1 +``` +. +
§1
+
+. + +Bare section sign (not captured): +. +just a § here +. +

just a § here

+. + +Space between sign and number (not captured): +. +§ 1 +. +

§ 1

+. + +Followed by ASCII letter, single level (not captured): +. +§1a +. +

§1a

+. + +Followed by ASCII letter, nested (not captured): +. +§1.2b +. +

§1.2b

+. + +Backslash-escaped (not captured): +. +\§1 +. +

\§1

+. + +Escaped backslash before ref (captured): +. +\\§1 +. +

\§1

+. + +Followed by non-digit, non-space char (not captured): +. +§x and §.5 +. +

§x and §.5

+. + +Leading zeros preserved: +. +§01.02 +. +

§01.02

+. + +In a heading: +. +## About §2 +. +

About §2

+. + +Non-ASCII letter after ref does not block match: +. +见§3章 +. +

§3

+. diff --git a/tests/test_section_ref.py b/tests/test_section_ref.py new file mode 100644 index 0000000..fd7354c --- /dev/null +++ b/tests/test_section_ref.py @@ -0,0 +1,128 @@ +from pathlib import Path +from types import SimpleNamespace + +from markdown_it import MarkdownIt +from markdown_it.token import Token +from markdown_it.utils import read_fixture_file +import pytest + +from mdit_py_plugins.section_ref import section_ref_plugin + +FIXTURE_PATH = Path(__file__).parent.joinpath("fixtures", "section_ref.md") + + +def test_token(): + md = MarkdownIt().use(section_ref_plugin) + src = "see §1.2 here" + tokens = md.parse(src) + print(tokens) + assert tokens == [ + Token( + type="paragraph_open", + tag="p", + nesting=1, + attrs=None, + map=[0, 1], + level=0, + children=None, + content="", + markup="", + info="", + meta={}, + block=True, + hidden=False, + ), + Token( + type="inline", + tag="", + nesting=0, + attrs=None, + map=[0, 1], + level=1, + children=[ + Token( + type="text", + tag="", + nesting=0, + attrs=None, + map=None, + level=0, + children=None, + content="see ", + markup="", + info="", + meta={}, + block=False, + hidden=False, + ), + Token( + type="section_ref", + tag="", + nesting=0, + attrs=None, + map=None, + level=0, + children=None, + content="§1.2", + markup="§", + info="", + meta={"number": "1.2"}, + block=False, + hidden=False, + ), + Token( + type="text", + tag="", + nesting=0, + attrs=None, + map=None, + level=0, + children=None, + content=" here", + markup="", + info="", + meta={}, + block=False, + hidden=False, + ), + ], + content="see §1.2 here", + markup="", + info="", + meta={}, + block=True, + hidden=False, + ), + Token( + type="paragraph_close", + tag="p", + nesting=-1, + attrs=None, + map=None, + level=0, + children=None, + content="", + markup="", + info="", + meta={}, + block=True, + hidden=False, + ), + ] + + +def test_runtime_error(): + """The plugin requires the ``add_terminator_char`` API (markdown-it-py >= 4.1.0).""" + md = MarkdownIt() + md.inline = SimpleNamespace() # type: ignore[assignment] # lacks add_terminator_char + with pytest.raises(RuntimeError, match="requires markdown-it-py"): + section_ref_plugin(md) + + +@pytest.mark.parametrize("line,title,input,expected", read_fixture_file(FIXTURE_PATH)) +def test_all(line, title, input, expected): + md = MarkdownIt("commonmark").use(section_ref_plugin) + md.options["xhtmlOut"] = False + text = md.render(input) + print(text) + assert text.rstrip() == expected.rstrip() From 8fba05f3e52cb5bba91e03a9e23a86ac8b61589e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 09:48:43 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Add=20PR=20reference?= =?UTF-8?q?=20to=20changelog=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LwRkZLzpbHdb3pgmctmn6H --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfbbfa..7451c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- ✨ NEW: Add section reference plugin (`section_ref`) +- ✨ 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, From 73cf1225151a87137c5c60d945dbe25655c16389 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 14:09:24 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Store=20parsed=20?= =?UTF-8?q?section=20number=20as=20list=20of=20ints=20in=20token=20meta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meta["numbers"] now holds e.g. [1, 1] for §1.1, so downstream renderers do not need to re-parse the dotted number string. A list (not tuple) is used so Token.as_dict()/JSON round-trips are type-stable. The original matched text remains available in token.content. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LwRkZLzpbHdb3pgmctmn6H --- CHANGELOG.md | 3 ++- mdit_py_plugins/section_ref/index.py | 8 +++++--- tests/fixtures/section_ref.md | 2 +- tests/test_section_ref.py | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7451c8b..1c8165b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ ``` A `§` must be immediately followed by ASCII digits (dot-separated for nested - levels, no spaces); the number string is stored on the token's `meta["number"]`. + 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.** diff --git a/mdit_py_plugins/section_ref/index.py b/mdit_py_plugins/section_ref/index.py index ec73a52..5069123 100644 --- a/mdit_py_plugins/section_ref/index.py +++ b/mdit_py_plugins/section_ref/index.py @@ -44,8 +44,10 @@ def section_ref_plugin(md: MarkdownIt) -> None: Each reference becomes a ``section_ref`` token, with the matched text (e.g. ``"§1.1"``) as its ``content``, ``markup`` set to ``§`` and - ``meta["number"]`` holding the number string (e.g. ``"1.1"``), so that - downstream renderers can resolve it to a heading target. + ``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 ``§1.1``. Override it with ``md.add_render_rule("section_ref", ...)``. @@ -78,7 +80,7 @@ def _section_ref_rule(state: StateInline, silent: bool) -> bool: token = state.push("section_ref", "", 0) token.content = match.group(0) token.markup = "§" - token.meta = {"number": match.group(1)} + token.meta = {"numbers": [int(part) for part in match.group(1).split(".")]} state.pos = end return True diff --git a/tests/fixtures/section_ref.md b/tests/fixtures/section_ref.md index 392eaf0..1e6d3ca 100644 --- a/tests/fixtures/section_ref.md +++ b/tests/fixtures/section_ref.md @@ -135,7 +135,7 @@ Followed by non-digit, non-space char (not captured):

§x and §.5

. -Leading zeros preserved: +Leading zeros preserved in content (meta numbers are normalized): . §01.02 . diff --git a/tests/test_section_ref.py b/tests/test_section_ref.py index fd7354c..7b4d804 100644 --- a/tests/test_section_ref.py +++ b/tests/test_section_ref.py @@ -66,7 +66,7 @@ def test_token(): content="§1.2", markup="§", info="", - meta={"number": "1.2"}, + meta={"numbers": [1, 2]}, block=False, hidden=False, ), From 6a4040d1c71c6dba41cc2b7d80c1e3572bb29df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:04:49 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Add=20fixture=20for?= =?UTF-8?q?=20possessive=20apostrophe=20after=20section=20ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LwRkZLzpbHdb3pgmctmn6H --- tests/fixtures/section_ref.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/fixtures/section_ref.md b/tests/fixtures/section_ref.md index 1e6d3ca..f870d2a 100644 --- a/tests/fixtures/section_ref.md +++ b/tests/fixtures/section_ref.md @@ -55,6 +55,13 @@ Trailing comma:

§3, and more

. +Trailing possessive apostrophe: +. +§4.3's rules +. +

§4.3's rules

+. + Inside emphasis: . *important: §2.1*