From b322ef79d6f66062d4426bba13949e8066ddd891 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 09:47:44 +0000 Subject: [PATCH 01/18] =?UTF-8?q?=E2=9C=A8=20NEW:=20Add=20section=5Fref=20?= =?UTF-8?q?syntax=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 02/18] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Add=20PR=20referen?= =?UTF-8?q?ce=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 03/18] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Consume=20parse?= =?UTF-8?q?d=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 04/18] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Add=20target=20?= =?UTF-8?q?title=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"> From fff540860c43f23b61f38fa8ed4d816d2aeecdc5 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 12:55:31 +0100 Subject: [PATCH 05/18] =?UTF-8?q?=F0=9F=94=A7=20MAINTAIN:=20Drop=20the=20c?= =?UTF-8?q?hangelog=20entry=20(written=20at=20release=20time)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa00cc0..ff2d904f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,5 @@ # 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 From 1d0f9c9b8f0d5d60d3dbaf0e4c273c717bb5c2d7 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 13:04:15 +0100 Subject: [PATCH 06/18] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Number=20section=20?= =?UTF-8?q?references=20the=20same=20in=20docutils=20and=20Sphinx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docutils promotes a lone top-level heading to the document title, and a lone child heading of that to the subtitle; Sphinx never does. The single-root heuristic then ran on the promoted tree, so in docutils "# T / See §1 §1.1 / ## A / ### A1" resolved §1 to A1 and left §1.1 unresolved, while Sphinx resolved §1 to A and §1.1 to A1. The numbering now recovers the heading hierarchy from the promoted title and subtitle, so §1 can target the subtitle, and a reference written in the subtitle stays inert, as it does in a Sphinx heading. A parity test runs each case through both parsers. --- myst_parser/mdit_to_docutils/transforms.py | 88 ++++++++--- .../fixtures/docutil_section_refs.md | 57 +++++++ tests/test_renderers/test_section_refs.py | 145 ++++++++++++++++++ 3 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 tests/test_renderers/test_section_refs.py diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index 99f37cec..28fce817 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -391,7 +391,7 @@ def _child_sections(node: nodes.Element) -> list[nodes.section]: def _number_sections( sections: list[nodes.section], prefix: tuple[int, ...], - number_map: dict[tuple[int, ...], nodes.section], + number_map: dict[tuple[int, ...], nodes.Element], ) -> None: """Recursively assign structural numbers to sections, populating ``number_map``. @@ -413,12 +413,14 @@ def _leave_section_ref_inert(node: nodes.Element) -> bool: 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. + ``<a>`` in those navs; or + - a ``subtitle`` — this is a heading that docutils promoted to the document + subtitle, and it stays inert as the same heading does in sphinx. """ parent = node.parent while parent is not None: if ( - isinstance(parent, nodes.reference | nodes.title) + isinstance(parent, nodes.reference | nodes.title | nodes.subtitle) or parent.tagname == "pending_xref" ): return True @@ -426,6 +428,48 @@ def _leave_section_ref_inert(node: nodes.Element) -> bool: return False +def _section_number_map( + document: nodes.document, +) -> dict[tuple[int, ...], nodes.Element]: + """Map structural section numbers to their target nodes. + + The numbering is that of the document's heading hierarchy as written: + when there is a single top-level heading (the common ``# Title`` layout), + its child sections are numbered ``1``, ``2``, ...; otherwise the + top-level sections themselves are. + + Sphinx never promotes a heading to the document title, so its doctree + keeps that hierarchy. docutils' ``DocTitle`` transform (on by default) + promotes the single top-level section to the document ``title``, and if + that section in turn holds nothing but one child section, promotes that + child to the document ``subtitle``. Their child sections are then + attached directly to the document, so the numbering is recovered from + the promoted ``title`` and ``subtitle`` nodes, and the same reference + resolves to the same heading in docutils and sphinx. + """ + number_map: dict[tuple[int, ...], nodes.Element] = {} + roots = _child_sections(document) + if any(isinstance(child, nodes.title) for child in document.children): + # docutils promoted the single top-level section to the document title, + # so its child sections are now the document's top-level sections + subtitle = next( + (child for child in document.children if isinstance(child, nodes.subtitle)), + None, + ) + if subtitle is None: + _number_sections(roots, (), number_map) + else: + # ... and its only child section to the subtitle, which is ``1``, + # so the top-level sections are that section's children ``1.1``, ... + number_map[(1,)] = subtitle + _number_sections(roots, (1,), number_map) + elif len(roots) == 1: + _number_sections(_child_sections(roots[0]), (), number_map) + else: + _number_sections(roots, (), number_map) + return number_map + + class ResolveSectionRefs(Transform): """Resolve ``§1.1`` section references to the target section's anchor. @@ -436,7 +480,8 @@ class ResolveSectionRefs(Transform): 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. + toctree), and accounts for doctitle promotion (see ``_section_number_map``), + so the same references resolve identically in docutils and sphinx. """ default_priority = 878 # before ResolveAnchorIds (879)/DoctreeReadEvent (880) @@ -454,14 +499,7 @@ def apply(self, **kwargs: t.Any) -> None: 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) + number_map = _section_number_map(self.document) for node in markers: # a reference nested inside a link or heading would produce invalid @@ -470,25 +508,29 @@ def apply(self, **kwargs: t.Any) -> None: continue content = node.astext() number = tuple(node["section_numbers"]) - section = number_map.get(number) - if section is not None and section["ids"]: + target = number_map.get(number) + if target is not None and target["ids"]: ref = nodes.reference( "", "", internal=True, - refid=section["ids"][0], + refid=target["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, + # sphinx HTML): a section's ``title`` child, or the subtitle itself + title_node = ( + target + if isinstance(target, nodes.subtitle) + else next( + ( + child + for child in target.children + if isinstance(child, nodes.title) + ), + None, + ) ) if title_node is not None: ref["reftitle"] = clean_astext(title_node) diff --git a/tests/test_renderers/fixtures/docutil_section_refs.md b/tests/test_renderers/fixtures/docutil_section_refs.md index bfa3c78a..11763373 100644 --- a/tests/test_renderers/fixtures/docutil_section_refs.md +++ b/tests/test_renderers/fixtures/docutil_section_refs.md @@ -176,3 +176,60 @@ Body §1 resolves to the first section. §1 resolves to the first section. . + +[title-and-one-section] --myst-enable-extensions=section_ref +. +# Title + +See §1 and §1.1. + +## Section One + +### Sub One One +. +<document ids="title" names="title" source="<src>/index.md" title="Title"> + <title> + Title + <paragraph> + See + <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One"> + §1 + and + <reference classes="section-ref" internal="True" refid="sub-one-one" reftitle="Sub One One"> + §1.1 + . + <section ids="section-one" names="section\ one"> + <title> + Section One + <section ids="sub-one-one" names="sub\ one\ one"> + <title> + Sub One One +. + +[subtitle] --myst-enable-extensions=section_ref +. +# Title + +## Subtitle + +See §1 and §1.1. + +### Section One +. +<document ids="title" names="title" source="<src>/index.md" title="Title"> + <title> + Title + <subtitle ids="subtitle" names="subtitle"> + Subtitle + <paragraph> + See + <reference classes="section-ref" internal="True" refid="subtitle" reftitle="Subtitle"> + §1 + and + <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One"> + §1.1 + . + <section ids="section-one" names="section\ one"> + <title> + Section One +. diff --git a/tests/test_renderers/test_section_refs.py b/tests/test_renderers/test_section_refs.py new file mode 100644 index 00000000..8470ec60 --- /dev/null +++ b/tests/test_renderers/test_section_refs.py @@ -0,0 +1,145 @@ +"""``§1.1`` section references resolve to the same heading in docutils and sphinx. + +docutils promotes a lone top-level heading to the document title +(and a lone child heading of that to the subtitle), but sphinx never does, +so the two doctrees differ in shape. +The numbering is that of the headings as written, so each case is checked +against both parsers, with the same expected result. +""" + +from __future__ import annotations + +import io + +import pytest +from docutils import nodes +from docutils.core import publish_doctree +from sphinx_pytest.plugin import CreateDoctree + +from myst_parser.parsers.docutils_ import Parser + +CASES = [ + pytest.param( + "# T\n\n## A\n\n### A1\n\n## B\n\nSee §1 §1.1 §2.\n", + [("§1", "a"), ("§1.1", "a1"), ("§2", "b")], + [], + id="title-and-two-sections", + ), + pytest.param( + # docutils promotes T to the title, leaving A as the only section; + # A is still §1, not the root whose children are numbered + "# T\n\nSee §1 and §1.1.\n\n## A\n\n### A1\n", + [("§1", "a"), ("§1.1", "a1")], + [], + id="title-and-one-section", + ), + pytest.param( + # docutils also promotes A to the subtitle, as T holds nothing else + "# T\n\n## A\n\nSee §1 and §1.1.\n\n### B\n", + [("§1", "a"), ("§1.1", "b")], + [], + id="subtitle", + ), + pytest.param( + "# T\n\n## A\n\nSee §1, not §2.\n", + [("§1", "a")], + ["§2"], + id="subtitle-only", + ), + pytest.param( + # a reference in the subtitle stays inert, as it does in the heading + "# T\n\n## A §1\n\nSee §1 and §1.1.\n\n### B\n", + [("§1", "a-1"), ("§1.1", "b")], + ["§1"], + id="reference-in-subtitle", + ), + pytest.param( + "## A\n\nSee §1 and §1.1.\n\n### A1\n", + [("§1", "a1")], + ["§1.1"], + id="single-root-below-h1", + ), + pytest.param( + "See §1 and §2.\n\n# A\n\n# B\n", + [("§1", "a"), ("§2", "b")], + [], + id="several-top-level", + ), + pytest.param( + # text before the heading means docutils promotes nothing + "See §1.\n\n# T\n\n## A\n", + [("§1", "a")], + [], + id="no-promotion", + ), +] + + +def _summarise(doctree: nodes.document) -> tuple[list, list]: + """Return the resolved ``(text, refid)`` pairs and the texts left unresolved.""" + resolved = [ + (ref.astext(), ref["refid"]) + for ref in doctree.findall(nodes.reference) + if "section-ref" in ref["classes"] + ] + unresolved = [ + node.astext() + for node in doctree.findall(nodes.inline) + if "section_numbers" in node + ] + return resolved, unresolved + + +@pytest.mark.parametrize("source,resolved,unresolved", CASES) +def test_docutils(source: str, resolved: list, unresolved: list): + doctree = publish_doctree( + source, + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "warning_stream": io.StringIO(), + }, + ) + assert _summarise(doctree) == (resolved, unresolved) + + +@pytest.mark.parametrize("source,resolved,unresolved", CASES) +def test_sphinx( + source: str, + resolved: list, + unresolved: list, + sphinx_doctree: CreateDoctree, +): + sphinx_doctree.set_conf( + {"extensions": ["myst_parser"], "myst_enable_extensions": ["section_ref"]} + ) + result = sphinx_doctree(source, "index.md") + assert _summarise(result.doctrees["index"]) == (resolved, unresolved) + + +def test_docutils_without_doctitle_promotion(): + """With ``doctitle_xform`` off, docutils keeps the headings as sphinx does.""" + doctree = publish_doctree( + "# T\n\nSee §1 and §1.1.\n\n## A\n\n### A1\n", + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "doctitle_xform": False, + }, + ) + assert _summarise(doctree) == ([("§1", "a"), ("§1.1", "a1")], []) + + +def test_subtitle_tooltip(): + """A reference to the promoted subtitle carries its text as the tooltip.""" + doctree = publish_doctree( + "# T\n\n## The *A*\n\nSee §1.\n", + parser=Parser(), + settings_overrides={"myst_enable_extensions": ["section_ref"]}, + ) + (ref,) = ( + ref + for ref in doctree.findall(nodes.reference) + if "section-ref" in ref["classes"] + ) + assert ref["reftitle"] == "The A" From e1c49d51a9f517c8698c6343ef58e8d99c4fc137 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 13:04:15 +0100 Subject: [PATCH 07/18] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Keep=20section=20re?= =?UTF-8?q?ferences=20in=20image=20alt=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A section_ref token has no children, so renderInlineAsText dropped it, and "![a §1 b](x.png)" gave alt="a b". Its content, the reference as written, is now kept. --- myst_parser/mdit_to_docutils/base.py | 4 +++- .../fixtures/docutil_section_refs.md | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index e1ef77e8..7b1808af 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -508,10 +508,12 @@ def renderInlineAsText(self, tokens: list[SyntaxTreeNode]) -> str: # noqa: N802 result = "" for token in tokens or []: - if token.type in {"text", "text_special"}: + if token.type in {"text", "text_special", "section_ref"}: # An escape or entity is a text_special token with no children. # Its content is the character the alt text must keep # (executablebooks/MyST-Parser#1210). + # A section reference is also a childless token, + # whose content is the reference as written (e.g. "§1.1"). result += token.content else: result += self.renderInlineAsText(token.children or []) diff --git a/tests/test_renderers/fixtures/docutil_section_refs.md b/tests/test_renderers/fixtures/docutil_section_refs.md index 11763373..8efe5ca4 100644 --- a/tests/test_renderers/fixtures/docutil_section_refs.md +++ b/tests/test_renderers/fixtures/docutil_section_refs.md @@ -233,3 +233,20 @@ See §1 and §1.1. <title> Section One . + +[image-alt] --myst-enable-extensions=section_ref +. +# Title + +## One + +![a §1 *b §1.1* c](x.png) +. +<document ids="title" names="title" source="<src>/index.md" title="Title"> + <title> + Title + <subtitle ids="one" names="one"> + One + <paragraph> + <image alt="a §1 b §1.1 c" uri="x.png"> +. From 8999b57e89503dd8c75cf643ec05bd5518ed628a Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 13:04:15 +0100 Subject: [PATCH 08/18] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Section=20referenc?= =?UTF-8?q?es:=20promotion,=20numbered=20toctrees,=20citations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Say that docutils title/subtitle promotion no longer changes what a reference targets, that §-numbers are not the numbers a :numbered: toctree displays, and that legal citations and references into other specifications (RFC 9110 §8.3) are read as local references, with § as the way to write a literal sign. Unresolved and inert references are "marked with the section-ref class", not styled: no CSS ships with the extension. --- docs/syntax/optional.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 03fe35ff..5d468c47 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -348,25 +348,43 @@ 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*, each link also carrying the target heading's title as a hover tooltip. +`§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 in Sphinx HTML output. -The numbering is **document-local** and purely **structural**, computed from the heading hierarchy (independent of any `toctree` `:numbered:` numbering): +The numbering is **document-local** and purely **structural**, computed from the heading hierarchy as written in the source: - 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. +The numbering is the same in Sphinx and in single-page docutils output. +docutils promotes a lone top-level heading to the document title and, if that heading holds nothing but one subsection, promotes the subsection's heading to the subtitle. +References still resolve as if nothing had been promoted, so `§1` then links to the subtitle, and `§1.1` to the first subsection under it. + 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`). +A `§` number directly followed by an ASCII letter, such as `§1a`, is not a reference. + +References that appear **inside a heading or inside link text** are intentionally left as text marked with the `section-ref` class, 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). + +:::{important} +§-numbers are **not** the section numbers that Sphinx displays. +In a `toctree` with the `:numbered:` option, Sphinx numbers sections across the documents it lists, so *Section One* above could be displayed as "2.1.", while `§1` still targets it, as the first section of its own document. +::: -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). +:::{caution} +With the extension enabled, every `§` followed by a number is read as a reference into the **current** document: + +- A legal citation such as `§242 BGB` gives a `myst.section_ref` warning, or links to section 242 if the document has one. +- A reference to another specification, such as `RFC 9110 §8.3`, links to section 8.3 *of the current document* if it has one, with no warning. + +To write a `§` that is not a reference, use the `§` entity (e.g. `§242 BGB`). +A backslash does not escape it, since `§` is not ASCII punctuation, so `\§242` keeps the backslash. +::: :::{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. +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 text marked with the `section-ref` class, 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. ::: From 567f31574e4bbb4fd61fb05bfa11e5e1bb124d53 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 13:05:29 +0100 Subject: [PATCH 09/18] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Say=20that=20a=20b?= =?UTF-8?q?ackslash=20stops=20a=20section=20reference=20but=20stays=20visi?= =?UTF-8?q?ble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/syntax/optional.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 5d468c47..7d949940 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -377,7 +377,7 @@ With the extension enabled, every `§` followed by a number is read as a referen - A reference to another specification, such as `RFC 9110 §8.3`, links to section 8.3 *of the current document* if it has one, with no warning. To write a `§` that is not a reference, use the `§` entity (e.g. `§242 BGB`). -A backslash does not escape it, since `§` is not ASCII punctuation, so `\§242` keeps the backslash. +A backslash before the `§` (`\§242`) also stops the reference, but stays in the output, since `§` is not ASCII punctuation. ::: :::{note} From 23ffc4858e90f9a01b76f7b035e2cf78f0b9ca43 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 13:26:43 +0100 Subject: [PATCH 10/18] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20REFACTOR:=20Resolve?= =?UTF-8?q?=20section=20references=20before=20docutils'=20title=20promotio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run ResolveSectionRefs at priority 300, after the transforms that settle each section's first id (PropagateTargets 260, SortIds 261, PrioritiseExplicitIds 262) and before docutils' DocTitle (320) and SectSubTitle (350). The doctree is then the headings as written in both docutils and sphinx, so the code that recovered the numbering from a promoted title or subtitle is gone, and the sectsubtitle_xform case now also agrees with sphinx. A link to a heading that docutils later promotes to the subtitle still lands, since the promotion copies the section ids. Also: - only section headings leave a reference inert; admonition, table, topic and sidebar titles are not copied into navigation, so references there now resolve (and a typo there now warns); - the unresolved-reference warning is located at the node, so inside an {include} it names the included file rather than the host file. New tests pin both priority bounds (an explicit target's id, and the promotion cases), the sectsubtitle_xform case, the pending_xref inert rule, admonition titles and the warning location. --- myst_parser/mdit_to_docutils/transforms.py | 133 ++++++++++----------- tests/test_renderers/test_section_refs.py | 82 ++++++++++++- 2 files changed, 140 insertions(+), 75 deletions(-) diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index 28fce817..df373ca0 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -391,7 +391,7 @@ def _child_sections(node: nodes.Element) -> list[nodes.section]: def _number_sections( sections: list[nodes.section], prefix: tuple[int, ...], - number_map: dict[tuple[int, ...], nodes.Element], + number_map: dict[tuple[int, ...], nodes.section], ) -> None: """Recursively assign structural numbers to sections, populating ``number_map``. @@ -404,6 +404,25 @@ def _number_sections( _number_sections(_child_sections(section), number, number_map) +def _section_number_map( + document: nodes.document, +) -> dict[tuple[int, ...], nodes.section]: + """Map structural section numbers to their sections. + + The numbering is that of the document's heading hierarchy as written: + when there is a single top-level heading (the common ``# Title`` layout), + its child sections are numbered ``1``, ``2``, ...; otherwise the + top-level sections themselves are. + """ + number_map: dict[tuple[int, ...], nodes.section] = {} + roots = _child_sections(document) + if len(roots) == 1: + _number_sections(_child_sections(roots[0]), (), number_map) + else: + _number_sections(roots, (), number_map) + return 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. @@ -411,80 +430,50 @@ def _leave_section_ref_inert(node: nodes.Element) -> bool: - 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; or - - a ``subtitle`` — this is a heading that docutils promoted to the document - subtitle, and it stays inert as the same heading does in sphinx. + - a section heading — a reference there would be copied into navigation + and toc entries (sphinx toctree entry links, docutils contents entries), + again nesting ``<a>`` in those navs. + + Other titles (admonition, table, topic or sidebar titles) are not copied + into navigation, so references in them are resolved as usual. """ parent = node.parent while parent is not None: if ( - isinstance(parent, nodes.reference | nodes.title | nodes.subtitle) + isinstance(parent, nodes.reference) or parent.tagname == "pending_xref" + or ( + isinstance(parent, nodes.title) + and isinstance(parent.parent, nodes.section) + ) ): return True parent = parent.parent return False -def _section_number_map( - document: nodes.document, -) -> dict[tuple[int, ...], nodes.Element]: - """Map structural section numbers to their target nodes. - - The numbering is that of the document's heading hierarchy as written: - when there is a single top-level heading (the common ``# Title`` layout), - its child sections are numbered ``1``, ``2``, ...; otherwise the - top-level sections themselves are. - - Sphinx never promotes a heading to the document title, so its doctree - keeps that hierarchy. docutils' ``DocTitle`` transform (on by default) - promotes the single top-level section to the document ``title``, and if - that section in turn holds nothing but one child section, promotes that - child to the document ``subtitle``. Their child sections are then - attached directly to the document, so the numbering is recovered from - the promoted ``title`` and ``subtitle`` nodes, and the same reference - resolves to the same heading in docutils and sphinx. - """ - number_map: dict[tuple[int, ...], nodes.Element] = {} - roots = _child_sections(document) - if any(isinstance(child, nodes.title) for child in document.children): - # docutils promoted the single top-level section to the document title, - # so its child sections are now the document's top-level sections - subtitle = next( - (child for child in document.children if isinstance(child, nodes.subtitle)), - None, - ) - if subtitle is None: - _number_sections(roots, (), number_map) - else: - # ... and its only child section to the subtitle, which is ``1``, - # so the top-level sections are that section's children ``1.1``, ... - number_map[(1,)] = subtitle - _number_sections(roots, (1,), number_map) - elif len(roots) == 1: - _number_sections(_child_sections(roots[0]), (), number_map) - else: - _number_sections(roots, (), number_map) - return number_map - - 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), and accounts for doctitle promotion (see ``_section_number_map``), - so the same references resolve identically in docutils and sphinx. + Numbering is document-local and purely structural (independent of any + ``:numbered:`` toctree), see ``_section_number_map``. + + The transform runs on the headings as written, so the same references + resolve identically in docutils and sphinx: after the id transforms, so + each section's first id is final, and before docutils' doctitle and + section-subtitle promotion, which sphinx never applies. + A link to a heading that docutils later promotes to the subtitle still + lands, because the promotion copies the section's ids onto the subtitle. """ - default_priority = 878 # before ResolveAnchorIds (879)/DoctreeReadEvent (880) + # strictly after docutils' PropagateTargets (260), sphinx's SortIds (261) + # and PrioritiseExplicitIds (262), which settle each section's first id + # (AddSlugIds (700) only appends), and strictly before docutils' DocTitle + # (320) and SectSubTitle (350), which reshape the section tree; + # this is also before sphinx's DoctreeReadEvent (880), so doctree-read + # consumers (``env.titles``, the toctree collector) see the resolved + # references rather than the raw markers + default_priority = 300 def apply(self, **kwargs: t.Any) -> None: """Apply the transform.""" @@ -518,27 +507,25 @@ 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): a section's ``title`` child, or the subtitle itself - title_node = ( - target - if isinstance(target, nodes.subtitle) - else next( - ( - child - for child in target.children - if isinstance(child, nodes.title) - ), - None, - ) + # add the section title as a hover tooltip (``title="..."`` in + # sphinx HTML) + title_node = next( + ( + child + for child in target.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: + # ``node=`` (not ``line=``) so the location is the file the + # reference was written in, which differs inside an ``{include}`` create_warning( self.document, f"Section reference target not found: {content!r}", MystWarnings.SECTION_REF, - line=node.line, + node=node, ) diff --git a/tests/test_renderers/test_section_refs.py b/tests/test_renderers/test_section_refs.py index 8470ec60..0fac0804 100644 --- a/tests/test_renderers/test_section_refs.py +++ b/tests/test_renderers/test_section_refs.py @@ -3,8 +3,9 @@ docutils promotes a lone top-level heading to the document title (and a lone child heading of that to the subtitle), but sphinx never does, so the two doctrees differ in shape. -The numbering is that of the headings as written, so each case is checked -against both parsers, with the same expected result. +The references are resolved before that promotion, against the headings as written, +so each case is checked against both parsers, with the same expected result; +a resolution that ran after the promotion would fail the docutils side. """ from __future__ import annotations @@ -65,6 +66,27 @@ [], id="several-top-level", ), + pytest.param( + # a reference in link text stays inert + "# T\n\n## A\n\n[see §1](#a)\n", + [], + ["§1"], + id="reference-in-anchor-link", + ), + pytest.param( + # the link goes to the explicit target's id, the section's first id + "# T\n\n(lbl)=\n## A\n\n## B\n\nSee §1.\n", + [("§1", "lbl")], + [], + id="explicit-target", + ), + pytest.param( + # only section headings are inert, other titles are not + "# T\n\n## A\n\n```{admonition} See §1\nx\n```\n", + [("§1", "a")], + [], + id="reference-in-admonition-title", + ), pytest.param( # text before the heading means docutils promotes nothing "See §1.\n\n# T\n\n## A\n", @@ -117,6 +139,21 @@ def test_sphinx( assert _summarise(result.doctrees["index"]) == (resolved, unresolved) +def test_sphinx_reference_in_document_link(sphinx_doctree: CreateDoctree): + """A reference in the text of a link to another document stays inert. + + sphinx parses such a link to a ``pending_xref`` rather than a ``reference`` + (docutils cannot resolve it at all, so this case is sphinx-only). + """ + sphinx_doctree.set_conf( + {"extensions": ["myst_parser"], "myst_enable_extensions": ["section_ref"]} + ) + result = sphinx_doctree("# T\n\n## A\n\n[see §1](other.md)\n", "index.md") + doctree = result.doctrees["index"] + assert any(node.tagname == "pending_xref" for node in doctree.findall()) + assert _summarise(doctree) == ([], ["§1"]) + + def test_docutils_without_doctitle_promotion(): """With ``doctitle_xform`` off, docutils keeps the headings as sphinx does.""" doctree = publish_doctree( @@ -130,6 +167,47 @@ def test_docutils_without_doctitle_promotion(): assert _summarise(doctree) == ([("§1", "a"), ("§1.1", "a1")], []) +def test_docutils_section_subtitles(): + """With ``sectsubtitle_xform`` on, references still follow the headings as written. + + docutils' ``SectSubTitle`` transform promotes C, the lone subsection of B, + to B's subtitle, but C is still ``§1.1.1``, as it is in sphinx. + """ + doctree = publish_doctree( + "# T\n\n## A\n\nSee §1.1 and §1.1.1.\n\n### B\n\n#### C\n\nx\n", + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "sectsubtitle_xform": True, + }, + ) + assert any( + isinstance(node.parent, nodes.section) + for node in doctree.findall(nodes.subtitle) + ), "the section subtitle promotion did not happen" + assert _summarise(doctree) == ([("§1.1", "b"), ("§1.1.1", "c")], []) + + +def test_docutils_warning_location_in_include(tmp_path): + """An unresolved reference in an included file is reported at that file.""" + (tmp_path / "index.md").write_text( + "# T\n\n## A\n\n```{include} sub.md\n```\n", encoding="utf8" + ) + (tmp_path / "sub.md").write_text("Para\n\nSee §7.\n", encoding="utf8") + warnings = io.StringIO() + publish_doctree( + (tmp_path / "index.md").read_text(encoding="utf8"), + source_path=str(tmp_path / "index.md"), + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "warning_stream": warnings, + }, + ) + location = f"{tmp_path / 'sub.md'}:3: (WARNING/2)" + assert f"{location} Section reference target not found" in warnings.getvalue() + + def test_subtitle_tooltip(): """A reference to the promoted subtitle carries its text as the tooltip.""" doctree = publish_doctree( From 8fcf04bd54d1ec05491b0e077918fc1f36c01cc5 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 13:26:43 +0100 Subject: [PATCH 11/18] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Section=20referenc?= =?UTF-8?q?es:=20positional=20numbers,=20includes,=20other=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Say that numbers are positional (inserting or moving a heading silently retargets later references; use a (label)= target where that matters), that a reference in an included file resolves against the including document, and that references in admonition or table titles resolve. Reword the docutils promotion paragraph to match resolution before it. --- docs/syntax/optional.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 7d949940..5ca5224c 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -357,13 +357,14 @@ The numbering is **document-local** and purely **structural**, computed from the - Deeper levels are appended with dots, so the second subsection of the first section is `§1.2`, and so on. The numbering is the same in Sphinx and in single-page docutils output. -docutils promotes a lone top-level heading to the document title and, if that heading holds nothing but one subsection, promotes the subsection's heading to the subtitle. -References still resolve as if nothing had been promoted, so `§1` then links to the subtitle, and `§1.1` to the first subsection under it. +docutils may promote a lone top-level heading to the document title, and a heading below it to the subtitle, +but references are resolved before that, against the headings as written, so `§1` can link to a heading that docutils displays as the subtitle. 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`). A `§` number directly followed by an ASCII letter, such as `§1a`, is not a reference. References that appear **inside a heading or inside link text** are intentionally left as text marked with the `section-ref` class, 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). +References in other titles, such as the title of an admonition or a table, are resolved as usual. :::{important} §-numbers are **not** the section numbers that Sphinx displays. @@ -382,6 +383,11 @@ A backslash before the `§` (`\§242`) also stops the reference, but stays in th :::{note} References resolve to positions **in the current document only**. +A reference written in a file that is pulled in with the `{include}` directive resolves against the numbering of the document that includes it. + +Numbers are positional: inserting or moving a heading changes what every later reference points to, without a warning. +Use a [`(label)=` target](syntax/targets) for references that must survive restructuring. + 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 text marked with the `section-ref` class, 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"]`. From 7b59988840588be1831225de2871b2b869683e21 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 14:08:29 +0100 Subject: [PATCH 12/18] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Section=20refer?= =?UTF-8?q?ences:=20conformance=20fixture,=20sections=20in=20{only}=20coun?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the docutils/Sphinx parity cases into one fixture file, tests/test_renderers/fixtures/section_ref_resolution.md, which states for each document which heading every reference resolves to (or that it is unresolved and warns, or inert and does not), so a second implementation can port it as data. It covers the documented numbering rules: single and several top-level headings, skipped levels, includes, eval-rst sections, rubrics, front matter and title_to_header, numbers as written (leading zeros, §0, the nine-digit limit), inert contexts, explicit targets, and headings in an {only} block. Sections inside an `only` node now count whatever its condition, so the numbering does not depend on the builder (an {eval-rst} `.. only::` keeps its sections inside the node). The unresolved warning now says "in this document", a resolved reference keeps its section_numbers attribute, and the priority comment no longer names consumers that never see a resolved reference. --- myst_parser/mdit_to_docutils/transforms.py | 24 +- .../fixtures/docutil_section_refs.md | 28 +- .../fixtures/section_ref_resolution.md | 451 ++++++++++++++++++ tests/test_renderers/test_section_refs.py | 292 ++++++++---- .../test_extended_syntaxes.xml | 4 +- 5 files changed, 679 insertions(+), 120 deletions(-) create mode 100644 tests/test_renderers/fixtures/section_ref_resolution.md diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index df373ca0..f5b65e09 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -384,8 +384,18 @@ def apply(self, **kwargs: t.Any) -> None: 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)] + """Return the child sections of an element, in document order. + + Sections inside a sphinx ``only`` node count, whatever its condition, + so that the numbering does not depend on the builder. + """ + sections: list[nodes.section] = [] + for child in node.children: + if isinstance(child, nodes.section): + sections.append(child) + elif child.tagname == "only": + sections.extend(_child_sections(child)) + return sections def _number_sections( @@ -396,7 +406,7 @@ def _number_sections( """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. + numbering recurses into each section's child sections. """ for i, section in enumerate(sections, start=1): number = prefix + (i,) @@ -470,9 +480,8 @@ class ResolveSectionRefs(Transform): # and PrioritiseExplicitIds (262), which settle each section's first id # (AddSlugIds (700) only appends), and strictly before docutils' DocTitle # (320) and SectSubTitle (350), which reshape the section tree; - # this is also before sphinx's DoctreeReadEvent (880), so doctree-read - # consumers (``env.titles``, the toctree collector) see the resolved - # references rather than the raw markers + # this is also before sphinx's DoctreeReadEvent (880), so anything that + # reads the body at doctree-read sees the resolved references default_priority = 300 def apply(self, **kwargs: t.Any) -> None: @@ -505,6 +514,7 @@ def apply(self, **kwargs: t.Any) -> None: internal=True, refid=target["ids"][0], classes=["section-ref"], + section_numbers=node["section_numbers"], ) ref += node.children # add the section title as a hover tooltip (``title="..."`` in @@ -525,7 +535,7 @@ def apply(self, **kwargs: t.Any) -> None: # reference was written in, which differs inside an ``{include}`` create_warning( self.document, - f"Section reference target not found: {content!r}", + f"Section reference target not found in this document: {content!r}", MystWarnings.SECTION_REF, node=node, ) diff --git a/tests/test_renderers/fixtures/docutil_section_refs.md b/tests/test_renderers/fixtures/docutil_section_refs.md index 8efe5ca4..812a482c 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" reftitle="Section One"> + <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One" section_numbers="1"> §1 , - <reference classes="section-ref" internal="True" refid="sub-one-one" reftitle="Sub One One"> + <reference classes="section-ref" internal="True" refid="sub-one-one" reftitle="Sub One One" section_numbers="1 1"> §1.1 and - <reference classes="section-ref" internal="True" refid="section-two" reftitle="Section Two"> + <reference classes="section-ref" internal="True" refid="section-two" reftitle="Section Two" section_numbers="2"> §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" reftitle="First"> + <reference classes="section-ref" internal="True" refid="first" reftitle="First" section_numbers="1"> §1 and - <reference classes="section-ref" internal="True" refid="second" reftitle="Second"> + <reference classes="section-ref" internal="True" refid="second" reftitle="Second" section_numbers="2"> §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" reftitle="Second"> + <reference classes="section-ref" internal="True" refid="second" reftitle="Second" section_numbers="2"> §2 . <section ids="second" names="second"> @@ -111,7 +111,7 @@ See §9.9 which does not exist. Only Section -<src>/index.md:3: (WARNING/2) Section reference target not found: '§9.9' [myst.section_ref] +<src>/index.md:3: (WARNING/2) Section reference target not found in this document: '§9.9' [myst.section_ref] . [in-link-text] --myst-enable-extensions=section_ref @@ -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" reftitle="One"> + <reference classes="section-ref" internal="True" refid="one" reftitle="One" section_numbers="1"> §1 inert, but body - <reference classes="section-ref" internal="True" refid="two" reftitle="Two"> + <reference classes="section-ref" internal="True" refid="two" reftitle="Two" section_numbers="2"> §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" reftitle="Heading with §1 inside"> + <reference classes="section-ref" internal="True" refid="heading-with-1-inside" reftitle="Heading with §1 inside" section_numbers="1"> §1 resolves to the first section. . @@ -192,10 +192,10 @@ See §1 and §1.1. Title <paragraph> See - <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One"> + <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One" section_numbers="1"> §1 and - <reference classes="section-ref" internal="True" refid="sub-one-one" reftitle="Sub One One"> + <reference classes="section-ref" internal="True" refid="sub-one-one" reftitle="Sub One One" section_numbers="1 1"> §1.1 . <section ids="section-one" names="section\ one"> @@ -223,10 +223,10 @@ See §1 and §1.1. Subtitle <paragraph> See - <reference classes="section-ref" internal="True" refid="subtitle" reftitle="Subtitle"> + <reference classes="section-ref" internal="True" refid="subtitle" reftitle="Subtitle" section_numbers="1"> §1 and - <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One"> + <reference classes="section-ref" internal="True" refid="section-one" reftitle="Section One" section_numbers="1 1"> §1.1 . <section ids="section-one" names="section\ one"> diff --git a/tests/test_renderers/fixtures/section_ref_resolution.md b/tests/test_renderers/fixtures/section_ref_resolution.md new file mode 100644 index 00000000..50e2ab89 --- /dev/null +++ b/tests/test_renderers/fixtures/section_ref_resolution.md @@ -0,0 +1,451 @@ +The conformance contract for `section_ref` resolution (docs/syntax/optional.md, "Numbering rules"), run through docutils and Sphinx. +Each case is a document, then one line per reference in document order: `§n -> <target heading title>` (`@<label>` if the target has a `(label)=`), `§n unresolved` (one warning) or `§n inert` (no link, no warning). +A `-- file: <name>` line starts an extra file; `[id] key=value` options set config, `sphinx-only` cases need `{only}`, `docutils:` options are docutils-only settings. + +[C1-title-and-sections] +. +# T + +See §1 §1.1 §1.2 §2 §2.1 §3. + +## A + +### A1 + +### A2 + +## B + +### B1 +. +§1 -> A +§1.1 -> A1 +§1.2 -> A2 +§2 -> B +§2.1 -> B1 +§3 unresolved +. + +[C2-title-and-one-section] +. +# T + +See §1 and §1.1. + +## A + +### A1 +. +§1 -> A +§1.1 -> A1 +. + +[C3-subtitle] +. +# T + +## A + +See §1 and §1.1. + +### B +. +§1 -> A +§1.1 -> B +. + +[C3-subtitle-only] +. +# T + +## A + +See §1, not §2. +. +§1 -> A +§2 unresolved +. + +[C4-several-top-level] +. +See §1 §1.1 §2 §2.1. + +# X + +## X1 + +# Y + +## Y1 +. +§1 -> X +§1.1 -> X1 +§2 -> Y +§2.1 -> Y1 +. + +[C5-no-h1] +. +## A + +### A1 + +## B + +See §1 §1.1 §2. +. +§1 -> A +§1.1 -> A1 +§2 -> B +. + +[C6-single-root-below-h1] +. +## A + +See §1 §2 §1.1 §1.2. + +### A1 + +### A2 +. +§1 -> A1 +§2 -> A2 +§1.1 unresolved +§1.2 unresolved +. + +[C7-numbers-as-written] +. +# T + +## A + +### A1 + +§0 §01 §1 §01.01 §1.0 §1.1.1.1 §99 §2.1 + +§000000001 §0000000001 §123456789 §1.1234567890 §1.000000001 +. +§0 unresolved +§01 -> A +§1 -> A +§01.01 -> A1 +§1.0 unresolved +§1.1.1.1 unresolved +§99 unresolved +§2.1 unresolved +§000000001 -> A +§123456789 unresolved +§1.000000001 -> A1 +. + +[C8-rubric-and-eval-rst] +. +# T + +## A + +```{note} +## In a note +``` + +## B + +```{eval-rst} +RST section +----------- + +text +``` + +## C + +See §1 §1.1 §2 §2.1 §3 §4. +. +§1 -> A +§1.1 unresolved +§2 -> B +§2.1 -> RST section +§3 -> C +§4 unresolved +. + +[C9-include] +. +# T + +## A + +```{include} part.md +``` + +## Z + +See §1 §2 §2.1 §3. +-- file: part.md +## P + +In the included file: §1 §2 §2.1 §9. + +### P1 +. +§1 -> A +§2 -> P +§2.1 -> P1 +§9 unresolved +§1 -> A +§2 -> P +§2.1 -> P1 +§3 -> Z +. + +[C13-inert-contexts] +. +# T §9 + +## A §1 + +## B §9 + +[link §1](#b), [link §9](https://example.com), *§1*, **§2** and §3. +. +§9 inert +§1 inert +§9 inert +§1 inert +§9 inert +§1 -> A §1 +§2 -> B §9 +§3 unresolved +. + +[C13-reference-in-subtitle] +. +# T + +## A §1 + +See §1 and §1.1. + +### B +. +§1 inert +§1 -> A §1 +§1.1 -> B +. + +[C13-reference-in-anchor-link] +. +# T + +## A + +[see §1](#a) +. +§1 inert +. + +[C13-reference-in-admonition-title] +. +# T + +## A + +```{admonition} See §1 +x +``` +. +§1 -> A +. + +[C14-duplicate-titles] +. +# T + +(lbl)= +## A + +## A + +## A + +See §1 §2 §3. +. +§1 -> A @lbl +§2 -> A +§3 -> A +. + +[C14-explicit-target] +. +# T + +(lbl)= +## A + +## B + +See §1 §2. +. +§1 -> A @lbl +§2 -> B +. + +[C15-skipped-level] +. +# T + +## A + +#### Deep + +### A2 + +See §1 §1.1 §1.2 §1.1.1. +. +§1 -> A +§1.1 -> Deep +§1.2 -> A2 +§1.1.1 unresolved +. + +[E2-title-and-subtitle] +. +# T + +## A + +### A1 + +See §1 §1.1. +. +§1 -> A +§1.1 -> A1 +. + +[E3-front-matter] +. +--- +author: Me +--- +# T + +## A + +### A1 + +See §1 §1.1. +. +§1 -> A +§1.1 -> A1 +. + +[E4-section-subtitles] docutils:sectsubtitle_xform=true +. +# T + +## A + +See §1.1 and §1.1.1. + +### B + +#### C + +x +. +§1.1 -> B +§1.1.1 -> C +. + +[E5-single-h2-root] +. +## A + +### A1 + +See §1 §1.1. + +#### A1a +. +§1 -> A1 +§1.1 -> A1a +. + +[E6-no-promotion] +. +See §1 §1.1. + +# T + +## A + +### A1 +. +§1 -> A +§1.1 -> A1 +. + +[E8-title-to-header] myst_title_to_header=true +. +--- +title: Front +--- +# H + +## H1 + +See §1 §2 §2.1 §1.1. +. +§1 -> Front +§2 -> H +§2.1 -> H1 +§1.1 unresolved +. + +[P7-only-inactive] sphinx-only +. +# T + +## A + +```{only} latex +## Only1 + +### Only1a +``` + +## A3 + +See §1 §2 §2.1 §3. +. +§1 -> A +§2 -> Only1 +§2.1 -> Only1a +§3 -> A3 +. + +[P7-only-inactive-eval-rst] sphinx-only +. +# T + +## A + +```{eval-rst} +.. only:: latex + + Only1 + ----- + + text +``` + +## A2 + +See §1 §1.1 §2. +. +§1 -> A +§1.1 -> Only1 +§2 -> A2 +. diff --git a/tests/test_renderers/test_section_refs.py b/tests/test_renderers/test_section_refs.py index 0fac0804..6c8c626f 100644 --- a/tests/test_renderers/test_section_refs.py +++ b/tests/test_renderers/test_section_refs.py @@ -4,97 +4,111 @@ (and a lone child heading of that to the subtitle), but sphinx never does, so the two doctrees differ in shape. The references are resolved before that promotion, against the headings as written, -so each case is checked against both parsers, with the same expected result; +so each case of ``fixtures/section_ref_resolution.md`` (the conformance contract) +is checked against both parsers, with the same expected result; a resolution that ran after the promotion would fail the docutils side. """ from __future__ import annotations import io +import re +from pathlib import Path import pytest from docutils import nodes from docutils.core import publish_doctree +from pytest_param_files import ParamTestData +from sphinx.util.console import strip_colors from sphinx_pytest.plugin import CreateDoctree from myst_parser.parsers.docutils_ import Parser -CASES = [ - pytest.param( - "# T\n\n## A\n\n### A1\n\n## B\n\nSee §1 §1.1 §2.\n", - [("§1", "a"), ("§1.1", "a1"), ("§2", "b")], - [], - id="title-and-two-sections", - ), - pytest.param( - # docutils promotes T to the title, leaving A as the only section; - # A is still §1, not the root whose children are numbered - "# T\n\nSee §1 and §1.1.\n\n## A\n\n### A1\n", - [("§1", "a"), ("§1.1", "a1")], - [], - id="title-and-one-section", - ), - pytest.param( - # docutils also promotes A to the subtitle, as T holds nothing else - "# T\n\n## A\n\nSee §1 and §1.1.\n\n### B\n", - [("§1", "a"), ("§1.1", "b")], - [], - id="subtitle", - ), - pytest.param( - "# T\n\n## A\n\nSee §1, not §2.\n", - [("§1", "a")], - ["§2"], - id="subtitle-only", - ), - pytest.param( - # a reference in the subtitle stays inert, as it does in the heading - "# T\n\n## A §1\n\nSee §1 and §1.1.\n\n### B\n", - [("§1", "a-1"), ("§1.1", "b")], - ["§1"], - id="reference-in-subtitle", - ), - pytest.param( - "## A\n\nSee §1 and §1.1.\n\n### A1\n", - [("§1", "a1")], - ["§1.1"], - id="single-root-below-h1", - ), - pytest.param( - "See §1 and §2.\n\n# A\n\n# B\n", - [("§1", "a"), ("§2", "b")], - [], - id="several-top-level", - ), - pytest.param( - # a reference in link text stays inert - "# T\n\n## A\n\n[see §1](#a)\n", - [], - ["§1"], - id="reference-in-anchor-link", - ), - pytest.param( - # the link goes to the explicit target's id, the section's first id - "# T\n\n(lbl)=\n## A\n\n## B\n\nSee §1.\n", - [("§1", "lbl")], - [], - id="explicit-target", - ), - pytest.param( - # only section headings are inert, other titles are not - "# T\n\n## A\n\n```{admonition} See §1\nx\n```\n", - [("§1", "a")], - [], - id="reference-in-admonition-title", - ), - pytest.param( - # text before the heading means docutils promotes nothing - "See §1.\n\n# T\n\n## A\n", - [("§1", "a")], - [], - id="no-promotion", - ), -] +FIXTURE_PATH = Path(__file__).parent.joinpath("fixtures") + +_FILE_RE = re.compile(r"^-- file: (\S+)\n", re.MULTILINE) +_WARNING_RE = re.compile( + r"Section reference target not found in this document: '([^']+)'" +) + + +def _split_files(content: str) -> tuple[str, dict[str, str]]: + """Split a case into its document and its extra files (``-- file: <name>`` lines).""" + parts = _FILE_RE.split(content) + return parts[0], dict(zip(parts[1::2], parts[2::2], strict=True)) + + +def _parse_options(description: str | None) -> tuple[bool, dict, dict]: + """Parse the case options into ``(sphinx_only, shared, docutils_only)`` settings.""" + sphinx_only = False + shared: dict = {} + docutils_only: dict = {} + for option in (description or "").split(): + if option == "sphinx-only": + sphinx_only = True + continue + key, value = option.split("=", 1) + parsed = {"true": True, "false": False}.get(value, value) + if key.startswith("docutils:"): + docutils_only[key.removeprefix("docutils:")] = parsed + else: + shared[key] = parsed + return sphinx_only, shared, docutils_only + + +def _is_inert(node: nodes.Element) -> bool: + """Whether a marker sits in a context where references are never linked (R3). + + In docutils, a section title may have been promoted to the document title or subtitle. + """ + parent = node.parent + while parent is not None: + if ( + isinstance(parent, (nodes.reference, nodes.subtitle)) + or parent.tagname == "pending_xref" + or ( + isinstance(parent, nodes.title) + and isinstance(parent.parent, (nodes.section, nodes.document)) + ) + ): + return True + parent = parent.parent + return False + + +def _target_title(doctree: nodes.document, refid: str) -> str: + """Return the title of the heading that holds ``refid``, plus ``@refid`` if explicit.""" + target = next( + node for node in doctree.findall(nodes.Element) if refid in node["ids"] + ) + if isinstance(target, (nodes.section, nodes.document)): + target = next( + child for child in target.children if isinstance(child, nodes.title) + ) + # an explicit target, such as ``(label)=``, leaves a ``target`` that points to it + explicit = any(node.get("refid") == refid for node in doctree.findall(nodes.target)) + return f"{target.astext()} @{refid}" if explicit else target.astext() + + +def _outcome(doctree: nodes.document, warnings: str) -> str: + """Summarise every reference, in document order, as in the fixture file. + + Each marker left unlinked is classed by its context, ``inert`` or ``unresolved``, + and the ``unresolved`` ones must be exactly the ones that warned. + """ + lines = [] + unresolved = [] + for node in doctree.findall(nodes.Element): + if isinstance(node, nodes.reference) and "section-ref" in node["classes"]: + lines.append(f"{node.astext()} -> {_target_title(doctree, node['refid'])}") + elif isinstance(node, nodes.inline) and "section_numbers" in node: + if _is_inert(node): + lines.append(f"{node.astext()} inert") + else: + lines.append(f"{node.astext()} unresolved") + unresolved.append(node.astext()) + assert sorted(_WARNING_RE.findall(warnings)) == sorted(unresolved) + return "\n".join(lines) def _summarise(doctree: nodes.document) -> tuple[list, list]: @@ -112,31 +126,49 @@ def _summarise(doctree: nodes.document) -> tuple[list, list]: return resolved, unresolved -@pytest.mark.parametrize("source,resolved,unresolved", CASES) -def test_docutils(source: str, resolved: list, unresolved: list): - doctree = publish_doctree( - source, - parser=Parser(), - settings_overrides={ - "myst_enable_extensions": ["section_ref"], - "warning_stream": io.StringIO(), - }, - ) - assert _summarise(doctree) == (resolved, unresolved) +@pytest.mark.param_file(FIXTURE_PATH / "section_ref_resolution.md") +def test_resolution( + file_params: ParamTestData, sphinx_doctree: CreateDoctree, tmp_path: Path +): + """Each conformance case gives the expected outcome in both docutils and sphinx.""" + source, files = _split_files(file_params.content) + sphinx_only, shared, docutils_only = _parse_options(file_params.description) + if not sphinx_only: + (tmp_path / "docutils").mkdir() + for name, text in files.items(): + (tmp_path / "docutils" / name).write_text(text, encoding="utf8") + warnings = io.StringIO() + doctree = publish_doctree( + source, + source_path=str(tmp_path / "docutils" / "index.md"), + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "warning_stream": warnings, + **shared, + **docutils_only, + }, + ) + file_params.assert_expected( + _outcome(doctree, warnings.getvalue()), rstrip_lines=True + ) -@pytest.mark.parametrize("source,resolved,unresolved", CASES) -def test_sphinx( - source: str, - resolved: list, - unresolved: list, - sphinx_doctree: CreateDoctree, -): + for name, text in files.items(): + sphinx_doctree.srcdir.joinpath(name).write_text(text, encoding="utf8") sphinx_doctree.set_conf( - {"extensions": ["myst_parser"], "myst_enable_extensions": ["section_ref"]} + { + "extensions": ["myst_parser"], + "myst_enable_extensions": ["section_ref"], + # extra files are only included, not documents of their own + "exclude_patterns": list(files), + **shared, + } ) result = sphinx_doctree(source, "index.md") - assert _summarise(result.doctrees["index"]) == (resolved, unresolved) + file_params.assert_expected( + _outcome(result.doctrees["index"], result.warnings), rstrip_lines=True + ) def test_sphinx_reference_in_document_link(sphinx_doctree: CreateDoctree): @@ -221,3 +253,69 @@ def test_subtitle_tooltip(): if "section-ref" in ref["classes"] ) assert ref["reftitle"] == "The A" + + +# an unresolvable number in a section heading and in link text, which stay inert, +# then one in the body, written with a leading zero +WARNING_SOURCE = "# T §9\n\n## A §9\n\n[see §9](#a-9) and §09.9.\n" +WARNING_MESSAGE = "Section reference target not found in this document: '§09.9'" + + +def test_docutils_warning_only_for_unresolved(): + """Only the unresolved body reference warns, with its number as written.""" + warnings = io.StringIO() + publish_doctree( + WARNING_SOURCE, + source_path="index.md", + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "warning_stream": warnings, + }, + ) + assert warnings.getvalue().splitlines() == [ + f"index.md:5: (WARNING/2) {WARNING_MESSAGE} [myst.section_ref]" + ] + + +def test_sphinx_warning_only_for_unresolved(sphinx_doctree: CreateDoctree): + """Only the unresolved body reference warns, with its number as written.""" + sphinx_doctree.set_conf( + {"extensions": ["myst_parser"], "myst_enable_extensions": ["section_ref"]} + ) + result = sphinx_doctree(WARNING_SOURCE, "index.md") + assert strip_colors(result.warnings).strip().splitlines() == [ + f"<src>/index.md:5: WARNING: {WARNING_MESSAGE} [myst.section_ref]" + ] + + +def test_sphinx_warning_suppressed(sphinx_doctree: CreateDoctree): + """``suppress_warnings = ["myst.section_ref"]`` silences the warning.""" + sphinx_doctree.set_conf( + { + "extensions": ["myst_parser"], + "myst_enable_extensions": ["section_ref"], + "suppress_warnings": ["myst.section_ref"], + } + ) + result = sphinx_doctree(WARNING_SOURCE, "index.md") + assert strip_colors(result.warnings).strip() == "" + assert _summarise(result.doctrees["index"])[1] == ["§9", "§9", "§9", "§09.9"] + + +def test_section_numbers_on_resolved_reference(): + """A resolved reference keeps the parsed number, as the unresolved marker does.""" + doctree = publish_doctree( + "# T\n\n## A\n\n### A1\n\nSee §01.1 and §7.\n", + parser=Parser(), + settings_overrides={ + "myst_enable_extensions": ["section_ref"], + "warning_stream": io.StringIO(), + }, + ) + numbers = [ + (node.tagname, node.astext(), node["section_numbers"]) + for node in doctree.findall(nodes.Element) + if "section_numbers" in node + ] + assert numbers == [("reference", "§01.1", [1, 1]), ("inline", "§7", [7])] 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 57f23fc6..234cc208 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" reftitle="Section with §1 self-ref"> + <reference classes="section-ref" internal="True" refid="section-with-1-self-ref" reftitle="Section with §1 self-ref" section_numbers="1"> §1 and - <reference classes="section-ref" internal="True" refid="nested-section" reftitle="Nested section"> + <reference classes="section-ref" internal="True" refid="nested-section" reftitle="Nested section" section_numbers="1 1"> §1.1 . <section ids="nested-section" names="nested\ section"> From b93e0f2e9053f062cbe74a0c72a319225fc215a0 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 14:08:29 +0100 Subject: [PATCH 13/18] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Section=20referenc?= =?UTF-8?q?es:=20numbering=20rules=20and=20the=20numref=20pointer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Numbering rules" subsection stating which text is a reference, which headings count, how they are numbered and how references resolve, including includes, eval-rst sections, {only} blocks, title_to_header, the nine-digit limit, and that a reference resolves against the document it is written in. Point to {numref} for citing the number a :numbered: toctree displays, and move the suppress_warnings hint next to the legal-citation caution. --- docs/syntax/optional.md | 52 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 5ca5224c..ac40ecd2 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -369,6 +369,7 @@ References in other titles, such as the title of an admonition or a table, are r :::{important} §-numbers are **not** the section numbers that Sphinx displays. In a `toctree` with the `:numbered:` option, Sphinx numbers sections across the documents it lists, so *Section One* above could be displayed as "2.1.", while `§1` still targets it, as the first section of its own document. +To cite the number that a `:numbered:` toctree displays, put a [`(label)=` target](syntax/targets) on the heading and use the [`{numref}` role](inv:sphinx:rst:role#numref) (with `numfig = True`), which by default prints "Section 2.1". ::: :::{caution} @@ -379,6 +380,9 @@ With the extension enabled, every `§` followed by a number is read as a referen To write a `§` that is not a reference, use the `§` entity (e.g. `§242 BGB`). A backslash before the `§` (`\§242`) also stops the reference, but stays in the output, since `§` is not ASCII punctuation. + +The warnings can be silenced with [`suppress_warnings`](sphinx/config-options), e.g. `suppress_warnings = ["myst.section_ref"]`, +but that also hides references that are broken. ::: :::{note} @@ -389,11 +393,57 @@ Numbers are positional: inserting or moving a heading changes what every later r Use a [`(label)=` target](syntax/targets) for references that must survive restructuring. 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 text marked with the `section-ref` class, 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"]`. Cross-document section references may be supported in a future release. ::: +### Numbering rules + +These rules say exactly which heading a reference points to. +Sphinx and docutils follow them in the same way, and MyST's test suite pins them. + +**What is a reference** + +- A reference is `§` followed by one or more levels separated by dots, each level being 1 to 9 ASCII digits (`§1`, `§2.10`, `§01.02`). + The longest such run is taken, but a trailing dot is not part of it, so `§1.` is `§1` followed by `.`. +- There is no reference, and the whole run stays as written, if the number is directly followed by an ASCII letter (`§1a`), + or if any level has more than nine digits, leading zeros included (`§0000000001`, `§1.1234567890.2`). + A non-ASCII letter does not stop a reference: `见§3章` contains `§3`. +- References are recognised wherever inline text is parsed, but not in code spans, autolinks, raw HTML or math. +- `\§` is not an escape: the backslash stays visible, and no reference is formed. + `\\§1` is a literal backslash followed by the reference `§1`. + To write a literal sign, use `§` or `§`. +- The link text is the reference as written (`§01.02`), and its number is the value of each level (1.2). + +**Which headings count** + +- Every heading that starts a section counts, in the order of the source, including: + - the headings of a file pulled in with `{include}`, at the point of inclusion and at the level they are written at; + - the sections of an `{eval-rst}` block, where the document places them; + - the headings inside an `{only}` block, whatever its condition, so that the numbers are the same for every builder; + - a front-matter `title` that [`myst_title_to_header`](#sphinx/config-options) turns into a heading: it is the first heading of the document. +- A heading inside the body of a directive, such as `{note}`, is rendered as a rubric, and does not count. + +**How headings are numbered** + +- A heading is nested under the nearest heading before it that has a smaller level, so a skipped level adds no number: + under `# Title`, a `####` heading that directly follows `## A` is `§1.1`. +- If the document has exactly one top-level heading, its child headings are `§1`, `§2`, …; otherwise the top-level headings are. +- Numbers start at 1, so `§0` never resolves. The k-th heading at the top is `§k`, and the j-th child of `§p` is `§p.j`. +- The numbers do not depend on `:numbered:` toctrees, `{sectnum}`, the builder, docutils' title and subtitle promotion, or any other document. + +**How references resolve** + +- `§1.2` links to the heading numbered exactly 1.2; there is no fallback to `§1` or to the nearest heading. +- The link goes to the heading's primary anchor, so a `(label)=` target on the heading is used. +- A reference in a section heading or in link text is never linked, and never warns. + Everywhere else (paragraphs, emphasis, lists, tables, figure captions, admonitions) it resolves. +- In image alt text, the reference is kept as written, without a link. +- A reference that does not resolve stays as text marked with the `section-ref` class, and gives one `myst.section_ref` warning. +- A reference resolves against the document it is written in, when that document is read + (for an `{include}`d file, that is the document that includes it). + Content that is copied into another document, for example by an extension, is not covered. + (syntax/substitutions)= ## Substitutions (with Jinja2) From b8e107f9acf69370a7708fe44f1dad5106dc63b4 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 14:35:05 +0100 Subject: [PATCH 14/18] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Section=20refer?= =?UTF-8?q?ences:=20count=20sections=20in=20any=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A section now counts wherever the document tree places it, under its nearest section ancestor: the numbering descends through any container element (an `only` node, an `ifconfig` node, ...), not just `only`. Conformance fixture: - the {only} cases prove the counting through the headings after the branch, rather than by linking into a branch the build excludes; - new Sphinx-only cases: `.. only::` sections at the document root, nested `only`, `.. ifconfig::` in {eval-rst}, and link text of a link to another document (replacing its standalone test); - new case: {include} with :heading-offset:; - a reference in a rubric heading (inside {note}) resolves; - targets that share a title carry an occurrence suffix (`A [2]`). --- myst_parser/mdit_to_docutils/transforms.py | 11 +- .../fixtures/section_ref_resolution.md | 129 ++++++++++++++++-- tests/test_renderers/test_section_refs.py | 66 +++++---- 3 files changed, 162 insertions(+), 44 deletions(-) diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index f5b65e09..a2e91472 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -386,14 +386,19 @@ def apply(self, **kwargs: t.Any) -> None: def _child_sections(node: nodes.Element) -> list[nodes.section]: """Return the child sections of an element, in document order. - Sections inside a sphinx ``only`` node count, whatever its condition, - so that the numbering does not depend on the builder. + A section counts wherever the document tree places it, under its nearest + section ancestor: the search descends through any other container element, + such as a sphinx ``only`` node (whatever its condition, so the numbering + does not depend on the builder) or an ``ifconfig`` node, but not into + sections or text elements. """ sections: list[nodes.section] = [] for child in node.children: if isinstance(child, nodes.section): sections.append(child) - elif child.tagname == "only": + elif isinstance(child, nodes.Element) and not isinstance( + child, nodes.TextElement + ): sections.extend(_child_sections(child)) return sections diff --git a/tests/test_renderers/fixtures/section_ref_resolution.md b/tests/test_renderers/fixtures/section_ref_resolution.md index 50e2ab89..af4be886 100644 --- a/tests/test_renderers/fixtures/section_ref_resolution.md +++ b/tests/test_renderers/fixtures/section_ref_resolution.md @@ -1,6 +1,6 @@ -The conformance contract for `section_ref` resolution (docs/syntax/optional.md, "Numbering rules"), run through docutils and Sphinx. -Each case is a document, then one line per reference in document order: `§n -> <target heading title>` (`@<label>` if the target has a `(label)=`), `§n unresolved` (one warning) or `§n inert` (no link, no warning). -A `-- file: <name>` line starts an extra file; `[id] key=value` options set config, `sphinx-only` cases need `{only}`, `docutils:` options are docutils-only settings. +The conformance contract for `section_ref` resolution (docs/syntax/optional.md, "Numbering rules"), run through docutils and Sphinx; text that is not a reference (`§0000000001`) has no line. +Each case is a document, then one line per reference in document order after inclusion: `§n -> <target section title>`, plus ` [k]` if k-th of several sections with that title, plus ` @<id>` if the target has a `(label)=`; `§n unresolved` (one warning); or `§n inert` (no link, no warning). +A `-- file: <name>` line starts an extra file; `[id] key=value` options set config for both engines, `docutils:`/`sphinx:` ones for one (`sphinx:extensions` is a comma list added to Sphinx's), and `sphinx-only` cases need a Sphinx-only directive ({only}, {ifconfig}) or a cross-document link. [C1-title-and-sections] . @@ -147,7 +147,7 @@ See §1 §2 §1.1 §1.2. ## A ```{note} -## In a note +## In a note §1 ``` ## B @@ -164,6 +164,7 @@ text See §1 §1.1 §2 §2.1 §3 §4. . §1 -> A +§1 -> A §1.1 unresolved §2 -> B §2.1 -> RST section @@ -200,6 +201,31 @@ In the included file: §1 §2 §2.1 §9. §3 -> Z . +[C9-include-heading-offset] +. +# T + +## A + +```{include} part.md +:heading-offset: 1 +``` + +## Z + +See §1 §2 §2.1 §3 §4. +-- file: part.md +# P + +## P1 +. +§1 -> A +§2 -> P +§2.1 -> P1 +§3 -> Z +§4 unresolved +. + [C13-inert-contexts] . # T §9 @@ -246,6 +272,17 @@ See §1 and §1.1. §1 inert . +[C13-reference-in-document-link] sphinx-only +. +# T + +## A + +[see §1](other.md) +. +§1 inert +. + [C13-reference-in-admonition-title] . # T @@ -272,9 +309,9 @@ x See §1 §2 §3. . -§1 -> A @lbl -§2 -> A -§3 -> A +§1 -> A [1] @lbl +§2 -> A [2] +§3 -> A [3] . [C14-explicit-target] @@ -418,11 +455,9 @@ See §1 §2 §2.1 §1.1. ## A3 -See §1 §2 §2.1 §3. +See §1 §3. . §1 -> A -§2 -> Only1 -§2.1 -> Only1a §3 -> A3 . @@ -441,11 +476,81 @@ See §1 §2 §2.1 §3. text ``` +### A1b + ## A2 -See §1 §1.1 §2. +See §1 §1.2 §2. . §1 -> A -§1.1 -> Only1 +§1.2 -> A1b §2 -> A2 . + +[P7-only-eval-rst-top-level] sphinx-only +. +See §1 §2. + +```{eval-rst} +.. only:: html or not html + + A + - + + B + - +``` +. +§1 -> A +§2 -> B +. + +[P7-only-nested] sphinx-only +. +# T + +## A + +```{eval-rst} +.. only:: html or not html + + .. only:: html or not html + + Inner + ----- + + text +``` + +## B + +See §1 §1.1 §2. +. +§1 -> A +§1.1 -> Inner +§2 -> B +. + +[N1-ifconfig-eval-rst] sphinx-only sphinx:extensions=sphinx.ext.ifconfig +. +# T + +## A + +```{eval-rst} +.. ifconfig:: True + + IfSec + ----- + + text +``` + +## B + +See §1 §1.1 §2. +. +§1 -> A +§1.1 -> IfSec +§2 -> B +. diff --git a/tests/test_renderers/test_section_refs.py b/tests/test_renderers/test_section_refs.py index 6c8c626f..a851f05a 100644 --- a/tests/test_renderers/test_section_refs.py +++ b/tests/test_renderers/test_section_refs.py @@ -38,22 +38,27 @@ def _split_files(content: str) -> tuple[str, dict[str, str]]: return parts[0], dict(zip(parts[1::2], parts[2::2], strict=True)) -def _parse_options(description: str | None) -> tuple[bool, dict, dict]: - """Parse the case options into ``(sphinx_only, shared, docutils_only)`` settings.""" +def _parse_options(description: str | None) -> tuple[bool, dict, dict, dict]: + """Parse the case options into ``(sphinx_only, shared, docutils, sphinx)`` settings.""" sphinx_only = False shared: dict = {} docutils_only: dict = {} + sphinx_conf: dict = {"extensions": ["myst_parser"]} for option in (description or "").split(): if option == "sphinx-only": sphinx_only = True continue key, value = option.split("=", 1) parsed = {"true": True, "false": False}.get(value, value) - if key.startswith("docutils:"): + if key == "sphinx:extensions": + sphinx_conf["extensions"] += value.split(",") + elif key.startswith("sphinx:"): + sphinx_conf[key.removeprefix("sphinx:")] = parsed + elif key.startswith("docutils:"): docutils_only[key.removeprefix("docutils:")] = parsed else: shared[key] = parsed - return sphinx_only, shared, docutils_only + return sphinx_only, shared, docutils_only, sphinx_conf def _is_inert(node: nodes.Element) -> bool: @@ -64,20 +69,29 @@ def _is_inert(node: nodes.Element) -> bool: parent = node.parent while parent is not None: if ( - isinstance(parent, (nodes.reference, nodes.subtitle)) + isinstance(parent, nodes.reference) or parent.tagname == "pending_xref" - or ( - isinstance(parent, nodes.title) - and isinstance(parent.parent, (nodes.section, nodes.document)) - ) + or _is_section_title(parent) ): return True parent = parent.parent return False +def _is_section_title(node: nodes.Node) -> bool: + """Whether a node is a section title (in docutils, maybe promoted to the document).""" + return isinstance(node, nodes.subtitle) or ( + isinstance(node, nodes.title) + and isinstance(node.parent, (nodes.section, nodes.document)) + ) + + def _target_title(doctree: nodes.document, refid: str) -> str: - """Return the title of the heading that holds ``refid``, plus ``@refid`` if explicit.""" + """Return the title of the section that holds ``refid``. + + ``[k]`` is added if it is the k-th of several sections with that title, + and ``@refid`` if the id comes from an explicit target. + """ target = next( node for node in doctree.findall(nodes.Element) if refid in node["ids"] ) @@ -85,9 +99,16 @@ def _target_title(doctree: nodes.document, refid: str) -> str: target = next( child for child in target.children if isinstance(child, nodes.title) ) + text = target.astext() + same = [ + node for node in doctree.findall(_is_section_title) if node.astext() == text + ] + if len(same) > 1: + text += f" [{next(i for i, node in enumerate(same, 1) if node is target)}]" # an explicit target, such as ``(label)=``, leaves a ``target`` that points to it - explicit = any(node.get("refid") == refid for node in doctree.findall(nodes.target)) - return f"{target.astext()} @{refid}" if explicit else target.astext() + if any(node.get("refid") == refid for node in doctree.findall(nodes.target)): + text += f" @{refid}" + return text def _outcome(doctree: nodes.document, warnings: str) -> str: @@ -132,7 +153,9 @@ def test_resolution( ): """Each conformance case gives the expected outcome in both docutils and sphinx.""" source, files = _split_files(file_params.content) - sphinx_only, shared, docutils_only = _parse_options(file_params.description) + sphinx_only, shared, docutils_only, sphinx_conf = _parse_options( + file_params.description + ) if not sphinx_only: (tmp_path / "docutils").mkdir() @@ -158,11 +181,11 @@ def test_resolution( sphinx_doctree.srcdir.joinpath(name).write_text(text, encoding="utf8") sphinx_doctree.set_conf( { - "extensions": ["myst_parser"], "myst_enable_extensions": ["section_ref"], # extra files are only included, not documents of their own "exclude_patterns": list(files), **shared, + **sphinx_conf, } ) result = sphinx_doctree(source, "index.md") @@ -171,21 +194,6 @@ def test_resolution( ) -def test_sphinx_reference_in_document_link(sphinx_doctree: CreateDoctree): - """A reference in the text of a link to another document stays inert. - - sphinx parses such a link to a ``pending_xref`` rather than a ``reference`` - (docutils cannot resolve it at all, so this case is sphinx-only). - """ - sphinx_doctree.set_conf( - {"extensions": ["myst_parser"], "myst_enable_extensions": ["section_ref"]} - ) - result = sphinx_doctree("# T\n\n## A\n\n[see §1](other.md)\n", "index.md") - doctree = result.doctrees["index"] - assert any(node.tagname == "pending_xref" for node in doctree.findall()) - assert _summarise(doctree) == ([], ["§1"]) - - def test_docutils_without_doctitle_promotion(): """With ``doctitle_xform`` off, docutils keeps the headings as sphinx does.""" doctree = publish_doctree( From da21e9bb12dc0405219250f455ba21053bfba2a8 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 14:35:05 +0100 Subject: [PATCH 15/18] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Section=20referenc?= =?UTF-8?q?es:=20heading=20offsets,=20other=20containers,=20{only}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Say that included headings are shifted by :heading-offset:, that sections placed by other directives count, that docutils has no {only}, that a reference into an excluded {only} branch points to a missing anchor, and that a rubric heading is not a section heading. {numref} on a section does not need numfig. --- docs/syntax/optional.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index ac40ecd2..3ee56a34 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -363,13 +363,13 @@ but references are resolved before that, against the headings as written, so `§ 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`). A `§` number directly followed by an ASCII letter, such as `§1a`, is not a reference. -References that appear **inside a heading or inside link text** are intentionally left as text marked with the `section-ref` class, 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). +References that appear **inside a section heading or inside link text** are intentionally left as text marked with the `section-ref` class, 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). References in other titles, such as the title of an admonition or a table, are resolved as usual. :::{important} §-numbers are **not** the section numbers that Sphinx displays. In a `toctree` with the `:numbered:` option, Sphinx numbers sections across the documents it lists, so *Section One* above could be displayed as "2.1.", while `§1` still targets it, as the first section of its own document. -To cite the number that a `:numbered:` toctree displays, put a [`(label)=` target](syntax/targets) on the heading and use the [`{numref}` role](inv:sphinx:rst:role#numref) (with `numfig = True`), which by default prints "Section 2.1". +To cite the number that a `:numbered:` toctree displays, put a [`(label)=` target](syntax/targets) on the heading and use the [`{numref}` role](inv:sphinx:rst:role#numref), which by default prints "Section 2.1". ::: :::{caution} @@ -399,8 +399,8 @@ Cross-document section references may be supported in a future release. ### Numbering rules -These rules say exactly which heading a reference points to. -Sphinx and docutils follow them in the same way, and MyST's test suite pins them. +These rules say exactly which section heading a reference points to. +Sphinx and docutils follow them in the same way (except that docutils has no `{only}` directive, so it drops such blocks), and MyST's test suite pins them. **What is a reference** @@ -418,11 +418,13 @@ Sphinx and docutils follow them in the same way, and MyST's test suite pins them **Which headings count** - Every heading that starts a section counts, in the order of the source, including: - - the headings of a file pulled in with `{include}`, at the point of inclusion and at the level they are written at; + - the headings of a file pulled in with `{include}`, at the point of inclusion and at the level they are written at, shifted by the directive's `:heading-offset:`; - the sections of an `{eval-rst}` block, where the document places them; - the headings inside an `{only}` block, whatever its condition, so that the numbers are the same for every builder; + - any other section that a directive places in the document, such as one inside `.. ifconfig::` in an `{eval-rst}` block, under the section that contains it; - a front-matter `title` that [`myst_title_to_header`](#sphinx/config-options) turns into a heading: it is the first heading of the document. - A heading inside the body of a directive, such as `{note}`, is rendered as a rubric, and does not count. + It is not a section heading, so a reference written in it resolves as usual. **How headings are numbered** @@ -434,8 +436,9 @@ Sphinx and docutils follow them in the same way, and MyST's test suite pins them **How references resolve** -- `§1.2` links to the heading numbered exactly 1.2; there is no fallback to `§1` or to the nearest heading. -- The link goes to the heading's primary anchor, so a `(label)=` target on the heading is used. +- `§1.2` links to the section heading numbered exactly 1.2; there is no fallback to `§1` or to the nearest heading. +- The link goes to the section's primary anchor, so a `(label)=` target on the heading is used. + A reference to a heading in an `{only}` branch that the build excludes points to an anchor that is not in the output. - A reference in a section heading or in link text is never linked, and never warns. Everywhere else (paragraphs, emphasis, lists, tables, figure captions, admonitions) it resolves. - In image alt text, the reference is kept as written, without a link. From e88f32a4ca12de3fd8b3ec764949b8d039f2b679 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 14:52:17 +0100 Subject: [PATCH 16/18] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Section=20referenc?= =?UTF-8?q?es:=20sections=20in=20other=20containers,=20rubric=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a fixture case, run in both parsers, where {eval-rst} places rST sections inside a {note} and a list item, so the general container rule is pinned by data a second engine can run. Put a rubric that shares a section's title into the duplicate-titles case, so the occurrence suffix counts sections only. Only a subtitle of a section (or of the document) counts as a section title in the harness; a sidebar's subtitle does not. --- .../fixtures/section_ref_resolution.md | 34 +++++++++++++++++++ tests/test_renderers/test_section_refs.py | 10 +++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/test_renderers/fixtures/section_ref_resolution.md b/tests/test_renderers/fixtures/section_ref_resolution.md index af4be886..6df2788f 100644 --- a/tests/test_renderers/fixtures/section_ref_resolution.md +++ b/tests/test_renderers/fixtures/section_ref_resolution.md @@ -303,6 +303,10 @@ x (lbl)= ## A +```{note} +## A +``` + ## A ## A @@ -531,6 +535,36 @@ See §1 §1.1 §2. §2 -> B . +[N1-eval-rst-in-container] +. +# T + +## A + +````{note} +```{eval-rst} +InNote +------ +``` +```` + +- item + + ```{eval-rst} + InList + ------ + ``` + +## B + +See §1 §1.1 §1.2 §2. +. +§1 -> A +§1.1 -> InNote +§1.2 -> InList +§2 -> B +. + [N1-ifconfig-eval-rst] sphinx-only sphinx:extensions=sphinx.ext.ifconfig . # T diff --git a/tests/test_renderers/test_section_refs.py b/tests/test_renderers/test_section_refs.py index a851f05a..2140921d 100644 --- a/tests/test_renderers/test_section_refs.py +++ b/tests/test_renderers/test_section_refs.py @@ -79,10 +79,12 @@ def _is_inert(node: nodes.Element) -> bool: def _is_section_title(node: nodes.Node) -> bool: - """Whether a node is a section title (in docutils, maybe promoted to the document).""" - return isinstance(node, nodes.subtitle) or ( - isinstance(node, nodes.title) - and isinstance(node.parent, (nodes.section, nodes.document)) + """Whether a node is a section title (in docutils, maybe promoted to the document). + + A sidebar's subtitle is not one. + """ + return isinstance(node, (nodes.title, nodes.subtitle)) and isinstance( + node.parent, (nodes.section, nodes.document) ) From 84c96da5d220de1c581e3c4ef2ee4946d9e053dd Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 14:52:17 +0100 Subject: [PATCH 17/18] =?UTF-8?q?=F0=9F=93=9A=20DOCS:=20Section=20referenc?= =?UTF-8?q?es:=20ifconfig=20branches=20and=20sidebar=20subtitles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name ifconfig beside {only} where docutils drops the block and where an excluded branch leaves a missing anchor, say that a sidebar's :subtitle: is not a section heading, and that the rubric rule is about Markdown headings. --- docs/syntax/optional.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index 3ee56a34..ae054b51 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -400,7 +400,7 @@ Cross-document section references may be supported in a future release. ### Numbering rules These rules say exactly which section heading a reference points to. -Sphinx and docutils follow them in the same way (except that docutils has no `{only}` directive, so it drops such blocks), and MyST's test suite pins them. +Sphinx and docutils follow them in the same way (except that docutils has no `{only}` or `ifconfig` directive, so it drops such blocks), and MyST's test suite pins them. **What is a reference** @@ -423,7 +423,7 @@ Sphinx and docutils follow them in the same way (except that docutils has no `{o - the headings inside an `{only}` block, whatever its condition, so that the numbers are the same for every builder; - any other section that a directive places in the document, such as one inside `.. ifconfig::` in an `{eval-rst}` block, under the section that contains it; - a front-matter `title` that [`myst_title_to_header`](#sphinx/config-options) turns into a heading: it is the first heading of the document. -- A heading inside the body of a directive, such as `{note}`, is rendered as a rubric, and does not count. +- A Markdown heading inside the body of a directive, such as `{note}`, is rendered as a rubric, and does not count. It is not a section heading, so a reference written in it resolves as usual. **How headings are numbered** @@ -438,8 +438,9 @@ Sphinx and docutils follow them in the same way (except that docutils has no `{o - `§1.2` links to the section heading numbered exactly 1.2; there is no fallback to `§1` or to the nearest heading. - The link goes to the section's primary anchor, so a `(label)=` target on the heading is used. - A reference to a heading in an `{only}` branch that the build excludes points to an anchor that is not in the output. + A reference to a heading in an `{only}` or `ifconfig` branch that the build excludes points to an anchor that is not in the output. - A reference in a section heading or in link text is never linked, and never warns. + A heading that docutils promotes to the subtitle is still a section heading, but the `:subtitle:` of a `{sidebar}` is not, so a reference there resolves. Everywhere else (paragraphs, emphasis, lists, tables, figure captions, admonitions) it resolves. - In image alt text, the reference is kept as written, without a link. - A reference that does not resolve stays as text marked with the `section-ref` class, and gives one `myst.section_ref` warning. From 36608d6503bd57683e1c80e683e7591070fa3d87 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Tue, 10 Nov 2026 18:53:33 +0100 Subject: [PATCH 18/18] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Section=20referenc?= =?UTF-8?q?es:=20Windows-safe=20warning=20path=20and=20UTF-8=20text=20regr?= =?UTF-8?q?ession?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_renderers/test_section_refs.py | 3 ++- tests/test_sphinx/test_sphinx_builds.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_renderers/test_section_refs.py b/tests/test_renderers/test_section_refs.py index 2140921d..e473f4ea 100644 --- a/tests/test_renderers/test_section_refs.py +++ b/tests/test_renderers/test_section_refs.py @@ -294,7 +294,8 @@ def test_sphinx_warning_only_for_unresolved(sphinx_doctree: CreateDoctree): {"extensions": ["myst_parser"], "myst_enable_extensions": ["section_ref"]} ) result = sphinx_doctree(WARNING_SOURCE, "index.md") - assert strip_colors(result.warnings).strip().splitlines() == [ + # the source path uses the OS separator (``<src>\\index.md`` on Windows) + assert strip_colors(result.warnings).replace("\\", "/").strip().splitlines() == [ f"<src>/index.md:5: WARNING: {WARNING_MESSAGE} [myst.section_ref]" ] diff --git a/tests/test_sphinx/test_sphinx_builds.py b/tests/test_sphinx/test_sphinx_builds.py index e81ebed0..b579eed3 100644 --- a/tests/test_sphinx/test_sphinx_builds.py +++ b/tests/test_sphinx/test_sphinx_builds.py @@ -269,7 +269,7 @@ def test_extended_syntaxes_text( buildername="text", filename="index.txt", ) - file_regression.check(content) + file_regression.check(content, encoding="utf8") @pytest.mark.skipif(