diff --git a/docs/syntax/cross-referencing.md b/docs/syntax/cross-referencing.md
index 6b23b682..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)=
@@ -69,7 +75,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`:
@@ -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/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
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/myst_parser/config/main.py b/myst_parser/config/main.py
index 9bf637dc..1e203fa6 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, AttributeError, ValueError) 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..8a29b5b1 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
@@ -243,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
@@ -859,6 +872,10 @@ def generate_heading_target(
)
else:
node["slug"] = slug
+ # 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:
@@ -2055,30 +2072,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.
-
- This aims to mimic the GitHub Markdown format, see:
+ """Default slugify function (GitHub-style).
- - https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/toc_filter.rb
- - https://gist.github.com/asabaylus/3071099
+ .. deprecated:: 5.2.0
+ 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 +2095,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..a7f9427d 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,6 +139,70 @@ 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)(nodes.Element):
+ slug = node.get("slug")
+ 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
+ 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.
+ """
+
+ # 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."""
+ 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 +296,37 @@ def apply(self, **kwargs: t.Any) -> None:
)
continue
+ # 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 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 or []:
+ 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
@@ -242,6 +338,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 d205f06a..177a7f50 100644
--- a/myst_parser/parsers/docutils_.py
+++ b/myst_parser/parsers/docutils_.py
@@ -24,7 +24,9 @@
)
from myst_parser.mdit_to_docutils.base import DocutilsRenderer
from myst_parser.mdit_to_docutils.transforms import (
+ AddSlugIds,
CollectFootnotes,
+ PrioritiseExplicitIds,
ResolveAnchorIds,
SortFootnotes,
UnreferencedFootnotesDetector,
@@ -142,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": "",
- }, f"(default: '{default}')"
+ }, f"'{default}'"
if get_origin(at.type) is Literal and all(
isinstance(a, str) for a in get_args(at.type)
):
@@ -255,6 +257,8 @@ 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 5708c950..60035284 100644
--- a/myst_parser/parsers/sphinx_.py
+++ b/myst_parser/parsers/sphinx_.py
@@ -15,7 +15,9 @@
)
from myst_parser.mdit_to_docutils.sphinx_ import SphinxRenderer
from myst_parser.mdit_to_docutils.transforms import (
+ AddSlugIds,
CollectFootnotes,
+ PrioritiseExplicitIds,
ResolveAnchorIds,
SortFootnotes,
)
@@ -53,6 +55,8 @@ def get_transforms(self):
return super().get_transforms() + [
SortFootnotes,
CollectFootnotes,
+ AddSlugIds,
+ PrioritiseExplicitIds,
ResolveAnchorIds,
]
diff --git a/myst_parser/slugs.py b/myst_parser/slugs.py
new file mode 100644
index 00000000..5a32c657
--- /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\u4e00-\u9fff\- ]")
+
+
+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..bdb89b54 100644
--- a/myst_parser/sphinx_ext/myst_refs.py
+++ b/myst_parser/sphinx_ext/myst_refs.py
@@ -99,6 +99,26 @@ 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
+ newnode["internal"] = True
+ 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,6 +146,24 @@ def run(self, **kwargs: Any) -> None:
node.replace_self(newnode)
+ 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 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,
optionally with a target id within that document.
@@ -150,7 +188,17 @@ 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()):
+ # the id demonstrably exists in the target document
+ # (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,
f"local id not found in doc {ref_docname!r}: {ref_id!r}",
@@ -158,8 +206,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/fixtures/slugs.json b/tests/fixtures/slugs.json
new file mode 100644
index 00000000..9eeb9f2b
--- /dev/null
+++ b/tests/fixtures/slugs.json
@@ -0,0 +1,155 @@
+{
+ "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"
+ },
+ {
+ "preset": "github",
+ "input": "!!!",
+ "expected": ""
+ },
+ {
+ "preset": "gitlab",
+ "input": "!!!",
+ "expected": ""
+ }
+ ],
+ "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"
+ },
+ {
+ "existing": [
+ "a-1"
+ ],
+ "slug": "a",
+ "expected": "a"
+ }
+ ]
+}
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() == ''
+ return out_stream.read()
+
+
+def test_print_anchors():
+ assert _run_cli("# a\n\n## b\n\ntext", "-l", "1").strip() == ''
+
+
+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..1e17235c 100644
--- a/tests/test_docutils.py
+++ b/tests/test_docutils.py
@@ -356,6 +356,79 @@ 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_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_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
+
+ 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/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..62259d96 100644
--- a/tests/test_renderers/fixtures/myst-config.txt
+++ b/tests/test_renderers/fixtures/myst-config.txt
@@ -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.py b/tests/test_renderers/test_myst_refs.py
index b9c74bf0..bfb98f69 100644
--- a/tests/test_renderers/test_myst_refs.py
+++ b/tests/test_renderers/test_myst_refs.py
@@ -33,6 +33,14 @@
),
),
("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(
@@ -60,3 +68,77 @@ 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_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):
+ # 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):
+ """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_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 @@
+
+
+
+
+ Title
+
+
+
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 @@
+
+
+
+
+ Title
+
+
+
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_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"}
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
-