From b322ef79d6f66062d4426bba13949e8066ddd891 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 09:47:44 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20NEW:=20Add=20section=5Fref=20sy?= =?UTF-8?q?ntax=20extension=20(=C2=A71.1=20heading=20references)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the (unreleased) mdit-py-plugins section_ref plugin into MyST-Parser as a new optional extension. §1 / §1.1 / §2.3.4 references resolve, via a new ResolveSectionRefs transform, to internal links targeting the correspondingly numbered heading of the current document. Numbering is document-local and structural: with a single top-level heading, its subsections are §1, §2, ...; nested levels dot-separated. Unresolvable references stay as section-ref styled text and emit a suppressible myst.section_ref warning. References inside headings or link text are deliberately left unlinked, since a reference there would nest anchors in the output and in sphinx toctree entries. Requires mdit-py-plugins >= 0.7 (unreleased). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LwRkZLzpbHdb3pgmctmn6H --- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 7 + docs/configuration.md | 3 + docs/syntax/optional.md | 45 +++++ myst_parser/config/main.py | 1 + myst_parser/mdit_to_docutils/base.py | 7 + myst_parser/mdit_to_docutils/transforms.py | 107 +++++++++++ myst_parser/parsers/docutils_.py | 2 + myst_parser/parsers/mdit.py | 3 + myst_parser/parsers/sphinx_.py | 2 + myst_parser/warnings_.py | 2 + pyproject.toml | 4 +- tests/test_docutils.py | 108 +++++++++++ .../fixtures/docutil_section_refs.md | 178 ++++++++++++++++++ .../test_renderers/test_fixtures_docutils.py | 29 +++ .../sourcedirs/extended_syntaxes/conf.py | 1 + .../sourcedirs/extended_syntaxes/index.md | 8 + .../test_extended_syntaxes.html | 39 ++++ .../test_extended_syntaxes.xml | 20 ++ .../test_extended_syntaxes_text.txt | 14 ++ 20 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 tests/test_renderers/fixtures/docutil_section_refs.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2f3f00d..309e5b3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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| diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2d904f..77f921c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 . + This extension requires `mdit-py-plugins >= 0.7`. + ## 5.1.0 - 2026-05-13 ### ✨ New Features diff --git a/docs/configuration.md b/docs/configuration.md index 6f4d7790..9e5cfa10 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index c8b9f24c..7b984118 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -40,6 +40,7 @@ myst_enable_extensions = [ "html_image", "linkify", "replacements", + "section_ref", "smartquotes", "strikethrough", "substitution", @@ -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*. + +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) diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py index 1e203fa6..d3b2e8d3 100644 --- a/myst_parser/config/main.py +++ b/myst_parser/config/main.py @@ -42,6 +42,7 @@ def check_extensions(inst: "MdParserConfig", field: dc.Field, value: Any) -> Non "html_image", "linkify", "replacements", + "section_ref", "smartquotes", "strikethrough", "substitution", diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 8a29b5b1..e072d5cf 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -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_number"] = token.meta["number"] + 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) diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index a7f9427d..f4316801 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -365,3 +365,110 @@ 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 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 ```` + inside another ````, 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 + ```` 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_number" 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(int(part) for part in node["section_number"].split(".")) + 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 + node.parent.replace(node, ref) + else: + create_warning( + self.document, + f"Section reference target not found: {content!r}", + MystWarnings.SECTION_REF, + line=node.line, + ) diff --git a/myst_parser/parsers/docutils_.py b/myst_parser/parsers/docutils_.py index 177a7f50..91ae74e3 100644 --- a/myst_parser/parsers/docutils_.py +++ b/myst_parser/parsers/docutils_.py @@ -28,6 +28,7 @@ CollectFootnotes, PrioritiseExplicitIds, ResolveAnchorIds, + ResolveSectionRefs, SortFootnotes, UnreferencedFootnotesDetector, ) @@ -260,6 +261,7 @@ def get_transforms(self): AddSlugIds, PrioritiseExplicitIds, ResolveAnchorIds, + ResolveSectionRefs, ] def parse(self, inputstring: str, document: nodes.document) -> None: diff --git a/myst_parser/parsers/mdit.py b/myst_parser/parsers/mdit.py index 2422837b..b248096b 100644 --- a/myst_parser/parsers/mdit.py +++ b/myst_parser/parsers/mdit.py @@ -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 @@ -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 diff --git a/myst_parser/parsers/sphinx_.py b/myst_parser/parsers/sphinx_.py index 60035284..b2a83e41 100644 --- a/myst_parser/parsers/sphinx_.py +++ b/myst_parser/parsers/sphinx_.py @@ -19,6 +19,7 @@ CollectFootnotes, PrioritiseExplicitIds, ResolveAnchorIds, + ResolveSectionRefs, SortFootnotes, ) from myst_parser.parsers.mdit import create_md_parser @@ -58,6 +59,7 @@ def get_transforms(self): AddSlugIds, PrioritiseExplicitIds, ResolveAnchorIds, + ResolveSectionRefs, ] def parse(self, inputstring: str, document: nodes.document) -> None: diff --git a/myst_parser/warnings_.py b/myst_parser/warnings_.py index 99b9072f..60edcb33 100644 --- a/myst_parser/warnings_.py +++ b/myst_parser/warnings_.py @@ -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( diff --git a/pyproject.toml b/pyproject.toml index 8c0d5d79..33215b05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -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"] diff --git a/tests/test_docutils.py b/tests/test_docutils.py index 1e17235c..99b32f6f 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -211,6 +211,114 @@ def test_linkify_no_warning_when_available(): assert "[myst.linkify]" not in stream.getvalue() +def test_section_ref_resolution(): + """A ``§1`` reference resolves to an internal link to the numbered heading.""" + source = dedent( + """\ + # Title + + See §1, §1.1 and §2. + + ## Section One + + ### Sub One One + + ## Section Two + """ + ) + doctree = publish_doctree( + source=source, + parser=Parser(), + settings_overrides={"myst_enable_extensions": ["section_ref"]}, + ) + refs = list(doctree.findall(nodes.reference)) + assert [(ref.astext(), ref["refid"]) for ref in refs] == [ + ("§1", "section-one"), + ("§1.1", "sub-one-one"), + ("§2", "section-two"), + ] + assert all("section-ref" in ref["classes"] and ref["internal"] for ref in refs) + + +def test_section_ref_unresolved_warning(): + """An unresolvable ``§`` reference emits a suppressible ``myst.section_ref`` warning.""" + source = "# Title\n\nSee §9.9.\n\n## Only Section\n" + stream = io.StringIO() + doctree = publish_doctree( + source=source, + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "warning_stream": stream, + }, + ) + assert "[myst.section_ref]" in stream.getvalue() + # the reference is left in place as styled inline text + inlines = [ + node for node in doctree.findall(nodes.inline) if "section_number" in node + ] + assert [node.astext() for node in inlines] == ["§9.9"] + + # the warning is suppressible + stream = io.StringIO() + publish_doctree( + source=source, + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "myst_suppress_warnings": ["myst.section_ref"], + "warning_stream": stream, + }, + ) + assert "[myst.section_ref]" not in stream.getvalue() + + +def test_section_ref_left_inert_in_link_and_heading(): + """A ``§`` reference inside link text or a heading stays inert styled text. + + Converting it would nest an ```` inside another ```` (link text) or + inside toc/contents entry links (heading), so no reference is created and no + warning is emitted for it, whether or not the number would resolve. + """ + source = dedent( + """\ + # Title + + ## Heading with §1 inside + + ## Other + + A link [see §1](https://example.com) and body §1. + """ + ) + stream = io.StringIO() + doctree = publish_doctree( + source=source, + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "warning_stream": stream, + }, + ) + # no reference is ever nested inside another reference + for ref in doctree.findall(nodes.reference): + assert not list(ref.findall(nodes.reference, include_self=False)) + # the heading ref and the in-link ref remain inert inline markers, + # while the plain body ref resolves to the first section + inert = [node for node in doctree.findall(nodes.inline) if "section_number" in node] + assert [node.astext() for node in inert] == ["§1", "§1"] + resolved = [ + ref + for ref in doctree.findall(nodes.reference) + if "section-ref" in ref["classes"] + ] + assert [(r.astext(), r["refid"]) for r in resolved] == [ + ("§1", "heading-with-1-inside") + ] + # inert markers are skipped silently (no warning) + assert "[myst.section_ref]" not in stream.getvalue() + + def test_html_deep_nesting_warns(): """Deeply nested HTML degrades to raw output with a warning. diff --git a/tests/test_renderers/fixtures/docutil_section_refs.md b/tests/test_renderers/fixtures/docutil_section_refs.md new file mode 100644 index 00000000..4d14bb6a --- /dev/null +++ b/tests/test_renderers/fixtures/docutil_section_refs.md @@ -0,0 +1,178 @@ +[resolved] --myst-enable-extensions=section_ref +. +# Title + +Intro referencing §1, §1.1 and §2. + +## Section One + +### Sub One One + +## Section Two +. + + + Title + <paragraph> + Intro referencing + <reference classes="section-ref" internal="True" refid="section-one"> + §1 + , + <reference classes="section-ref" internal="True" refid="sub-one-one"> + §1.1 + and + <reference classes="section-ref" internal="True" refid="section-two"> + §2 + . + <section ids="section-one" names="section\ one"> + <title> + Section One + <section ids="sub-one-one" names="sub\ one\ one"> + <title> + Sub One One + <section ids="section-two" names="section\ two"> + <title> + Section Two +. + +[multiple-headings] --myst-enable-extensions=section_ref +. +See §1 and §2. + +# First + +# Second +. +<document source="<src>/index.md"> + <paragraph> + See + <reference classes="section-ref" internal="True" refid="first"> + §1 + and + <reference classes="section-ref" internal="True" refid="second"> + §2 + . + <section ids="first" names="first"> + <title> + First + <section ids="second" names="second"> + <title> + Second +. + +[forward-reference] --myst-enable-extensions=section_ref +. +# Title + +## First + +Text in first, see §2. + +## Second + +Text in second. +. +<document ids="title" names="title" source="<src>/index.md" title="Title"> + <title> + Title + <section ids="first" names="first"> + <title> + First + <paragraph> + Text in first, see + <reference classes="section-ref" internal="True" refid="second"> + §2 + . + <section ids="second" names="second"> + <title> + Second + <paragraph> + Text in second. +. + +[unresolved] --myst-enable-extensions=section_ref +. +# Title + +See §9.9 which does not exist. + +## Only Section +. +<document ids="title" names="title" source="<src>/index.md" title="Title"> + <title> + Title + <paragraph> + See + <inline classes="section-ref" section_number="9.9"> + §9.9 + which does not exist. + <section ids="only-section" names="only\ section"> + <title> + Only Section + + +<src>/index.md:3: (WARNING/2) Section reference target not found: '§9.9' [myst.section_ref] +. + +[in-link-text] --myst-enable-extensions=section_ref +. +# Title + +## One + +## Two + +A link [see §1](https://example.com) leaves §1 inert, but body §2 resolves. +. +<document ids="title" names="title" source="<src>/index.md" title="Title"> + <title> + Title + <section ids="one" names="one"> + <title> + One + <section ids="two" names="two"> + <title> + Two + <paragraph> + A link + <reference refuri="https://example.com"> + see + <inline classes="section-ref" section_number="1"> + §1 + leaves + <reference classes="section-ref" internal="True" refid="one"> + §1 + inert, but body + <reference classes="section-ref" internal="True" refid="two"> + §2 + resolves. +. + +[in-heading] --myst-enable-extensions=section_ref +. +# Title + +## Heading with §1 inside + +## Other + +Body §1 resolves to the first section. +. +<document ids="title" names="title" source="<src>/index.md" title="Title"> + <title> + Title + <section ids="heading-with-1-inside" names="heading\ with\ §1\ inside"> + <title> + Heading with + <inline classes="section-ref" section_number="1"> + §1 + inside + <section ids="other" names="other"> + <title> + Other + <paragraph> + Body + <reference classes="section-ref" internal="True" refid="heading-with-1-inside"> + §1 + resolves to the first section. +. diff --git a/tests/test_renderers/test_fixtures_docutils.py b/tests/test_renderers/test_fixtures_docutils.py index 4e52bb6b..fbe9ab3b 100644 --- a/tests/test_renderers/test_fixtures_docutils.py +++ b/tests/test_renderers/test_fixtures_docutils.py @@ -122,6 +122,35 @@ def _apply_transforms(self): ) +@pytest.mark.param_file(FIXTURE_PATH / "docutil_section_refs.md") +def test_section_refs(file_params: ParamTestData, normalize_doctree_xml): + """Test that ``§1.1`` section references resolve, or give the correct warning. + + The description is parsed as a docutils commandline. + Transforms are applied, since resolution happens in ``ResolveSectionRefs``. + """ + settings = settings_from_cmdline(file_params.description) + report_stream = StringIO() + settings["warning_stream"] = report_stream + doctree = publish_doctree( + file_params.content, + source_path="<src>/index.md", + parser=Parser(), + settings_overrides=settings, + ) + # docutils >=0.23 also inserts info-level (severity 1) system messages + # into the doctree; they are still written to the report stream, + # which is what the fixtures capture, so drop them from the tree + if docutils_version >= (0, 23): + for msg in list(doctree.findall(nodes.system_message)): + if msg["level"] < 2: + msg.parent.remove(msg) + outcome = normalize_doctree_xml(doctree.pformat()) + if report_stream.getvalue().strip(): + outcome += "\n\n" + report_stream.getvalue().strip() + file_params.assert_expected(outcome, rstrip_lines=True) + + @pytest.mark.param_file(FIXTURE_PATH / "docutil_syntax_extensions.txt") def test_syntax_extensions(file_params: ParamTestData, normalize_doctree_xml): """The description is parsed as a docutils commandline""" diff --git a/tests/test_sphinx/sourcedirs/extended_syntaxes/conf.py b/tests/test_sphinx/sourcedirs/extended_syntaxes/conf.py index 8a13151e..fdf67605 100644 --- a/tests/test_sphinx/sourcedirs/extended_syntaxes/conf.py +++ b/tests/test_sphinx/sourcedirs/extended_syntaxes/conf.py @@ -14,6 +14,7 @@ "tasklist", "attrs_inline", "attrs_block", + "section_ref", ] myst_number_code_blocks = ["typescript"] myst_html_meta = { diff --git a/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md b/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md index fd757f41..5e404e53 100644 --- a/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md +++ b/tests/test_sphinx/sourcedirs/extended_syntaxes/index.md @@ -78,3 +78,11 @@ Numbered code block: ```typescript type Result = "pass" | "fail" ``` + +## Section with §1 self-ref + +Body referencing §1 and §1.1. + +### Nested section + +## Second section diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html index 962b72cc..204a867d 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html @@ -190,6 +190,45 @@ <h1> </pre> </div> </div> + <section id="section-with-1-self-ref"> + <h2> + Section with + <span class="section-ref"> + §1 + </span> + self-ref + <a class="headerlink" href="#section-with-1-self-ref" title="Link to this heading"> + ¶ + </a> + </h2> + <p> + Body referencing + <a class="section-ref reference internal" href="#section-with-1-self-ref"> + §1 + </a> + and + <a class="section-ref reference internal" href="#nested-section"> + §1.1 + </a> + . + </p> + <section id="nested-section"> + <h3> + Nested section + <a class="headerlink" href="#nested-section" title="Link to this heading"> + ¶ + </a> + </h3> + </section> + </section> + <section id="second-section"> + <h2> + Second section + <a class="headerlink" href="#second-section" title="Link to this heading"> + ¶ + </a> + </h2> + </section> </section> </div> </div> diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml index f00cb508..2b5e8bf5 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml @@ -110,3 +110,23 @@ Numbered code block: <literal_block language="typescript" linenos="True" xml:space="preserve"> type Result = "pass" | "fail" + <section ids="section-with-1-self-ref" names="section\ with\ §1\ self-ref"> + <title> + Section with + <inline classes="section-ref" section_number="1"> + §1 + self-ref + <paragraph> + Body referencing + <reference classes="section-ref" internal="True" refid="section-with-1-self-ref"> + §1 + and + <reference classes="section-ref" internal="True" refid="nested-section"> + §1.1 + . + <section ids="nested-section" names="nested\ section"> + <title> + Nested section + <section ids="second-section" names="second\ section"> + <title> + Second section diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt index e35e2598..aa20aee6 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes_text.txt @@ -61,3 +61,17 @@ linkify URL: www.example.com Numbered code block: type Result = "pass" | "fail" + + +Section with §1 self-ref +======================== + +Body referencing §1 and §1.1. + + +Nested section +-------------- + + +Second section +============== From 83a9d72684c217b48a0b0fb4a595403dde07f14a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 16 Jul 2026 09:49:00 +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 <noreply@anthropic.com> 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 77f921c5..eaa00cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### ✨ 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>. +- ✨ 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 From 625ca2b6234cb0b8a7a1bb8e317733f0ff75a670 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 16 Jul 2026 14:09:26 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Consume=20parsed?= =?UTF-8?q?=20section=20numbers=20from=20token=20meta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section_ref plugin now provides meta["numbers"] as a list of ints, so the marker attribute becomes section_numbers and the transform no longer parses the dotted string itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwRkZLzpbHdb3pgmctmn6H --- myst_parser/mdit_to_docutils/base.py | 2 +- myst_parser/mdit_to_docutils/transforms.py | 6 +++--- tests/test_docutils.py | 6 ++++-- tests/test_renderers/fixtures/docutil_section_refs.md | 6 +++--- .../test_sphinx_builds/test_extended_syntaxes.xml | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index e072d5cf..3c76cca5 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -1279,7 +1279,7 @@ def render_span(self, token: SyntaxTreeNode) -> None: 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_number"] = token.meta["number"] + node["section_numbers"] = token.meta["numbers"] self.add_line_and_source_path(node, token) self.current_node.append(node) diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index f4316801..96ae8bc8 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -389,7 +389,7 @@ def _number_sections( def _leave_section_ref_inert(node: nodes.Element) -> bool: - """Whether a section-reference marker should be left as inert styled text. + """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: @@ -433,7 +433,7 @@ def apply(self, **kwargs: t.Any) -> None: markers = [ node for node in findall(self.document)(nodes.inline) - if "section_number" in node + if "section_numbers" in node ] if not markers: return @@ -453,7 +453,7 @@ def apply(self, **kwargs: t.Any) -> None: if _leave_section_ref_inert(node): continue content = node.astext() - number = tuple(int(part) for part in node["section_number"].split(".")) + number = tuple(node["section_numbers"]) section = number_map.get(number) if section is not None and section["ids"]: ref = nodes.reference( diff --git a/tests/test_docutils.py b/tests/test_docutils.py index 99b32f6f..a199dded 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -255,7 +255,7 @@ def test_section_ref_unresolved_warning(): assert "[myst.section_ref]" in stream.getvalue() # the reference is left in place as styled inline text inlines = [ - node for node in doctree.findall(nodes.inline) if "section_number" in node + node for node in doctree.findall(nodes.inline) if "section_numbers" in node ] assert [node.astext() for node in inlines] == ["§9.9"] @@ -305,7 +305,9 @@ def test_section_ref_left_inert_in_link_and_heading(): assert not list(ref.findall(nodes.reference, include_self=False)) # the heading ref and the in-link ref remain inert inline markers, # while the plain body ref resolves to the first section - inert = [node for node in doctree.findall(nodes.inline) if "section_number" in node] + inert = [ + node for node in doctree.findall(nodes.inline) if "section_numbers" in node + ] assert [node.astext() for node in inert] == ["§1", "§1"] resolved = [ ref diff --git a/tests/test_renderers/fixtures/docutil_section_refs.md b/tests/test_renderers/fixtures/docutil_section_refs.md index 4d14bb6a..0eac8788 100644 --- a/tests/test_renderers/fixtures/docutil_section_refs.md +++ b/tests/test_renderers/fixtures/docutil_section_refs.md @@ -103,7 +103,7 @@ See §9.9 which does not exist. Title <paragraph> See - <inline classes="section-ref" section_number="9.9"> + <inline classes="section-ref" section_numbers="9 9"> §9.9 which does not exist. <section ids="only-section" names="only\ section"> @@ -137,7 +137,7 @@ A link [see §1](https://example.com) leaves §1 inert, but body §2 resolves. A link <reference refuri="https://example.com"> see - <inline classes="section-ref" section_number="1"> + <inline classes="section-ref" section_numbers="1"> §1 leaves <reference classes="section-ref" internal="True" refid="one"> @@ -164,7 +164,7 @@ Body §1 resolves to the first section. <section ids="heading-with-1-inside" names="heading\ with\ §1\ inside"> <title> Heading with - <inline classes="section-ref" section_number="1"> + <inline classes="section-ref" section_numbers="1"> §1 inside <section ids="other" names="other"> diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml index 2b5e8bf5..611f9481 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml @@ -113,7 +113,7 @@ <section ids="section-with-1-self-ref" names="section\ with\ §1\ self-ref"> <title> Section with - <inline classes="section-ref" section_number="1"> + <inline classes="section-ref" section_numbers="1"> §1 self-ref <paragraph> From cc4cbb200efb1b216f3c2bbad52cb95f493cd4cc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 16 Jul 2026 15:23:14 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Add=20target=20ti?= =?UTF-8?q?tle=20tooltip=20to=20resolved=20section=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolved §refs now carry the target section's title as reftitle, rendered by Sphinx HTML as a hover tooltip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwRkZLzpbHdb3pgmctmn6H --- docs/syntax/optional.md | 2 +- myst_parser/mdit_to_docutils/transforms.py | 12 ++++++++++++ tests/test_docutils.py | 8 ++++---- .../fixtures/docutil_section_refs.md | 18 +++++++++--------- .../test_extended_syntaxes.html | 4 ++-- .../test_extended_syntaxes.xml | 4 ++-- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 7b984118..e885015b 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -348,7 +348,7 @@ See §1 and §1.1, and also §2. ## Section Two ``` -`§1` links to *Section One*, `§1.1` to *Sub One One*, and `§2` to *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): diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index 96ae8bc8..627ee28f 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -464,6 +464,18 @@ def apply(self, **kwargs: t.Any) -> None: 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( diff --git a/tests/test_docutils.py b/tests/test_docutils.py index a199dded..1ba23506 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -232,10 +232,10 @@ def test_section_ref_resolution(): settings_overrides={"myst_enable_extensions": ["section_ref"]}, ) refs = list(doctree.findall(nodes.reference)) - assert [(ref.astext(), ref["refid"]) for ref in refs] == [ - ("§1", "section-one"), - ("§1.1", "sub-one-one"), - ("§2", "section-two"), + assert [(ref.astext(), ref["refid"], ref["reftitle"]) for ref in refs] == [ + ("§1", "section-one", "Section One"), + ("§1.1", "sub-one-one", "Sub One One"), + ("§2", "section-two", "Section Two"), ] assert all("section-ref" in ref["classes"] and ref["internal"] for ref in refs) diff --git a/tests/test_renderers/fixtures/docutil_section_refs.md b/tests/test_renderers/fixtures/docutil_section_refs.md index 0eac8788..bfa3c78a 100644 --- a/tests/test_renderers/fixtures/docutil_section_refs.md +++ b/tests/test_renderers/fixtures/docutil_section_refs.md @@ -15,13 +15,13 @@ Intro referencing §1, §1.1 and §2. Title <paragraph> Intro referencing - <reference classes="section-ref" internal="True" refid="section-one"> + <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One"> §1 , - <reference classes="section-ref" internal="True" refid="sub-one-one"> + <reference classes="section-ref" internal="True" refid="sub-one-one" reftitle="Sub One One"> §1.1 and - <reference classes="section-ref" internal="True" refid="section-two"> + <reference classes="section-ref" internal="True" refid="section-two" reftitle="Section Two"> §2 . <section ids="section-one" names="section\ one"> @@ -46,10 +46,10 @@ See §1 and §2. <document source="<src>/index.md"> <paragraph> See - <reference classes="section-ref" internal="True" refid="first"> + <reference classes="section-ref" internal="True" refid="first" reftitle="First"> §1 and - <reference classes="section-ref" internal="True" refid="second"> + <reference classes="section-ref" internal="True" refid="second" reftitle="Second"> §2 . <section ids="first" names="first"> @@ -80,7 +80,7 @@ Text in second. First <paragraph> Text in first, see - <reference classes="section-ref" internal="True" refid="second"> + <reference classes="section-ref" internal="True" refid="second" reftitle="Second"> §2 . <section ids="second" names="second"> @@ -140,10 +140,10 @@ A link [see §1](https://example.com) leaves §1 inert, but body §2 resolves. <inline classes="section-ref" section_numbers="1"> §1 leaves - <reference classes="section-ref" internal="True" refid="one"> + <reference classes="section-ref" internal="True" refid="one" reftitle="One"> §1 inert, but body - <reference classes="section-ref" internal="True" refid="two"> + <reference classes="section-ref" internal="True" refid="two" reftitle="Two"> §2 resolves. . @@ -172,7 +172,7 @@ Body §1 resolves to the first section. Other <paragraph> Body - <reference classes="section-ref" internal="True" refid="heading-with-1-inside"> + <reference classes="section-ref" internal="True" refid="heading-with-1-inside" reftitle="Heading with §1 inside"> §1 resolves to the first section. . diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html index 204a867d..efbf3061 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.html @@ -203,11 +203,11 @@ <h2> </h2> <p> Body referencing - <a class="section-ref reference internal" href="#section-with-1-self-ref"> + <a class="section-ref reference internal" href="#section-with-1-self-ref" title="Section with §1 self-ref"> §1 </a> and - <a class="section-ref reference internal" href="#nested-section"> + <a class="section-ref reference internal" href="#nested-section" title="Nested section"> §1.1 </a> . diff --git a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml index 611f9481..57f23fc6 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml @@ -118,10 +118,10 @@ self-ref <paragraph> Body referencing - <reference classes="section-ref" internal="True" refid="section-with-1-self-ref"> + <reference classes="section-ref" internal="True" refid="section-with-1-self-ref" reftitle="Section with §1 self-ref"> §1 and - <reference classes="section-ref" internal="True" refid="nested-section"> + <reference classes="section-ref" internal="True" refid="nested-section" reftitle="Nested section"> §1.1 . <section ids="nested-section" names="nested\ section">