From d8f020484dd684ef4c08a53d6d7e636e2da6987e Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 12 Jul 2026 16:55:09 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20NEW:=20stable=20heading=20ancho?= =?UTF-8?q?rs:=20shared=20slugs=20module,=20slug=20ids=20in=20HTML,=20expl?= =?UTF-8?q?icit-id=20priority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core changes for the heading-anchor overhaul (tests/CLI/docs follow): - New `myst_parser.slugs` module: the single source of truth for slug generation (github + gitlab presets, fixed unique-suffix algorithm), pure/stdlib-only so alternative implementations can replicate it. - Duplicate headings now suffix as `x, x-1, x-2` (GitHub behaviour, as documented), not `x, x-1, x-1-2`. - Anchor slugs are additionally emitted as secondary HTML ids, so GitHub-style fragments actually exist in published output; the docutils id stays first, keeping all previously published fragments and resolved references unchanged. Escape hatch: `myst_heading_anchors_html_ids = False`. - `myst_heading_slug_func` accepts preset names ("github", "gitlab") as the canonical, declarative form; dotted import paths remain as a legacy escape hatch, and dotless unknown strings now error clearly. - New `PrioritiseExplicitIds` transform: an explicitly named target (`(name)=`) propagated onto a section becomes its primary id, so toc/permalinks/objects.inv use the stable explicit id; the implicit id is kept as a secondary anchor. - `[](#target)` now falls back to implicit docutils names/ids in the same document (e.g. headings beyond the `heading_anchors` depth), and cross-doc `[](./doc.md#id)` accepts section ids and std-domain labels that demonstrably exist, instead of warning on working links. All regress fixture changes reviewed: explicit-first id reordering and added slug ids only. --- myst_parser/config/main.py | 45 ++++++++--- myst_parser/mdit_to_docutils/base.py | 53 +++++++------ myst_parser/mdit_to_docutils/transforms.py | 52 +++++++++++++ myst_parser/parsers/docutils_.py | 2 + myst_parser/parsers/sphinx_.py | 2 + myst_parser/slugs.py | 77 +++++++++++++++++++ myst_parser/sphinx_ext/myst_refs.py | 24 +++++- .../fixtures/docutil_link_resolution.md | 14 ++-- tests/test_renderers/fixtures/myst-config.txt | 8 +- .../fixtures/sphinx_link_resolution.md | 4 +- .../test_myst_refs/duplicate.xml | 2 +- tests/test_renderers/test_myst_refs/ref.xml | 2 +- .../test_myst_refs/ref_colon.xml | 2 +- .../test_myst_refs/ref_nested.xml | 2 +- .../test_sphinx_builds/test_basic.html | 6 +- .../test_basic.resolved.xml | 2 +- .../test_sphinx_builds/test_basic.xml | 2 +- .../test_sphinx_builds/test_includes.html | 6 +- .../test_sphinx_builds/test_includes.xml | 2 +- .../test_sphinx_builds/test_references.html | 12 ++- .../test_references.resolved.xml | 6 +- .../test_sphinx_builds/test_references.xml | 6 +- 22 files changed, 259 insertions(+), 72 deletions(-) create mode 100644 myst_parser/slugs.py diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py index 9bf637dc..ac8c158b 100644 --- a/myst_parser/config/main.py +++ b/myst_parser/config/main.py @@ -138,19 +138,30 @@ def check_inventories(_: "MdParserConfig", field: dc.Field, value: Any) -> None: def check_heading_slug_func( inst: "MdParserConfig", field: dc.Field, value: Any ) -> None: - """Check that the heading_slug_func is a callable.""" + """Check that the heading_slug_func is a preset name or a callable.""" if value is None: return if isinstance(value, str): - # attempt to load the function as a python import - try: - module_path, function_name = value.rsplit(".", 1) - mod = import_module(module_path) - value = getattr(mod, function_name) - except ImportError as exc: + from myst_parser.slugs import SLUG_PRESETS + + if value in SLUG_PRESETS: + value = SLUG_PRESETS[value] + elif "." in value: + # attempt to load the function as a python import (legacy form) + try: + module_path, function_name = value.rsplit(".", 1) + mod = import_module(module_path) + value = getattr(mod, function_name) + except ImportError as exc: + raise TypeError( + f"'{field.name}' could not be loaded from string: {value!r}" + ) from exc + else: raise TypeError( - f"'{field.name}' could not be loaded from string: {value!r}" - ) from exc + f"'{field.name}' is not a known preset " + f"({', '.join(sorted(SLUG_PRESETS))}) " + f"or a python import path: {value!r}" + ) setattr(inst, field.name, value) if not callable(value): raise TypeError(f"'{field.name}' is not callable: {value!r}") @@ -301,11 +312,21 @@ def __repr__(self) -> str: metadata={ "validator": check_heading_slug_func, "help": ( - "Function for creating heading anchors, " - "or a python import path e.g. `my_package.my_module.my_function`" + "Function for creating heading anchors: " + 'a preset name ("github", "gitlab"), ' + "or (legacy) a python import path " + "e.g. `my_package.my_module.my_function`" ), "global_only": True, - "doc_type": "None | Callable[[str], str] | str", + "doc_type": "None | str | Callable[[str], str]", + }, + ) + + heading_anchors_html_ids: bool = dc.field( + default=True, + metadata={ + "validator": instance_of(bool), + "help": "Also emit heading anchor slugs as (additional) HTML ids", }, ) diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index fe292635..3db3851a 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -7,7 +7,14 @@ import os import posixpath import re -from collections.abc import Callable, Iterable, Iterator, MutableMapping, Sequence +from collections.abc import ( + Callable, + Container, + Iterable, + Iterator, + MutableMapping, + Sequence, +) from contextlib import contextmanager, suppress from datetime import date, datetime from types import ModuleType @@ -51,6 +58,7 @@ MarkupError, parse_directive_text, ) +from myst_parser.slugs import github_slugify, unique_slug from myst_parser.warnings_ import MystWarnings, create_warning from .html_to_nodes import html_to_nodes @@ -859,6 +867,20 @@ def generate_heading_target( ) else: node["slug"] = slug + if ( + self.md_config.heading_anchors_html_ids + and slug + # a custom slug_func may produce whitespace, + # which is invalid in an HTML id + and not re.search(r"\s", slug) + and slug not in self.document.ids + ): + # also emit the slug as a (secondary) id, so that the anchor + # actually exists in published HTML output; the docutils id + # stays first, so all previously published fragments, + # and the targets of resolved references, are unchanged + node["ids"].append(slug) + self.document.ids[slug] = node self._heading_slugs[slug] = (node.line, node["ids"][0], implicit_text) def render_heading(self, token: SyntaxTreeNode) -> None: @@ -2055,30 +2077,22 @@ def clean_astext(node: nodes.Element) -> str: return node.astext() -_SLUGIFY_CLEAN_REGEX = re.compile(r"[^\w\u4e00-\u9fff\- ]") - - def default_slugify(title: str) -> str: - """Default slugify function. + """Default slugify function (GitHub-style). - This aims to mimic the GitHub Markdown format, see: - - - https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/toc_filter.rb - - https://gist.github.com/asabaylus/3071099 + .. deprecated:: 5.2 + Use :func:`myst_parser.slugs.github_slugify` instead. """ - return _SLUGIFY_CLEAN_REGEX.sub("", title.lower().replace(" ", "-")) + return github_slugify(title) def compute_unique_slug( token_tree: SyntaxTreeNode, - slugs: Iterable[str], + slugs: Container[str], slug_func: None | Callable[[str], str] = None, ) -> str: - """Compute the slug for a token. - - This directly mirrors the logic in `mdit_py_plugins.anchors_plugin` - """ - slug_func = default_slugify if slug_func is None else slug_func + """Compute the slug for a heading token, unique against existing slugs.""" + slug_func = github_slugify if slug_func is None else slug_func tokens = token_tree.to_tokens() inline_token = tokens[1] title = "".join( @@ -2086,9 +2100,4 @@ def compute_unique_slug( for child in (inline_token.children or []) if child.type in ["text", "code_inline"] ) - slug = slug_func(title) - i = 1 - while slug in slugs: - slug = f"{slug}-{i}" - i += 1 - return slug + return unique_slug(slug_func(title), slugs) diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index ed681ca0..52a8821d 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -138,6 +138,33 @@ def _sort_key(footnote: tuple[str, nodes.footnote]) -> int | str: self.document += footnote +class PrioritiseExplicitIds(Transform): + """Reorder ``section["ids"]`` so an explicitly named target's id is first. + + Docutils' ``PropagateTargets`` (priority 260) appends propagated target + ids *after* the section's implicit id, so themes, tocs, permalinks and + ``objects.inv`` pick up the (unstable) implicit id. This moves the first + explicitly named id to the front; the implicit id remains in the list, + as a secondary anchor, so previously published fragments keep working. + """ + + default_priority = 261 # directly after docutils' PropagateTargets (260) + + def apply(self, **kwargs: t.Any) -> None: + """Apply the transform.""" + explicit_ids = { + self.document.nameids[name] + for name, is_explicit in self.document.nametypes.items() + if is_explicit and self.document.nameids.get(name) + } + for section in findall(self.document)(nodes.section): + ids = section["ids"] + first = next((id_ for id_ in ids if id_ in explicit_ids), None) + if first is not None and ids[0] != first: + ids.remove(first) + ids.insert(0, first) + + class ResolveAnchorIds(Transform): """Transform for resolving `[name](#id)` type links.""" @@ -231,6 +258,31 @@ def apply(self, **kwargs: t.Any) -> None: ) continue + # now search implicit docutils names and ids: these cover e.g. + # headings not assigned a slug (beyond the `heading_anchors` + # depth), whose anchors nonetheless exist in the output, so + # should resolve locally rather than falling through to + # project-wide resolution (or a warning) + labelid = self.document.nameids.get(target) or ( + target if target in self.document.ids else None + ) + node = self.document.ids.get(labelid) if labelid else None + if node is not None and not ( + node.tagname == "footnote" + or "refuri" in node + or node.tagname.startswith("desc_") + ): + refnode["refid"] = labelid + if not refnode.children: + implicit_title = None + for subnode in node: + if isinstance(subnode, nodes.caption | nodes.title): + implicit_title = clean_astext(subnode) + break + text = implicit_title or ("#" + target) + refnode += nodes.inline(text, text, classes=["std", "std-ref"]) + continue + # if still not found, and using sphinx, then create a pending_xref if hasattr(self.document.settings, "env"): from sphinx import addnodes diff --git a/myst_parser/parsers/docutils_.py b/myst_parser/parsers/docutils_.py index d205f06a..18316188 100644 --- a/myst_parser/parsers/docutils_.py +++ b/myst_parser/parsers/docutils_.py @@ -25,6 +25,7 @@ from myst_parser.mdit_to_docutils.base import DocutilsRenderer from myst_parser.mdit_to_docutils.transforms import ( CollectFootnotes, + PrioritiseExplicitIds, ResolveAnchorIds, SortFootnotes, UnreferencedFootnotesDetector, @@ -255,6 +256,7 @@ def get_transforms(self): UnreferencedFootnotesDetector, SortFootnotes, CollectFootnotes, + PrioritiseExplicitIds, ResolveAnchorIds, ] diff --git a/myst_parser/parsers/sphinx_.py b/myst_parser/parsers/sphinx_.py index 5708c950..5788470b 100644 --- a/myst_parser/parsers/sphinx_.py +++ b/myst_parser/parsers/sphinx_.py @@ -16,6 +16,7 @@ from myst_parser.mdit_to_docutils.sphinx_ import SphinxRenderer from myst_parser.mdit_to_docutils.transforms import ( CollectFootnotes, + PrioritiseExplicitIds, ResolveAnchorIds, SortFootnotes, ) @@ -53,6 +54,7 @@ def get_transforms(self): return super().get_transforms() + [ SortFootnotes, CollectFootnotes, + PrioritiseExplicitIds, ResolveAnchorIds, ] diff --git a/myst_parser/slugs.py b/myst_parser/slugs.py new file mode 100644 index 00000000..3cface04 --- /dev/null +++ b/myst_parser/slugs.py @@ -0,0 +1,77 @@ +"""Slug generation for heading anchors. + +This is the single source of truth for heading slugs, +shared by the renderer (``myst_heading_anchors``) and the ``myst-anchors`` CLI. + +The functions here are pure and dependency-free (standard library ``re`` only), +so that alternative implementations of MyST can replicate them from this file; +for the same reason, the unit-test corpus is kept as a language-agnostic +data file (``tests/fixtures/slugs.json``). +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Container + +_GITHUB_CLEAN = re.compile(r"[^\w一-鿿\- ]") + + +def github_slugify(title: str) -> str: + """Convert a heading title to a GitHub-style slug. + + Algorithm: lowercase the title; replace spaces with hyphens ``-``; + remove every character that is not a word character (``\\w``), + a CJK ideograph (U+4E00-U+9FFF), a hyphen or a space. + + Leading/trailing whitespace is deliberately *not* stripped first, + so it survives as leading/trailing hyphens + (e.g. ``" a b "`` -> ``"-a-b-"``); see the v0.18.1 changelog. + + See https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/toc_filter.rb + and https://gist.github.com/asabaylus/3071099 + """ + return _GITHUB_CLEAN.sub("", title.lower().replace(" ", "-")) + + +_GITLAB_CLEAN = re.compile(r"[^\w\- ]") +_GITLAB_DIGITS = re.compile(r"\d+") +_GITLAB_SQUEEZE = re.compile(r"-{2,}") + + +def gitlab_slugify(title: str) -> str: + """Convert a heading title to a GitLab-style slug. + + Algorithm: strip and lowercase the title; remove every character that is + not a word character (``\\w``), a hyphen or a space; replace spaces with + hyphens; squeeze consecutive hyphens; prefix ``anchor-`` if the result + consists only of digits. + + See https://docs.gitlab.com/ee/user/markdown.html#heading-ids-and-links + and ``Gitlab::Utils::Markdown#string_to_anchor``. + """ + slug = _GITLAB_CLEAN.sub("", title.strip().lower()).replace(" ", "-") + slug = _GITLAB_SQUEEZE.sub("-", slug) + if _GITLAB_DIGITS.fullmatch(slug): + slug = f"anchor-{slug}" + return slug + + +SLUG_PRESETS: dict[str, Callable[[str], str]] = { + "github": github_slugify, + "gitlab": gitlab_slugify, +} +"""Named slugify functions, usable as ``myst_heading_slug_func`` values.""" + + +def unique_slug(slug: str, existing: Container[str]) -> str: + """Suffix ``slug`` with ``-1``, ``-2``, ... until it is not in ``existing``. + + The base never changes, so duplicates of ``x`` become + ``x, x-1, x-2, ...`` (GitHub behaviour), not ``x, x-1, x-1-2, ...``. + """ + uniq, i = slug, 1 + while uniq in existing: + uniq = f"{slug}-{i}" + i += 1 + return uniq diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py index 5d69666f..d074021c 100644 --- a/myst_parser/sphinx_ext/myst_refs.py +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -126,6 +126,17 @@ def run(self, **kwargs: Any) -> None: node.replace_self(newnode) + def _std_label_in_doc(self, docname: str, ref_id: str) -> bool: + """Whether ``ref_id`` is a std-domain label (name or id) in ``docname``.""" + std = self.env.domaindata.get("std", {}) + for name, entry in std.get("labels", {}).items(): + if entry[0] == docname and ref_id in (name, entry[1]): + return True + for name, entry in std.get("anonlabels", {}).items(): + if entry[0] == docname and ref_id in (name, entry[1]): + return True + return False + def resolve_myst_ref_doc(self, node: pending_xref): """Resolve a reference, from a markdown link, to another document, optionally with a target id within that document. @@ -150,7 +161,16 @@ def resolve_myst_ref_doc(self, node: pending_xref): if ref_id: slug_to_section = self.env.metadata[ref_docname].get("myst_slugs", {}) - if ref_id not in slug_to_section: + if ref_id in slug_to_section: + _, targetid, implicit_text = slug_to_section[ref_id] + elif any( + sect_id == ref_id for _, sect_id, _ in slug_to_section.values() + ) or self._std_label_in_doc(ref_docname, ref_id): + # the id demonstrably exists in the target document + # (a section's docutils id, or an explicit target), + # so resolve silently rather than warn + targetid = ref_id + else: self.log_warning( ref_id, f"local id not found in doc {ref_docname!r}: {ref_id!r}", @@ -158,8 +178,6 @@ def resolve_myst_ref_doc(self, node: pending_xref): location=node, ) targetid = ref_id - else: - _, targetid, implicit_text = slug_to_section[ref_id] inner_classes = ["std", "std-ref"] else: implicit_text = clean_astext(self.env.titles[ref_docname]) diff --git a/tests/test_renderers/fixtures/docutil_link_resolution.md b/tests/test_renderers/fixtures/docutil_link_resolution.md index 250739ac..e29b8d9f 100644 --- a/tests/test_renderers/fixtures/docutil_link_resolution.md +++ b/tests/test_renderers/fixtures/docutil_link_resolution.md @@ -1,4 +1,4 @@ -[external] +[external] . [alt2](https://www.google.com) [](https://www.google.com) @@ -19,7 +19,7 @@ https://example.com?foo=bar&a=1 . -[missing] +[missing] . [](#test) @@ -92,7 +92,7 @@ explicit . -[explicit-heading] +[explicit-heading] . (target)= # Test @@ -101,7 +101,7 @@ [explicit](#target) . - + Test <target refid="target"> @@ -130,7 +130,7 @@ <document dupnames="test" ids="test" slug="test" source="<src>/index.md" title="Test"> <title> Test - <subtitle ids="other test-1" names="other test"> + <subtitle ids="test-1 other" names="other test"> Other <target refid="test-1"> <paragraph> @@ -143,7 +143,7 @@ <src>/index.md:3: (INFO/1) Hyperlink target "test-1" is not referenced. . -[id-with-spaces] +[id-with-spaces] . (name with spaces)= Paragraph @@ -160,7 +160,7 @@ Paragraph #name with spaces . -[ref-table] +[ref-table] . ```{table} caption :name: table diff --git a/tests/test_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt index b58f3513..348eaa15 100644 --- a/tests/test_renderers/fixtures/myst-config.txt +++ b/tests/test_renderers/fixtures/myst-config.txt @@ -386,7 +386,7 @@ My paragraph Chris Sewell . -[inv_link] +[inv_link] . <inv:#index> [](inv:#index) @@ -437,7 +437,7 @@ My paragraph Title . -[inv_link_error] +[inv_link_error] . <inv:#other> @@ -473,10 +473,10 @@ My paragraph [reversed](#eltit) . -<document dupnames="title" ids="title" slug="eltit" source="<string>" title="title"> +<document dupnames="title" ids="title eltit" slug="eltit" source="<string>" title="title"> <title> title - <section dupnames="title" ids="title-1" slug="eltit-1"> + <section dupnames="title" ids="title-1 eltit-1" slug="eltit-1"> <title> title <section ids="title-a-b-c" names="title\ a\ b\ c" slug="c b a eltit"> diff --git a/tests/test_renderers/fixtures/sphinx_link_resolution.md b/tests/test_renderers/fixtures/sphinx_link_resolution.md index b952ebb5..6cdf66a6 100644 --- a/tests/test_renderers/fixtures/sphinx_link_resolution.md +++ b/tests/test_renderers/fixtures/sphinx_link_resolution.md @@ -86,7 +86,7 @@ . <document source="<src>/index.md"> <target refid="target"> - <section ids="test target" names="test target"> + <section ids="target test" names="test target"> <title> Test <paragraph> @@ -119,7 +119,7 @@ <title> Test <target refid="id1"> - <section ids="other id1" names="other test"> + <section ids="id1 other" names="other test"> <title> Other <paragraph> diff --git a/tests/test_renderers/test_myst_refs/duplicate.xml b/tests/test_renderers/test_myst_refs/duplicate.xml index 323f14c0..9e4eeea7 100644 --- a/tests/test_renderers/test_myst_refs/duplicate.xml +++ b/tests/test_renderers/test_myst_refs/duplicate.xml @@ -1,6 +1,6 @@ <document source="root/index.md"> <target refid="index"> - <section ids="title index" names="title index"> + <section ids="index title" names="title index"> <title> Title <paragraph> diff --git a/tests/test_renderers/test_myst_refs/ref.xml b/tests/test_renderers/test_myst_refs/ref.xml index e4ae200d..29cb1f94 100644 --- a/tests/test_renderers/test_myst_refs/ref.xml +++ b/tests/test_renderers/test_myst_refs/ref.xml @@ -1,6 +1,6 @@ <document source="root/index.md"> <target refid="ref"> - <section ids="title ref" names="title ref"> + <section ids="ref title" names="title ref"> <title> Title <paragraph> diff --git a/tests/test_renderers/test_myst_refs/ref_colon.xml b/tests/test_renderers/test_myst_refs/ref_colon.xml index f1e9923b..398a0b43 100644 --- a/tests/test_renderers/test_myst_refs/ref_colon.xml +++ b/tests/test_renderers/test_myst_refs/ref_colon.xml @@ -1,6 +1,6 @@ <document source="root/index.md"> <target refid="ref-colon"> - <section ids="title ref-colon" names="title ref:colon"> + <section ids="ref-colon title" names="title ref:colon"> <title> Title <paragraph> diff --git a/tests/test_renderers/test_myst_refs/ref_nested.xml b/tests/test_renderers/test_myst_refs/ref_nested.xml index be69ef22..bcd1ec95 100644 --- a/tests/test_renderers/test_myst_refs/ref_nested.xml +++ b/tests/test_renderers/test_myst_refs/ref_nested.xml @@ -1,6 +1,6 @@ <document source="root/index.md"> <target refid="ref"> - <section ids="title ref" names="title ref"> + <section ids="ref title" names="title ref"> <title> Title <paragraph> diff --git a/tests/test_sphinx/test_sphinx_builds/test_basic.html b/tests/test_sphinx/test_sphinx_builds/test_basic.html index a87ea38a..a562c557 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_basic.html +++ b/tests/test_sphinx/test_sphinx_builds/test_basic.html @@ -24,12 +24,12 @@ side </p> </div> - <section class="tex2jax_ignore mathjax_ignore" id="header"> - <span id="target"> + <section class="tex2jax_ignore mathjax_ignore" id="target"> + <span id="header"> </span> <h1> Header - <a class="headerlink" href="#header" title="Link to this heading"> + <a class="headerlink" href="#target" title="Link to this heading"> ¶ </a> </h1> diff --git a/tests/test_sphinx/test_sphinx_builds/test_basic.resolved.xml b/tests/test_sphinx/test_sphinx_builds/test_basic.resolved.xml index 8e548702..4e0b0eee 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_basic.resolved.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_basic.resolved.xml @@ -16,7 +16,7 @@ dark side <target refid="target"> - <section classes="tex2jax_ignore mathjax_ignore" ids="header target" names="header target"> + <section classes="tex2jax_ignore mathjax_ignore" ids="target header" names="header target"> <title> Header <comment xml:space="preserve"> diff --git a/tests/test_sphinx/test_sphinx_builds/test_basic.xml b/tests/test_sphinx/test_sphinx_builds/test_basic.xml index 7dfd0cd7..fc23f2cc 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_basic.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_basic.xml @@ -16,7 +16,7 @@ dark side <target refid="target"> - <section classes="tex2jax_ignore mathjax_ignore" ids="header target" names="header target"> + <section classes="tex2jax_ignore mathjax_ignore" ids="target header" names="header target"> <title> Header <comment xml:space="preserve"> diff --git a/tests/test_sphinx/test_sphinx_builds/test_includes.html b/tests/test_sphinx/test_sphinx_builds/test_includes.html index 251d0e72..e2273579 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_includes.html +++ b/tests/test_sphinx/test_sphinx_builds/test_includes.html @@ -21,12 +21,12 @@ <h1> target </a> </p> - <section id="a-sub-heading-in-include"> - <span id="inc-header"> + <section id="inc-header"> + <span id="a-sub-heading-in-include"> </span> <h2> A Sub-Heading in Include - <a class="headerlink" href="#a-sub-heading-in-include" title="Link to this heading"> + <a class="headerlink" href="#inc-header" title="Link to this heading"> ¶ </a> </h2> diff --git a/tests/test_sphinx/test_sphinx_builds/test_includes.xml b/tests/test_sphinx/test_sphinx_builds/test_includes.xml index c12a9922..ede13005 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_includes.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_includes.xml @@ -9,7 +9,7 @@ <reference refuri="http://example.com/"> target <target refid="inc-header"> - <section ids="a-sub-heading-in-include inc-header" names="a\ sub-heading\ in\ include inc_header"> + <section ids="inc-header a-sub-heading-in-include" names="a\ sub-heading\ in\ include inc_header"> <title> A Sub-Heading in Include <paragraph> diff --git a/tests/test_sphinx/test_sphinx_builds/test_references.html b/tests/test_sphinx/test_sphinx_builds/test_references.html index c3dda3bf..77ec6dfb 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_references.html +++ b/tests/test_sphinx/test_sphinx_builds/test_references.html @@ -1,8 +1,10 @@ <div class="documentwrapper"> <div class="bodywrapper"> <div class="body" role="main"> - <section class="tex2jax_ignore mathjax_ignore" id="title-with-nested-a-1"> - <span id="title"> + <section class="tex2jax_ignore mathjax_ignore" id="title"> + <span id="title-with-nested-a-1"> + </span> + <span id="title-with-nested-"> </span> <h1> Title with @@ -12,7 +14,7 @@ <h1> <span class="math notranslate nohighlight"> \(a=1\) </span> - <a class="headerlink" href="#title-with-nested-a-1" title="Link to this heading"> + <a class="headerlink" href="#title" title="Link to this heading"> ¶ </a> </h1> @@ -198,6 +200,8 @@ <h2> </section> </section> <section class="tex2jax_ignore mathjax_ignore" id="intersphinx-via"> + <span id="intersphinx-via-"> + </span> <h1> Intersphinx via <code class="docutils literal notranslate"> @@ -269,6 +273,8 @@ <h1> </p> </section> <section class="tex2jax_ignore mathjax_ignore" id="image-in-title"> + <span id="image-in-title-"> + </span> <h1> Image in title <img alt="badge" src="https://shields.io/or/something.svg"/> diff --git a/tests/test_sphinx/test_sphinx_builds/test_references.resolved.xml b/tests/test_sphinx/test_sphinx_builds/test_references.resolved.xml index 4277ec6b..5322b865 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_references.resolved.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_references.resolved.xml @@ -1,6 +1,6 @@ <document source="index.md"> <target refid="title"> - <section classes="tex2jax_ignore mathjax_ignore" ids="title-with-nested-a-1 title" names="title\ with\ nested\ a=1 title" slug="title-with-nested-"> + <section classes="tex2jax_ignore mathjax_ignore" ids="title title-with-nested-a-1 title-with-nested-" names="title\ with\ nested\ a=1 title" slug="title-with-nested-"> <title> Title with <strong> @@ -116,7 +116,7 @@ <reference internal="True" refuri="subfolder/other2.html#title-anchors"> <inline classes="std std-ref"> Title anchors - <section classes="tex2jax_ignore mathjax_ignore" ids="intersphinx-via" names="intersphinx\ via\ #" slug="intersphinx-via-"> + <section classes="tex2jax_ignore mathjax_ignore" ids="intersphinx-via intersphinx-via-" names="intersphinx\ via\ #" slug="intersphinx-via-"> <title> Intersphinx via <literal> @@ -153,7 +153,7 @@ <reference internal="False" reftitle="Example 0.0.1" refuri="https://example.com/index.html#module-duplicate"> <literal classes="iref myst"> duplicate - <section classes="tex2jax_ignore mathjax_ignore" ids="image-in-title" names="image\ in\ title" slug="image-in-title-"> + <section classes="tex2jax_ignore mathjax_ignore" ids="image-in-title image-in-title-" names="image\ in\ title" slug="image-in-title-"> <title> Image in title <image alt="badge" candidates="{'?': 'https://shields.io/or/something.svg'}" uri="https://shields.io/or/something.svg"> diff --git a/tests/test_sphinx/test_sphinx_builds/test_references.xml b/tests/test_sphinx/test_sphinx_builds/test_references.xml index a3b74005..9204c001 100644 --- a/tests/test_sphinx/test_sphinx_builds/test_references.xml +++ b/tests/test_sphinx/test_sphinx_builds/test_references.xml @@ -1,6 +1,6 @@ <document source="index.md"> <target refid="title"> - <section classes="tex2jax_ignore mathjax_ignore" ids="title-with-nested-a-1 title" names="title\ with\ nested\ a=1 title" slug="title-with-nested-"> + <section classes="tex2jax_ignore mathjax_ignore" ids="title title-with-nested-a-1 title-with-nested-" names="title\ with\ nested\ a=1 title" slug="title-with-nested-"> <title> Title with <strong> @@ -98,7 +98,7 @@ <paragraph> <pending_xref refdoc="index" refdomain="doc" refexplicit="False" reftarget="subfolder/other2" reftargetid="title-anchors" reftype="myst"> <inline classes="xref myst"> - <section classes="tex2jax_ignore mathjax_ignore" ids="intersphinx-via" names="intersphinx\ via\ #" slug="intersphinx-via-"> + <section classes="tex2jax_ignore mathjax_ignore" ids="intersphinx-via intersphinx-via-" names="intersphinx\ via\ #" slug="intersphinx-via-"> <title> Intersphinx via <literal> @@ -131,7 +131,7 @@ Ambiguous <pending_xref refdoc="index" refdomain="True" refexplicit="False" reftarget="duplicate" reftype="myst"> <inline classes="xref myst"> - <section classes="tex2jax_ignore mathjax_ignore" ids="image-in-title" names="image\ in\ title" slug="image-in-title-"> + <section classes="tex2jax_ignore mathjax_ignore" ids="image-in-title image-in-title-" names="image\ in\ title" slug="image-in-title-"> <title> Image in title <image alt="badge" candidates="{'?': 'https://shields.io/or/something.svg'}" uri="https://shields.io/or/something.svg"> From 21f78918d81c5357a7823189997daed7a9b5c5a5 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Sun, 12 Jul 2026 17:00:37 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=A7=AA=20Slug=20corpus,=20CLI=20prese?= =?UTF-8?q?t=20option,=20cross-check=20tests,=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/fixtures/slugs.json: language-agnostic slugify/uniqueness corpus (expected values generated from the implementation), consumed by the new tests/test_slugs.py - myst-anchors CLI: gains --slug-func github|gitlab and now shares the renderer's slugify (previously drifted: the anchors-plugin default strips whitespace) - tests/test_anchors.py: CLI duplicate/preset cases + a renderer/CLI cross-check over a 13-heading corpus (the single-source-of-truth regression guard) - config/renderer tests: heading_anchors_html_ids escape hatch, unknown preset error, cross-doc link to a std label id resolving silently - docs: presets as the primary heading_slug_func form, secondary HTML ids + escape hatch, fixed duplicate suffixing, CLI example --- docs/syntax/cross-referencing.md | 2 +- docs/syntax/optional.md | 34 +++-- myst_parser/cli.py | 11 +- tests/fixtures/slugs.json | 138 ++++++++++++++++++ tests/test_anchors.py | 83 ++++++++++- tests/test_docutils.py | 26 ++++ tests/test_renderers/test_myst_refs.py | 2 + .../test_myst_refs/doc_with_target_id.xml | 8 + tests/test_slugs.py | 60 ++++++++ 9 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/slugs.json create mode 100644 tests/test_renderers/test_myst_refs/doc_with_target_id.xml create mode 100644 tests/test_slugs.py diff --git a/docs/syntax/cross-referencing.md b/docs/syntax/cross-referencing.md index 6b23b682..734720ba 100644 --- a/docs/syntax/cross-referencing.md +++ b/docs/syntax/cross-referencing.md @@ -69,7 +69,7 @@ Headings within documents can also be assigned an implicit target, by setting the `myst_heading_anchors` configuration option. This is should be set to an integer, between 1 and 6, indicating the depth of headings to assign targets to. -The anchor "slugs" are created according to the [GitHub implementation](https://github.com/Flet/github-slugger): heading titles are lower cased, punctuation is removed, spaces are replaced with `-`, and uniqueness is enforced by suffix enumeration. +By default the anchor "slugs" are created according to the [GitHub implementation](https://github.com/Flet/github-slugger): heading titles are lower cased, punctuation is removed, spaces are replaced with `-`, and uniqueness is enforced by suffix enumeration (`x`, `x-1`, `x-2`, ...). The slug function is configurable via `myst_heading_slug_func` (e.g. the `"gitlab"` preset); see [auto-generated header anchors](#syntax/header-anchors). For example, using `myst_heading_anchors = 2`: diff --git a/docs/syntax/optional.md b/docs/syntax/optional.md index bbd1fc38..c8b9f24c 100644 --- a/docs/syntax/optional.md +++ b/docs/syntax/optional.md @@ -528,27 +528,43 @@ The paths to other files should be relative to the current file: ### Anchor slug structure -The anchor "slugs" created aim to follow the [GitHub implementation](https://github.com/Flet/github-slugger): +By default the anchor "slugs" created aim to follow the [GitHub implementation](https://github.com/Flet/github-slugger): - lower-case text - remove punctuation - replace spaces with `-` -- enforce uniqueness *via* suffix enumeration `-1` +- enforce uniqueness *via* suffix enumeration: duplicates of `x` become `x`, `x-1`, `x-2`, ... (the base slug is never mutated) -To change the slug generation function, set `myst_heading_slug_func` in your `conf.py` to a function that accepts a string and returns a string. +The slug generation function is selected with the `myst_heading_slug_func` option in your `conf.py`. +The recommended form is a named preset: -:::{versionadded} 0.19.0 -`myst_heading_slug_func` can now also be set to a string, -which will be interpreted as an import path to a function, -e.g. `myst_heading_slug_func = "mypackage.mymodule.slugify"`. +```python +myst_heading_slug_func = "github" # the default; also available: "gitlab" +``` + +The `"gitlab"` preset follows [GitLab's algorithm](https://docs.gitlab.com/ee/user/markdown.html#heading-ids-and-links) (which, unlike GitHub, strips surrounding whitespace, squeezes repeated hyphens and prefixes digit-only slugs with `anchor-`). + +:::{versionchanged} 5.2.0 +`myst_heading_slug_func` now accepts the preset names `"github"` (default) and `"gitlab"`. + +As a legacy form, it can still be set to an import path string +(e.g. `myst_heading_slug_func = "mypackage.mymodule.slugify"`, added in 0.19.0) +or, in `conf.py`, directly to a callable that accepts a string and returns a string. +::: + +:::{note} +Anchor slugs are additionally emitted as (secondary) HTML `id` attributes on the rendered headings, so that `#slug` fragments resolve in the published HTML. +The primary docutils id is left first and unchanged, so previously published fragments keep working. +Set `myst_heading_anchors_html_ids = False` to disable this and emit only the docutils id. ::: ### Inspect the links that will be created -You can inspect the links that will be created using the command-line tool: +You can inspect the links that will be created using the command-line tool +(use `--slug-func` to preview a preset, matching `myst_heading_slug_func`): ```console -$ myst-anchors -l 2 docs/syntax/optional.md +$ myst-anchors -l 2 --slug-func github docs/syntax/optional.md <h1 id="optional-myst-syntaxes"></h1> <h2 id="admonition-directives"></h2> <h2 id="auto-generated-header-anchors"></h2> diff --git a/myst_parser/cli.py b/myst_parser/cli.py index a1f546d9..f678cd91 100644 --- a/myst_parser/cli.py +++ b/myst_parser/cli.py @@ -7,6 +7,7 @@ from myst_parser.config.main import MdParserConfig from myst_parser.parsers.mdit import create_md_parser +from myst_parser.slugs import SLUG_PRESETS def print_anchors(args=None): @@ -29,9 +30,17 @@ def print_anchors(args=None): arg_parser.add_argument( "-l", "--level", type=int, default=2, help="Maximum heading level." ) + arg_parser.add_argument( + "--slug-func", + choices=sorted(SLUG_PRESETS), + default="github", + help="Slug preset for anchor ids (matches myst_heading_slug_func presets).", + ) args = arg_parser.parse_args(args) parser = create_md_parser(MdParserConfig(), RendererHTML) - parser.use(anchors_plugin, max_level=args.level) + parser.use( + anchors_plugin, max_level=args.level, slug_func=SLUG_PRESETS[args.slug_func] + ) def _filter_plugin(state: StateCore) -> None: state.tokens = [ diff --git a/tests/fixtures/slugs.json b/tests/fixtures/slugs.json new file mode 100644 index 00000000..63170872 --- /dev/null +++ b/tests/fixtures/slugs.json @@ -0,0 +1,138 @@ +{ + "slugify": [ + { + "preset": "github", + "input": "Ubuntu 20.04", + "expected": "ubuntu-2004" + }, + { + "preset": "github", + "input": "lxc.env for environment variables", + "expected": "lxcenv-for-environment-variables" + }, + { + "preset": "github", + "input": "2.0", + "expected": "20" + }, + { + "preset": "github", + "input": "3rd party", + "expected": "3rd-party" + }, + { + "preset": "github", + "input": "Привет Мир", + "expected": "привет-мир" + }, + { + "preset": "github", + "input": "中文标题", + "expected": "中文标题" + }, + { + "preset": "github", + "input": " a b c ", + "expected": "-a-b-c-" + }, + { + "preset": "github", + "input": "Title with UPPER", + "expected": "title-with-upper" + }, + { + "preset": "github", + "input": "Hello 🎉 World!", + "expected": "hello--world" + }, + { + "preset": "gitlab", + "input": "Ubuntu 20.04", + "expected": "ubuntu-2004" + }, + { + "preset": "gitlab", + "input": "lxc.env for environment variables", + "expected": "lxcenv-for-environment-variables" + }, + { + "preset": "gitlab", + "input": "2.0", + "expected": "anchor-20" + }, + { + "preset": "gitlab", + "input": "3rd party", + "expected": "3rd-party" + }, + { + "preset": "gitlab", + "input": "Привет Мир", + "expected": "привет-мир" + }, + { + "preset": "gitlab", + "input": "中文标题", + "expected": "中文标题" + }, + { + "preset": "gitlab", + "input": " Spaced ", + "expected": "spaced" + }, + { + "preset": "gitlab", + "input": "Title with UPPER", + "expected": "title-with-upper" + }, + { + "preset": "gitlab", + "input": "Hello 🎉 World!", + "expected": "hello-world" + }, + { + "preset": "gitlab", + "input": "a - b", + "expected": "a-b" + } + ], + "unique": [ + { + "existing": [], + "slug": "a", + "expected": "a" + }, + { + "existing": [ + "a" + ], + "slug": "a", + "expected": "a-1" + }, + { + "existing": [ + "a", + "a-1" + ], + "slug": "a", + "expected": "a-2" + }, + { + "existing": [ + "a", + "a-1", + "a-2" + ], + "slug": "a", + "expected": "a-3" + }, + { + "existing": [ + "a", + "a-1" + ], + "slug": "a", + "expected": "a-2" + } + ] +} diff --git a/tests/test_anchors.py b/tests/test_anchors.py index 7fa49af2..df676cbb 100644 --- a/tests/test_anchors.py +++ b/tests/test_anchors.py @@ -1,13 +1,86 @@ +import re from io import StringIO from unittest import mock +from docutils import nodes +from docutils.core import publish_doctree + from myst_parser.cli import print_anchors +from myst_parser.parsers.docutils_ import Parser -def test_print_anchors(): - in_stream = StringIO("# a\n\n## b\n\ntext") +def _run_cli(text: str, *args: str) -> str: + """Run the ``myst-anchors`` CLI on ``text`` and return its output.""" out_stream = StringIO() - with mock.patch("sys.stdin", in_stream), mock.patch("sys.stdout", out_stream): - print_anchors(["-l", "1"]) + with mock.patch("sys.stdin", StringIO(text)), mock.patch("sys.stdout", out_stream): + print_anchors(list(args)) out_stream.seek(0) - assert out_stream.read().strip() == '<h1 id="a"></h1>' + return out_stream.read() + + +def test_print_anchors(): + assert _run_cli("# a\n\n## b\n\ntext", "-l", "1").strip() == '<h1 id="a"></h1>' + + +def test_print_anchors_duplicates(): + """Duplicate headings are suffixed as x, x-1, x-2.""" + out = _run_cli("# a\n\n# a\n\n# a", "-l", "1") + assert re.findall(r'id="([^"]*)"', out) == ["a", "a-1", "a-2"] + + +def test_print_anchors_gitlab(): + """``--slug-func gitlab`` applies the gitlab preset (digits-only -> anchor-).""" + out = _run_cli("# 2.0", "-l", "1", "--slug-func", "gitlab") + assert re.findall(r'id="([^"]*)"', out) == ["anchor-20"] + + +# a corpus exercising duplicates, dotted/digit titles, non-latin scripts, +# inline code, emphasis and internal whitespace +CROSS_CHECK_CORPUS = """\ +# Dup + +# Dup + +# Dup + +# Ubuntu 20.04 + +# lxc.env for environment variables + +# 3rd party + +# 2.0 + +# Привет Мир + +# 中文标题 + +# Some `code` here + +# Some *emphasis* text + +# x y +""" + + +def test_anchor_slugs_match_renderer(): + """Single source of truth: the CLI and the docutils renderer agree. + + The ids emitted by the ``myst-anchors`` CLI (in document order) must equal + the ``slug`` attributes stamped on section nodes by the docutils parser. + """ + cli_out = _run_cli(CROSS_CHECK_CORPUS, "-l", "6") + cli_ids = re.findall(r'id="([^"]*)"', cli_out) + + doctree = publish_doctree( + CROSS_CHECK_CORPUS, + parser=Parser(), + settings_overrides={"myst_heading_anchors": 6, "doctitle_xform": False}, + ) + doc_slugs = [ + section["slug"] + for section in doctree.findall(nodes.section) + if "slug" in section + ] + + assert cli_ids == doc_slugs diff --git a/tests/test_docutils.py b/tests/test_docutils.py index 8a716d51..f56dbf8b 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -356,6 +356,32 @@ def test_topmatter_alias_expansion_bomb_warns(): assert "content" in doctree.pformat() +def test_heading_anchors_html_ids_disabled(): + """``myst_heading_anchors_html_ids=False`` restores lookup-only slugs.""" + settings: dict = {"myst_heading_anchors": 1, "doctitle_xform": False} + doctree = publish_doctree( + source="# Ubuntu 20.04\n", parser=Parser(), settings_overrides=settings + ) + section = next(doctree.findall(nodes.section)) + assert section["ids"] == ["ubuntu-20-04", "ubuntu-2004"] + doctree = publish_doctree( + source="# Ubuntu 20.04\n", + parser=Parser(), + settings_overrides={**settings, "myst_heading_anchors_html_ids": False}, + ) + section = next(doctree.findall(nodes.section)) + assert section["ids"] == ["ubuntu-20-04"] + assert section["slug"] == "ubuntu-2004" + + +def test_heading_slug_func_unknown_preset(): + """An unknown ``heading_slug_func`` string errors, naming the presets.""" + from myst_parser.config.main import MdParserConfig + + with pytest.raises(TypeError, match="github, gitlab"): + MdParserConfig(heading_slug_func="bogus") + + def test_footnote_duplicate_definition_warns(): """A genuinely duplicated footnote definition still warns and is dropped.""" stream = io.StringIO() diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index b9c74bf0..05d7589a 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -33,6 +33,8 @@ ), ), ("ref_colon", "(ref:colon)=\n# Title\n[](ref:colon)", False), + # a std-domain label id in the target doc resolves without warning + ("doc_with_target_id", "(ref)=\n# Title\n[](index.md#ref)", False), ], ) def test_parse( diff --git a/tests/test_renderers/test_myst_refs/doc_with_target_id.xml b/tests/test_renderers/test_myst_refs/doc_with_target_id.xml new file mode 100644 index 00000000..94eda8c8 --- /dev/null +++ b/tests/test_renderers/test_myst_refs/doc_with_target_id.xml @@ -0,0 +1,8 @@ +<document source="root/index.md"> + <target refid="ref"> + <section ids="ref title" names="title ref"> + <title> + Title + <paragraph> + <reference internal="True" refid="ref"> + <inline classes="std std-ref"> diff --git a/tests/test_slugs.py b/tests/test_slugs.py new file mode 100644 index 00000000..0ffa4c01 --- /dev/null +++ b/tests/test_slugs.py @@ -0,0 +1,60 @@ +"""Tests for :mod:`myst_parser.slugs`. + +The cases are stored in a language-agnostic data corpus +(``tests/fixtures/slugs.json``), so that alternative MyST implementations +can reuse them; see the module docstring of ``myst_parser.slugs``. +""" + +import json +from pathlib import Path + +import pytest + +from myst_parser.slugs import SLUG_PRESETS, unique_slug + +CORPUS = json.loads( + Path(__file__).parent.joinpath("fixtures", "slugs.json").read_text("utf8") +) + + +@pytest.mark.parametrize( + "record", + CORPUS["slugify"], + ids=[f"{r['preset']}-{r['input']}" for r in CORPUS["slugify"]], +) +def test_slugify(record): + """Each preset slugify function matches the corpus.""" + slug_func = SLUG_PRESETS[record["preset"]] + assert slug_func(record["input"]) == record["expected"] + + +@pytest.mark.parametrize( + "record", + CORPUS["unique"], + ids=[f"{r['slug']}-in-{r['existing']}" for r in CORPUS["unique"]], +) +def test_unique_slug(record): + """``unique_slug`` suffixes against existing slugs (x, x-1, x-2, ...).""" + assert unique_slug(record["slug"], record["existing"]) == record["expected"] + + +def test_slug_presets_keys(): + """The presets are exactly the two documented ones.""" + assert set(SLUG_PRESETS) == {"github", "gitlab"} + + +def test_slugs_module_has_no_third_party_deps(): + """``myst_parser.slugs`` is pure standard library (only ``re``, ``typing``). + + This keeps it trivially portable for other MyST implementations. + """ + import ast + + source = Path(SLUG_PRESETS["github"].__code__.co_filename).read_text("utf8") + imported = set() + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Import): + imported.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + imported.add(node.module.split(".")[0]) + assert imported <= {"__future__", "re", "collections", "typing"} From 8cd2b7a3f9f96ceabd33652bc05c0872fd085be4 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Sun, 12 Jul 2026 17:33:53 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20review=20findings=20o?= =?UTF-8?q?n=20the=20heading-anchors=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slug ids are now emitted by a dedicated transform (`AddSlugIds`, priority 700) that runs after ALL other id assignment, instead of at parse time. This fixes two silent-change classes: a slug can no longer claim an id that docutils/the user would assign to a later element (previously renaming published and even explicitly chosen ids), and sphinx's SortIds can no longer rotate the slug into being a section's primary id for auto-`idN` headings (previously changing toc/searchindex anchors for non-ASCII/digit titles). - The local implicit-anchor fallback for `[](#target)` is now docutils-mode-only at resolve time; under sphinx it is recorded on the pending_xref (`reflocalid`) and used only after project-wide and intersphinx resolution both fail, so links that previously resolved to another document's explicit target keep doing so. - Cross-doc `[](./doc.md#name)` referencing a std label by *name* now resolves to the label's actual anchor id, instead of silently emitting the (broken) name as a fragment. - The documented `#`-destination resolution order now matches actual behaviour, with the new last-resort local step; the explicit-target primary-id change is documented with a versionchanged note. Each finding has a regression test; plus corpus edge rows and the doubled "(default:" in the docutils CLI help fixed. --- docs/syntax/cross-referencing.md | 15 ++++- myst_parser/mdit_to_docutils/base.py | 23 +++---- myst_parser/mdit_to_docutils/transforms.py | 64 ++++++++++++++++--- myst_parser/parsers/docutils_.py | 4 +- myst_parser/parsers/sphinx_.py | 2 + myst_parser/sphinx_ext/myst_refs.py | 55 ++++++++++++---- tests/fixtures/slugs.json | 17 +++++ tests/test_docutils.py | 30 +++++++++ tests/test_renderers/test_myst_refs.py | 61 ++++++++++++++++++ .../test_myst_refs/doc_with_target_name.xml | 8 +++ 10 files changed, 238 insertions(+), 41 deletions(-) create mode 100644 tests/test_renderers/test_myst_refs/doc_with_target_name.xml diff --git a/docs/syntax/cross-referencing.md b/docs/syntax/cross-referencing.md index 734720ba..18d2e224 100644 --- a/docs/syntax/cross-referencing.md +++ b/docs/syntax/cross-referencing.md @@ -17,6 +17,12 @@ There are three primary ways to create targets: 2. Annotating a syntax block/inline/span with an `{#id}` attribute (using the [attrs_block](#syntax/attributes/block) and [attrs_inline](#syntax/attributes/inline) extensions) 3. Adding a `name` option to a directive +```{versionchanged} 5.2.0 +An explicit target placed before a section heading now becomes the section's +primary HTML id, used by tocs, permalinks and `objects.inv`; the implicit +heading id is kept as a secondary anchor, so existing fragment links keep working. +``` + ::::{myst-example} (heading-target)= @@ -157,10 +163,15 @@ By default, MyST will resolve link destinations according to the following rules {style=lower-roman} 1. First, explicit targets in the same file are searched for, if not found - 2. Then, implicit targets in the same file are searched for, if not found + 2. Then, implicit [heading anchors](#syntax/header-anchors) in the same file are searched for, if not found 3. Then, explicit targets across the whole project are searched for, if not found 4. Then, intersphinx references are searched for, if not found - 5. A warning is emitted and the destination is left as an external link. + 5. Then, any other anchor that exists in the same file is used (e.g. a heading beyond the `myst_heading_anchors` depth), if not found + 6. A warning is emitted and the destination is left as an external link. + + ```{versionchanged} 5.2.0 + Step v was added: such links previously emitted a warning. + ``` :::{note} Local file path resolution and cross-project references are not available in [single page builds](#myst-docutils) diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 3db3851a..e6ab047d 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -251,6 +251,11 @@ def _render_finalise(self) -> None: self._heading_slugs ) + # ensure this setting is set for the later slug-id transform + self.document.settings.myst_heading_anchors_html_ids = ( + self.md_config.heading_anchors_html_ids + ) + # ensure these settings are set for later footnote transforms self.document.settings.myst_footnote_transition = ( self.md_config.footnote_transition @@ -867,20 +872,10 @@ def generate_heading_target( ) else: node["slug"] = slug - if ( - self.md_config.heading_anchors_html_ids - and slug - # a custom slug_func may produce whitespace, - # which is invalid in an HTML id - and not re.search(r"\s", slug) - and slug not in self.document.ids - ): - # also emit the slug as a (secondary) id, so that the anchor - # actually exists in published HTML output; the docutils id - # stays first, so all previously published fragments, - # and the targets of resolved references, are unchanged - node["ids"].append(slug) - self.document.ids[slug] = node + # note: the slug is additionally emitted as a (secondary) HTML id + # by the `AddSlugIds` transform, which runs only after *all* + # docutils/sphinx id assignment, so that it cannot claim an id + # another element would otherwise receive self._heading_slugs[slug] = (node.line, node["ids"][0], implicit_text) def render_heading(self, token: SyntaxTreeNode) -> None: diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index 52a8821d..722e69f5 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import typing as t from docutils import nodes @@ -138,14 +139,49 @@ def _sort_key(footnote: tuple[str, nodes.footnote]) -> int | str: self.document += footnote +class AddSlugIds(Transform): + """Emit each heading's anchor slug as an additional (secondary) id. + + This makes the anchor actually exist in published HTML output. + + It must run only after *all* other id assignment — docutils' + ``PropagateTargets`` (260) and sphinx's ``SortIds`` (261) included — + so that a slug can neither claim an id another element would otherwise + receive, nor become a section's primary id: primary ids (used by + tocs, permalinks and ``objects.inv``) are unchanged by this transform. + Slugs are registered in ``document.ids`` directly (not via ``set_id``), + deliberately bypassing ``id_prefix``: the raw slug is the anchor. + """ + + default_priority = 700 # after all id assignment, before ResolveAnchorIds + + def apply(self, **kwargs: t.Any) -> None: + """Apply the transform.""" + if not getattr(self.document.settings, "myst_heading_anchors_html_ids", True): + return + for node in findall(self.document)(): + slug = node.get("slug") if isinstance(node, nodes.Element) else None + if ( + slug + # a custom slug_func may produce whitespace, + # which is invalid in an HTML id + and not re.search(r"\s", slug) + and slug not in self.document.ids + ): + node["ids"].append(slug) + self.document.ids[slug] = node + + class PrioritiseExplicitIds(Transform): """Reorder ``section["ids"]`` so an explicitly named target's id is first. Docutils' ``PropagateTargets`` (priority 260) appends propagated target ids *after* the section's implicit id, so themes, tocs, permalinks and - ``objects.inv`` pick up the (unstable) implicit id. This moves the first - explicitly named id to the front; the implicit id remains in the list, - as a secondary anchor, so previously published fragments keep working. + ``objects.inv`` pick up the (unstable) implicit id. This moves the + explicitly named id earliest in the id list (for multiple ``(name)=`` + targets, that is the one nearest the heading) to the front; the implicit + id remains in the list, as a secondary anchor, so previously published + fragments keep working. """ default_priority = 261 # directly after docutils' PropagateTargets (260) @@ -258,24 +294,30 @@ def apply(self, **kwargs: t.Any) -> None: ) continue - # now search implicit docutils names and ids: these cover e.g. - # headings not assigned a slug (beyond the `heading_anchors` - # depth), whose anchors nonetheless exist in the output, so - # should resolve locally rather than falling through to - # project-wide resolution (or a warning) + # candidate implicit local anchor: covers e.g. headings not + # assigned a slug (beyond the `heading_anchors` depth), whose + # anchors nonetheless exist in the output labelid = self.document.nameids.get(target) or ( target if target in self.document.ids else None ) node = self.document.ids.get(labelid) if labelid else None - if node is not None and not ( + if node is None or ( node.tagname == "footnote" or "refuri" in node or node.tagname.startswith("desc_") ): + labelid = None + + # in docutils (single-document) mode, resolve to the local + # anchor directly (previously these links warned); + # in sphinx mode it is only recorded on the pending_xref, as a + # last-resort fallback after project-wide resolution, so that + # the precedence of existing reference resolution is unchanged + if labelid and not hasattr(self.document.settings, "env"): refnode["refid"] = labelid if not refnode.children: implicit_title = None - for subnode in node: + for subnode in node: # type: ignore[union-attr] if isinstance(subnode, nodes.caption | nodes.title): implicit_title = clean_astext(subnode) break @@ -294,6 +336,8 @@ def apply(self, **kwargs: t.Any) -> None: reftarget=target, refexplicit=bool(refnode.children), ) + if labelid: + pending["reflocalid"] = labelid inner_node = nodes.inline( "", "", classes=["xref", "myst"] + refnode["classes"] ) diff --git a/myst_parser/parsers/docutils_.py b/myst_parser/parsers/docutils_.py index 18316188..177a7f50 100644 --- a/myst_parser/parsers/docutils_.py +++ b/myst_parser/parsers/docutils_.py @@ -24,6 +24,7 @@ ) from myst_parser.mdit_to_docutils.base import DocutilsRenderer from myst_parser.mdit_to_docutils.transforms import ( + AddSlugIds, CollectFootnotes, PrioritiseExplicitIds, ResolveAnchorIds, @@ -143,7 +144,7 @@ def _attr_to_optparse_option(at: Field, default: Any) -> tuple[dict[str, Any], s if at.type is str or at.name == "heading_slug_func": return { "metavar": "<str>", - }, f"(default: '{default}')" + }, f"'{default}'" if get_origin(at.type) is Literal and all( isinstance(a, str) for a in get_args(at.type) ): @@ -256,6 +257,7 @@ def get_transforms(self): UnreferencedFootnotesDetector, SortFootnotes, CollectFootnotes, + AddSlugIds, PrioritiseExplicitIds, ResolveAnchorIds, ] diff --git a/myst_parser/parsers/sphinx_.py b/myst_parser/parsers/sphinx_.py index 5788470b..60035284 100644 --- a/myst_parser/parsers/sphinx_.py +++ b/myst_parser/parsers/sphinx_.py @@ -15,6 +15,7 @@ ) from myst_parser.mdit_to_docutils.sphinx_ import SphinxRenderer from myst_parser.mdit_to_docutils.transforms import ( + AddSlugIds, CollectFootnotes, PrioritiseExplicitIds, ResolveAnchorIds, @@ -54,6 +55,7 @@ def get_transforms(self): return super().get_transforms() + [ SortFootnotes, CollectFootnotes, + AddSlugIds, PrioritiseExplicitIds, ResolveAnchorIds, ] diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py index d074021c..5d8aa576 100644 --- a/myst_parser/sphinx_ext/myst_refs.py +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -99,6 +99,25 @@ def run(self, **kwargs: Any) -> None: newnode = self._resolve_myst_ref_intersphinx( node, contnode, target, search_domains ) + if newnode is None and node.get("reflocalid"): + # fall back to an implicit local anchor that demonstrably + # exists in the source document (e.g. a heading beyond the + # `heading_anchors` depth); only reached when project-wide + # and intersphinx resolution both failed, so the precedence + # of previously resolving references is unchanged + refid = node["reflocalid"] + newnode = nodes.reference() + newnode["refid"] = refid + inner = cast(nodes.TextElement, node[0].deepcopy()) + if not inner.children: + local_node = self.document.ids.get(refid) + for subnode in local_node or []: + if isinstance(subnode, nodes.caption | nodes.title): + title = clean_astext(subnode) + inner += nodes.Text(title) + break + newnode.append(inner) + if newnode is None: # if still not resolved, log a warning, self.log_warning( @@ -126,16 +145,23 @@ def run(self, **kwargs: Any) -> None: node.replace_self(newnode) - def _std_label_in_doc(self, docname: str, ref_id: str) -> bool: - """Whether ``ref_id`` is a std-domain label (name or id) in ``docname``.""" + def _std_label_id_in_doc(self, docname: str, ref_id: str) -> str | None: + """Resolve ``ref_id`` against the std-domain labels of ``docname``. + + Returns the label's id (the actual anchor) if ``ref_id`` is either + a label id or a label name in that document, else None. + """ std = self.env.domaindata.get("std", {}) - for name, entry in std.get("labels", {}).items(): - if entry[0] == docname and ref_id in (name, entry[1]): - return True - for name, entry in std.get("anonlabels", {}).items(): - if entry[0] == docname and ref_id in (name, entry[1]): - return True - return False + for store in ("labels", "anonlabels"): + for name, entry in std.get(store, {}).items(): + if entry[0] != docname: + continue + if entry[1] == ref_id: + return ref_id + if name == ref_id: + # referenced by label name: point at its actual anchor + return entry[1] + return None def resolve_myst_ref_doc(self, node: pending_xref): """Resolve a reference, from a markdown link, to another document, @@ -163,13 +189,14 @@ def resolve_myst_ref_doc(self, node: pending_xref): slug_to_section = self.env.metadata[ref_docname].get("myst_slugs", {}) if ref_id in slug_to_section: _, targetid, implicit_text = slug_to_section[ref_id] - elif any( - sect_id == ref_id for _, sect_id, _ in slug_to_section.values() - ) or self._std_label_in_doc(ref_docname, ref_id): + elif any(sect_id == ref_id for _, sect_id, _ in slug_to_section.values()): # the id demonstrably exists in the target document - # (a section's docutils id, or an explicit target), - # so resolve silently rather than warn + # (a section's docutils id), so resolve silently targetid = ref_id + elif (std_id := self._std_label_id_in_doc(ref_docname, ref_id)) is not None: + # an explicit target in the document, referenced by its + # id or name: point at its actual anchor + targetid = std_id else: self.log_warning( ref_id, diff --git a/tests/fixtures/slugs.json b/tests/fixtures/slugs.json index 63170872..9eeb9f2b 100644 --- a/tests/fixtures/slugs.json +++ b/tests/fixtures/slugs.json @@ -94,6 +94,16 @@ "preset": "gitlab", "input": "a - b", "expected": "a-b" + }, + { + "preset": "github", + "input": "!!!", + "expected": "" + }, + { + "preset": "gitlab", + "input": "!!!", + "expected": "" } ], "unique": [ @@ -133,6 +143,13 @@ ], "slug": "a", "expected": "a-2" + }, + { + "existing": [ + "a-1" + ], + "slug": "a", + "expected": "a" } ] } diff --git a/tests/test_docutils.py b/tests/test_docutils.py index f56dbf8b..0cf17121 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -374,6 +374,36 @@ def test_heading_anchors_html_ids_disabled(): assert section["slug"] == "ubuntu-2004" +def test_slug_id_cannot_steal_later_ids(): + """A heading's slug id must not claim an id another element receives. + + Regression: slugs were registered in ``document.ids`` at parse time, + so docutils deduplicated *later* elements' ids around them, silently + renaming previously published (or user-chosen) ids. + """ + settings: dict = {"myst_heading_anchors": 1, "doctitle_xform": False} + # the first heading's slug == the second heading's docutils id: + # the second heading must keep its id, the colliding slug is skipped + doctree = publish_doctree( + source="# Ubuntu 20.04\n\n# Ubuntu 2004\n", + parser=Parser(), + settings_overrides=settings, + ) + ids = [s["ids"] for s in doctree.findall(nodes.section)] + assert ids == [["ubuntu-20-04"], ["ubuntu-2004", "ubuntu-2004-1"]] + # the first heading's slug == a user's explicit target name: + # the explicit target keeps its id + doctree = publish_doctree( + source="# Ubuntu 20.04\n\n(ubuntu-2004)=\n\n# Other\n", + parser=Parser(), + settings_overrides=settings, + ) + ids = [s["ids"] for s in doctree.findall(nodes.section)] + assert ids[0] == ["ubuntu-20-04"] + assert ids[1][0] == "ubuntu-2004" + assert "ubuntu-2004-1" not in ids[1] + + def test_heading_slug_func_unknown_preset(): """An unknown ``heading_slug_func`` string errors, naming the presets.""" from myst_parser.config.main import MdParserConfig diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index 05d7589a..ff904ffb 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -35,6 +35,12 @@ ("ref_colon", "(ref:colon)=\n# Title\n[](ref:colon)", False), # a std-domain label id in the target doc resolves without warning ("doc_with_target_id", "(ref)=\n# Title\n[](index.md#ref)", False), + # a label referenced by *name* resolves to its actual anchor id + ( + "doc_with_target_name", + "(ref:colon)=\n# Title\n[](index.md#ref:colon)", + False, + ), ], ) def test_parse( @@ -62,3 +68,58 @@ def test_parse( if result.warnings.strip(): outcome += "\n\n" + strip_colors(result.warnings.strip()) file_regression.check(outcome, basename=test_name, extension=".xml") + + +def test_project_resolution_beats_local_implicit(sphinx_doctree: CreateDoctree): + """`[](#name)` prefers project-wide targets over local implicit names. + + Regression: a local implicit heading name (e.g. "Installation") must not + hijack a link that previously resolved to another document's explicit + target of the same name. + """ + sphinx_doctree.set_conf( + {"extensions": ["myst_parser"], "suppress_warnings": ["toc.not_included"]} + ) + sphinx_doctree.srcdir.joinpath("other.md").write_text( + "(installation)=\n\n# Other Install\n", encoding="utf8" + ) + result = sphinx_doctree("# Installation\n\n[](#installation)\n", "index.md") + doctree = result.get_resolved_doctree("index") + from docutils import nodes as docutils_nodes + + ref = next(doctree.findall(docutils_nodes.reference)) + # project-wide resolution (std:ref) fills in the *target* section's + # title and a refuri; the local fallback would set refid + local title + assert ref.astext() == "Other Install", ref.attributes + assert "refuri" in ref.attributes, ref.attributes + assert not ref.get("refid"), ref.attributes + + +def test_local_implicit_fallback_beyond_anchor_depth(sphinx_doctree: CreateDoctree): + """A heading beyond `heading_anchors` depth resolves locally, unwarned, + when nothing project-wide matches.""" + sphinx_doctree.set_conf({"extensions": ["myst_parser"], "myst_heading_anchors": 1}) + result = sphinx_doctree("# One\n\n## Deep Two\n\n[](#deep-two)\n", "index.md") + assert not result.warnings + doctree = result.get_resolved_doctree("index") + from docutils import nodes as docutils_nodes + + ref = next(doctree.findall(docutils_nodes.reference)) + assert ref.get("refid") == "deep-two", ref.attributes + + +def test_slug_id_stays_secondary_under_sortids(sphinx_doctree: CreateDoctree): + """Sphinx's SortIds must not promote a slug to a section's primary id. + + Regression: for auto-generated (`idN`) ids — non-ASCII or digit-leading + titles — SortIds rotated the docutils id to the back, silently changing + toc/searchindex anchors to the slug. + """ + sphinx_doctree.set_conf({"extensions": ["myst_parser"], "myst_heading_anchors": 2}) + result = sphinx_doctree("# Заголовок\n\n## Привет Мир\n", "index.md") + doctree = result.get_resolved_doctree("index") + from docutils import nodes as docutils_nodes + + for section in doctree.findall(docutils_nodes.section): + assert section["ids"][0].startswith("id"), section["ids"] + assert section["slug"] in section["ids"][1:], section["ids"] diff --git a/tests/test_renderers/test_myst_refs/doc_with_target_name.xml b/tests/test_renderers/test_myst_refs/doc_with_target_name.xml new file mode 100644 index 00000000..6470822b --- /dev/null +++ b/tests/test_renderers/test_myst_refs/doc_with_target_name.xml @@ -0,0 +1,8 @@ +<document source="root/index.md"> + <target refid="ref-colon"> + <section ids="ref-colon title" names="title ref:colon"> + <title> + Title + <paragraph> + <reference internal="True" refid="ref-colon"> + <inline classes="std std-ref"> From cf6a883d817e87ab2341e233e9ad59f5a9dbf1a0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:34:54 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_renderers/fixtures/myst-config.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_renderers/fixtures/myst-config.txt b/tests/test_renderers/fixtures/myst-config.txt index 348eaa15..62259d96 100644 --- a/tests/test_renderers/fixtures/myst-config.txt +++ b/tests/test_renderers/fixtures/myst-config.txt @@ -386,7 +386,7 @@ My paragraph Chris Sewell . -[inv_link] +[inv_link] . <inv:#index> [](inv:#index) @@ -437,7 +437,7 @@ My paragraph Title . -[inv_link_error] +[inv_link_error] . <inv:#other> From 64d1e40c81222e43cdb338837c6bf83070d7b002 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Sun, 12 Jul 2026 17:37:55 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=94=A7=20Remove=20unused=20type-ignor?= =?UTF-8?q?e=20(mypy=20with=20full=20stubs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myst_parser/mdit_to_docutils/transforms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index 722e69f5..9981bd63 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -317,7 +317,7 @@ def apply(self, **kwargs: t.Any) -> None: refnode["refid"] = labelid if not refnode.children: implicit_title = None - for subnode in node: # type: ignore[union-attr] + for subnode in node or []: if isinstance(subnode, nodes.caption | nodes.title): implicit_title = clean_astext(subnode) break From f7e91de2591e4ee17d9b3a70fc9bbcc83c2026d0 Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Sun, 12 Jul 2026 17:56:36 +0000 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=91=8C=20Apply=20review=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PrioritiseExplicitIds priority 261 -> 262: strictly after sphinx SortIds, independent of transform insertion order - AddSlugIds iterates elements only - legacy heading_slug_func import paths: AttributeError/ValueError now also produce the friendly TypeError - reflocalid fallback reference marked internal - CJK range in the github regex written in escaped form - tests: escape hatch under sphinx; whitespace-slug skip asserted directly --- myst_parser/config/main.py | 2 +- myst_parser/mdit_to_docutils/base.py | 2 +- myst_parser/mdit_to_docutils/transforms.py | 8 +++++--- myst_parser/slugs.py | 2 +- myst_parser/sphinx_ext/myst_refs.py | 1 + tests/test_docutils.py | 17 +++++++++++++++++ tests/test_renderers/test_myst_refs.py | 17 +++++++++++++++++ 7 files changed, 43 insertions(+), 6 deletions(-) diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py index ac8c158b..1e203fa6 100644 --- a/myst_parser/config/main.py +++ b/myst_parser/config/main.py @@ -152,7 +152,7 @@ def check_heading_slug_func( module_path, function_name = value.rsplit(".", 1) mod = import_module(module_path) value = getattr(mod, function_name) - except ImportError as exc: + except (ImportError, AttributeError, ValueError) as exc: raise TypeError( f"'{field.name}' could not be loaded from string: {value!r}" ) from exc diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index e6ab047d..8a29b5b1 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -2075,7 +2075,7 @@ def clean_astext(node: nodes.Element) -> str: def default_slugify(title: str) -> str: """Default slugify function (GitHub-style). - .. deprecated:: 5.2 + .. deprecated:: 5.2.0 Use :func:`myst_parser.slugs.github_slugify` instead. """ return github_slugify(title) diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index 9981bd63..a7f9427d 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -159,8 +159,8 @@ def apply(self, **kwargs: t.Any) -> None: """Apply the transform.""" if not getattr(self.document.settings, "myst_heading_anchors_html_ids", True): return - for node in findall(self.document)(): - slug = node.get("slug") if isinstance(node, nodes.Element) else None + for node in findall(self.document)(nodes.Element): + slug = node.get("slug") if ( slug # a custom slug_func may produce whitespace, @@ -184,7 +184,9 @@ class PrioritiseExplicitIds(Transform): fragments keep working. """ - default_priority = 261 # directly after docutils' PropagateTargets (260) + # strictly after docutils' PropagateTargets (260) and sphinx's SortIds + # (261), so the ordering does not depend on transform insertion order + default_priority = 262 def apply(self, **kwargs: t.Any) -> None: """Apply the transform.""" diff --git a/myst_parser/slugs.py b/myst_parser/slugs.py index 3cface04..5a32c657 100644 --- a/myst_parser/slugs.py +++ b/myst_parser/slugs.py @@ -14,7 +14,7 @@ import re from collections.abc import Callable, Container -_GITHUB_CLEAN = re.compile(r"[^\w一-鿿\- ]") +_GITHUB_CLEAN = re.compile(r"[^\w\u4e00-\u9fff\- ]") def github_slugify(title: str) -> str: diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py index 5d8aa576..bdb89b54 100644 --- a/myst_parser/sphinx_ext/myst_refs.py +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -108,6 +108,7 @@ def run(self, **kwargs: Any) -> None: refid = node["reflocalid"] newnode = nodes.reference() newnode["refid"] = refid + newnode["internal"] = True inner = cast(nodes.TextElement, node[0].deepcopy()) if not inner.children: local_node = self.document.ids.get(refid) diff --git a/tests/test_docutils.py b/tests/test_docutils.py index 0cf17121..1e17235c 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -404,6 +404,23 @@ def test_slug_id_cannot_steal_later_ids(): assert "ubuntu-2004-1" not in ids[1] +def test_whitespace_slug_not_emitted_as_id(): + """A custom slug_func returning whitespace does not produce an HTML id.""" + doctree = publish_doctree( + source="# ab cd\n", + parser=Parser(), + settings_overrides={ + "myst_heading_anchors": 1, + "doctitle_xform": False, + # reverses the title, producing "dc ba" (contains a space) + "myst_heading_slug_func": "myst_parser.config.main._test_slug_func", + }, + ) + section = next(doctree.findall(nodes.section)) + assert section["slug"] == "dc ba" + assert section["ids"] == ["ab-cd"] + + def test_heading_slug_func_unknown_preset(): """An unknown ``heading_slug_func`` string errors, naming the presets.""" from myst_parser.config.main import MdParserConfig diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index ff904ffb..2c861726 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -108,6 +108,23 @@ def test_local_implicit_fallback_beyond_anchor_depth(sphinx_doctree: CreateDoctr assert ref.get("refid") == "deep-two", ref.attributes +def test_heading_anchors_html_ids_disabled_sphinx(sphinx_doctree: CreateDoctree): + """The `myst_heading_anchors_html_ids=False` escape hatch works under sphinx.""" + sphinx_doctree.set_conf( + { + "extensions": ["myst_parser"], + "myst_heading_anchors": 2, + "myst_heading_anchors_html_ids": False, + } + ) + result = sphinx_doctree("# One\n\n## Ubuntu 20.04\n", "index.md") + doctree = result.get_resolved_doctree("index") + from docutils import nodes as docutils_nodes + + for section in doctree.findall(docutils_nodes.section): + assert section["slug"] not in section["ids"], section["ids"] + + def test_slug_id_stays_secondary_under_sortids(sphinx_doctree: CreateDoctree): """Sphinx's SortIds must not promote a slug to a section's primary id. From 1b920da1d258923d1b75afa5e9e32c98be364eed Mon Sep 17 00:00:00 2001 From: Chris Sewell <chrisj_sewell@hotmail.com> Date: Sun, 12 Jul 2026 17:57:51 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A7=AA=20Fix=20escape-hatch=20sphinx?= =?UTF-8?q?=20test:=20assert=20no=20extra=20id,=20not=20slug=20absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a heading like `# One` the slug equals the docutils id, so asserting the slug is absent from `ids` was wrong. --- tests/test_renderers/test_myst_refs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index 2c861726..bfb98f69 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -122,7 +122,9 @@ def test_heading_anchors_html_ids_disabled_sphinx(sphinx_doctree: CreateDoctree) from docutils import nodes as docutils_nodes for section in doctree.findall(docutils_nodes.section): - assert section["slug"] not in section["ids"], section["ids"] + # only the docutils id: no secondary slug id was emitted + assert len(section["ids"]) == 1, section["ids"] + assert "slug" in section.attributes, section.attributes def test_slug_id_stays_secondary_under_sortids(sphinx_doctree: CreateDoctree):