diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b065e365..8a16f84a 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 - sphinx-syntax-example~=0.2.0 files: > (?x)^( diff --git a/docs/configuration.md b/docs/configuration.md index 6925cec3..a2101ffd 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 aa0d97cd..ae054b51 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,128 @@ 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*, 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 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 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 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), which by default prints "Section 2.1". +::: + +:::{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 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} +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. + +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}` or `ifconfig` directive, so it drops such blocks), 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, 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 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** + +- 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 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}` 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. +- 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) diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py index bb637707..24e2a540 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 c26211d1..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 []) @@ -1327,6 +1329,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_numbers"] = token.meta["numbers"] + 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 7d7e1d88..a2e91472 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -381,3 +381,166 @@ 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 child sections of an element, in document order. + + 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 isinstance(child, nodes.Element) and not isinstance( + child, nodes.TextElement + ): + sections.extend(_child_sections(child)) + return sections + + +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 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 _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. + + 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 section heading — a reference there would be copied into navigation + and toc entries (sphinx toctree entry links, docutils contents entries), + again nesting ```` 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) + or parent.tagname == "pending_xref" + or ( + isinstance(parent, nodes.title) + and isinstance(parent.parent, nodes.section) + ) + ): + return True + parent = parent.parent + return False + + +class ResolveSectionRefs(Transform): + """Resolve ``§1.1`` section references to the target section's anchor. + + 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. + """ + + # 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 anything that + # reads the body at doctree-read sees the resolved references + default_priority = 300 + + 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_numbers" in node + ] + if not markers: + return + + number_map = _section_number_map(self.document) + + 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(node["section_numbers"]) + target = number_map.get(number) + if target is not None and target["ids"]: + ref = nodes.reference( + "", + "", + 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 + # 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 in this document: {content!r}", + MystWarnings.SECTION_REF, + node=node, + ) 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 78d6d642..f66c5c7c 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", ] @@ -90,7 +90,7 @@ mypy = [ "types-urllib3", "sphinx~=8.2", "markdown-it-py~=4.2", - "mdit-py-plugins~=0.6.0", + "mdit-py-plugins~=0.7.0", "sphinx-syntax-example~=0.2.0", ] ruff = ["ruff==0.17.4"] diff --git a/tests/test_docutils.py b/tests/test_docutils.py index fc0a98e2..cabb2c03 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -329,6 +329,116 @@ 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"], 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) + + +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_numbers" 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_numbers" 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..812a482c --- /dev/null +++ b/tests/test_renderers/fixtures/docutil_section_refs.md @@ -0,0 +1,252 @@ +[resolved] --myst-enable-extensions=section_ref +. +# Title + +Intro referencing §1, §1.1 and §2. + +## Section One + +### Sub One One + +## Section Two +. + + + Title + + Intro referencing + + §1 + , + + §1.1 + and + + §2 + . + + + Section One + + + Sub One One + + + Section Two +. + +[multiple-headings] --myst-enable-extensions=section_ref +. +See §1 and §2. + +# First + +# Second +. + + + See + + §1 + and + + §2 + . + + + First + + + Second +. + +[forward-reference] --myst-enable-extensions=section_ref +. +# Title + +## First + +Text in first, see §2. + +## Second + +Text in second. +. + + + Title + + + First + + Text in first, see + + §2 + . + + + Second + + Text in second. +. + +[unresolved] --myst-enable-extensions=section_ref +. +# Title + +See §9.9 which does not exist. + +## Only Section +. + + + Title + + See + + §9.9 + which does not exist. + + + Only Section + + +/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 +. +# Title + +## One + +## Two + +A link [see §1](https://example.com) leaves §1 inert, but body §2 resolves. +. + + + Title + + + One + + + Two + + A link + + see + + §1 + leaves + + §1 + inert, but body + + §2 + resolves. +. + +[in-heading] --myst-enable-extensions=section_ref +. +# Title + +## Heading with §1 inside + +## Other + +Body §1 resolves to the first section. +. + + + Title + + + Heading with + + §1 + inside + + + Other + + Body + + §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 +. + + + Title + + See + + §1 + and + + §1.1 + . + + + Section One + + + Sub One One +. + +[subtitle] --myst-enable-extensions=section_ref +. +# Title + +## Subtitle + +See §1 and §1.1. + +### Section One +. + + + Title + + Subtitle + + See + + §1 + and + + §1.1 + . + + + Section One +. + +[image-alt] --myst-enable-extensions=section_ref +. +# Title + +## One + + +. + + + Title + + 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..6df2788f --- /dev/null +++ b/tests/test_renderers/fixtures/section_ref_resolution.md @@ -0,0 +1,590 @@ +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 -> `, plus ` [k]` if k-th of several sections with that title, plus ` @` if the target has a `(label)=`; `§n unresolved` (one warning); or `§n inert` (no link, no warning). +A `-- file: ` 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] +. +# 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 §1 +``` + +## B + +```{eval-rst} +RST section +----------- + +text +``` + +## C + +See §1 §1.1 §2 §2.1 §3 §4. +. +§1 -> A +§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 +. + +[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 + +## 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-document-link] sphinx-only +. +# T + +## A + +[see §1](other.md) +. +§1 inert +. + +[C13-reference-in-admonition-title] +. +# T + +## A + +```{admonition} See §1 +x +``` +. +§1 -> A +. + +[C14-duplicate-titles] +. +# T + +(lbl)= +## A + +```{note} +## A +``` + +## A + +## A + +See §1 §2 §3. +. +§1 -> A [1] @lbl +§2 -> A [2] +§3 -> A [3] +. + +[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 §3. +. +§1 -> A +§3 -> A3 +. + +[P7-only-inactive-eval-rst] sphinx-only +. +# T + +## A + +```{eval-rst} +.. only:: latex + + Only1 + ----- + + text +``` + +### A1b + +## A2 + +See §1 §1.2 §2. +. +§1 -> A +§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-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 + +## 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_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="/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_renderers/test_section_refs.py b/tests/test_renderers/test_section_refs.py new file mode 100644 index 00000000..e473f4ea --- /dev/null +++ b/tests/test_renderers/test_section_refs.py @@ -0,0 +1,332 @@ +"""``§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 references are resolved before that promotion, against the headings as written, +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 + +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: `` 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, 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 == "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, sphinx_conf + + +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) + or parent.tagname == "pending_xref" + 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). + + A sidebar's subtitle is not one. + """ + return isinstance(node, (nodes.title, nodes.subtitle)) and isinstance( + node.parent, (nodes.section, nodes.document) + ) + + +def _target_title(doctree: nodes.document, refid: str) -> str: + """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"] + ) + if isinstance(target, (nodes.section, nodes.document)): + 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 + 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: + """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]: + """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.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, sphinx_conf = _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 + ) + + for name, text in files.items(): + sphinx_doctree.srcdir.joinpath(name).write_text(text, encoding="utf8") + sphinx_doctree.set_conf( + { + "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") + file_params.assert_expected( + _outcome(result.doctrees["index"], result.warnings), rstrip_lines=True + ) + + +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_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( + "# 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" + + +# 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") + # the source path uses the OS separator (``\\index.md`` on Windows) + assert strip_colors(result.warnings).replace("\\", "/").strip().splitlines() == [ + f"/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/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.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( 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..efbf3061 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 @@ + + + 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.xml b/tests/test_sphinx/test_sphinx_builds/test_extended_syntaxes.xml index f00cb508..234cc208 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: 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_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 +==============
+ Body referencing + + §1 + + and + + §1.1 + + . +