diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af9c77..1c8165b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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..5069123 --- /dev/null +++ b/mdit_py_plugins/section_ref/index.py @@ -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 ``§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 = {"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'{escapeHtml(token.content)}' diff --git a/tests/fixtures/section_ref.md b/tests/fixtures/section_ref.md new file mode 100644 index 0000000..f870d2a --- /dev/null +++ b/tests/fixtures/section_ref.md @@ -0,0 +1,164 @@ + +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
+. + +Trailing possessive apostrophe: +. +§4.3's rules +. +§4.3's rules
+. + +Inside emphasis: +. +*important: §2.1* +. +important: §2.1
+. + +Inside link text: +. +[see §1](https://example.com) +. + +. + +Inside inline code (not captured): +. +`§1` +. +§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 in content (meta numbers are normalized): +. +§01.02 +. +§01.02
+. + +In a heading: +. +## About §2 +. +见§3章
+. diff --git a/tests/test_section_ref.py b/tests/test_section_ref.py new file mode 100644 index 0000000..7b4d804 --- /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={"numbers": [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()