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
@@ -130,7 +130,7 @@
Test
-
+
Other
@@ -143,7 +143,7 @@
/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)
@@ -437,7 +437,7 @@ My paragraph
Title
.
-[inv_link_error]
+[inv_link_error]
.
@@ -473,10 +473,10 @@ My paragraph
[reversed](#eltit)
.
-
+
title
-
+
title
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 @@
.
-
+
Test
@@ -119,7 +119,7 @@
Test
-
+
Other
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 @@
-
+
Title
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 @@
-
+
Title
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 @@
-
+
Title
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 @@
-
+
Title
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
-