Skip to content

Commit 14d6e55

Browse files
committed
feat: Add link_titles option and adapt related logic
This change adds a `link_titles` option that defaults to `"auto"`. In automatic mode, link titles are either: - always set if Material for MkDocs and its instant preview feature aren't detected - only set on external links otherwise (since instant preview are not supported on external links anyway) The option also accepts the `True` and `False`, for always/never setting titles, respectively. An update to the title logic accompanies this change in order to make use of recorded heading titles (a change brought two commit ago): - optional cross-references will use the original title, and optionally append the identifier if it doesn't already appear in the title - mandatory cross-references will use either the original title if there's one, or no title at all This is because optional cross-refs are almost exclusively created by mkdocstrings handlers, and therefore displaying the identifier (full qualified name of objects) is useful when hovering on a link. Manual cross-references on the other hand can often be references to text sections, and should never display the section anchor. The limitation being that manual cross-references to API objects won't show the identifier. We could consider using an additional attribute (other than `optional`) to label cross-refs as "API objects" or not, though users would still have to annotate their manual cross-refs with such an attribute to enjoy the appended identifier to the title. Issue-33: #33 Issue-62: #62
1 parent 254d703 commit 14d6e55

File tree

5 files changed

+176
-30
lines changed

5 files changed

+176
-30
lines changed

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,22 @@ You can also change the actual identifier of a heading, thanks again to the `att
182182
```
183183

184184
...though note that this will impact the URL anchor too (and therefore the permalink to the heading).
185+
186+
### Link titles
187+
188+
When rendering cross-references, the autorefs plugin sets `title` HTML attributes on links. These titles are displayed as tooltips when hovering on links. For mandatory cross-references (user-written ones), the original title of the target section is used as tooltip, for example: `Original title`. For optional cross-references (typically rendered by mkdocstrings handlers), the identifier is appended to the original title, for example: `Original title (package.module.function)`. This is useful to indicate the fully qualified name of API objects.
189+
190+
Since the presence of titles prevents [the instant preview feature of Material for MkDocs][instant-preview] from working, the autorefs plugin will detect when this theme and feature are used, and only set titles on *external* links (for which instant previews cannot work).
191+
192+
If you want to force autorefs to always set titles, never set titles, or only set titles on external links, you can use the `link_titles` option:
193+
194+
```yaml
195+
plugins:
196+
- autorefs:
197+
link_titles: external
198+
# link_titles: true
199+
# link_titles: false
200+
# link_titles: auto # default
201+
```
202+
203+
[instant-preview]: https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#instant-previews

src/mkdocs_autorefs/plugin.py

+33-2
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313
import functools
1414
import logging
1515
from pathlib import PurePosixPath as URL # noqa: N814
16-
from typing import TYPE_CHECKING, Any, Callable
16+
from typing import TYPE_CHECKING, Any, Callable, Literal
1717
from urllib.parse import urlsplit
1818
from warnings import warn
1919

2020
from mkdocs.config.base import Config
21-
from mkdocs.config.config_options import Type
21+
from mkdocs.config.config_options import Choice, Type
2222
from mkdocs.plugins import BasePlugin, event_priority
2323
from mkdocs.structure.pages import Page
2424

@@ -60,6 +60,22 @@ class AutorefsConfig(Config):
6060
When false and multiple URLs are found for an identifier, autorefs will log a warning and resolve to the first URL.
6161
"""
6262

63+
link_titles: bool | Literal["auto", "external"] = Choice((True, False, "auto", "external"), default="auto") # type: ignore[assignment]
64+
"""Whether to set titles on links.
65+
66+
Such title attributes are displayed as tooltips when hovering over the links.
67+
68+
- `"auto"`: autorefs will detect whether the instant preview feature of Material for MkDocs is enabled,
69+
and set titles on external links when it is, all links if it is not.
70+
- `"external"`: autorefs will set titles on external links only.
71+
- `True`: autorefs will set titles on all links.
72+
- `False`: autorefs will not set any title attributes on links.
73+
74+
Titles are only set when they are different from the link's text.
75+
Titles are constructed from the linked heading's original title,
76+
optionally appending the identifier for API objects.
77+
"""
78+
6379

6480
class AutorefsPlugin(BasePlugin[AutorefsConfig]):
6581
"""The `autorefs` plugin for `mkdocs`.
@@ -115,6 +131,8 @@ def __init__(self) -> None:
115131
# YORE: Bump 2: Remove line.
116132
self._get_fallback_anchor: Callable[[str], tuple[str, ...]] | None = None
117133

134+
self._link_titles: bool | Literal["external"] = True
135+
118136
# YORE: Bump 2: Remove block.
119137
@property
120138
def get_fallback_anchor(self) -> Callable[[str], tuple[str, ...]] | None:
@@ -284,6 +302,18 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
284302
"""
285303
log.debug("Adding AutorefsExtension to the list")
286304
config.markdown_extensions.append(AutorefsExtension(self)) # type: ignore[arg-type]
305+
306+
if self.config.link_titles == "auto":
307+
if getattr(config.theme, "name", None) == "material" and "navigation.instant.preview" in config.theme.get(
308+
"features",
309+
(),
310+
):
311+
self._link_titles = "external"
312+
else:
313+
self._link_titles = True
314+
else:
315+
self._link_titles = self.config.link_titles
316+
287317
return config
288318

289319
def on_page_markdown(self, markdown: str, page: Page, **kwargs: Any) -> str: # noqa: ARG002
@@ -369,6 +399,7 @@ def on_env(self, env: Environment, /, *, config: MkDocsConfig, files: Files) ->
369399
file.page.content, unmapped = fix_refs(
370400
file.page.content,
371401
url_mapper,
402+
link_titles=self._link_titles,
372403
_legacy_refs=self.legacy_refs,
373404
)
374405

src/mkdocs_autorefs/references.py

+43-11
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from functools import lru_cache
1111
from html import escape, unescape
1212
from html.parser import HTMLParser
13-
from typing import TYPE_CHECKING, Any, Callable, ClassVar
13+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal
1414
from urllib.parse import urlsplit
1515
from xml.etree.ElementTree import Element
1616

@@ -319,7 +319,7 @@ class _AutorefsAttrs(dict):
319319
_handled_attrs: ClassVar[set[str]] = {
320320
"identifier",
321321
"optional",
322-
"hover",
322+
"hover", # TODO: Remove at some point.
323323
"class",
324324
"domain",
325325
"role",
@@ -376,9 +376,22 @@ def _find_url(
376376
raise KeyError(f"None of the identifiers {identifiers} were found")
377377

378378

379+
def _tooltip(identifier: str, title: str | None) -> str:
380+
if title:
381+
# Don't append identifier if it's already in the title.
382+
if identifier in title:
383+
return title
384+
# Append identifier (useful for API objects).
385+
return f"{title} (<code>{identifier}</code>)"
386+
# No title, just return the identifier.
387+
return f"<code>{identifier}</code>"
388+
389+
379390
def fix_ref(
380391
url_mapper: Callable[[str], tuple[str, str | None]],
381392
unmapped: list[tuple[str, AutorefsHookInterface.Context | None]],
393+
*,
394+
link_titles: bool | Literal["external"] = True,
382395
) -> Callable:
383396
"""Return a `repl` function for [`re.sub`](https://docs.python.org/3/library/re.html#re.sub).
384397
@@ -392,6 +405,7 @@ def fix_ref(
392405
url_mapper: A callable that gets an object's site URL by its identifier,
393406
such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
394407
unmapped: A list to store unmapped identifiers.
408+
link_titles: How to set HTML titles on links. Always (`True`), never (`False`), or external-only (`"external"`).
395409
396410
Returns:
397411
The actual function accepting a [`Match` object](https://docs.python.org/3/library/re.html#match-objects)
@@ -404,7 +418,6 @@ def inner(match: Match) -> str:
404418
identifier: str = attrs["identifier"]
405419
slug = attrs.get("slug", None)
406420
optional = "optional" in attrs
407-
hover = "hover" in attrs
408421

409422
identifiers = (identifier, slug) if slug else (identifier,)
410423

@@ -413,9 +426,7 @@ def inner(match: Match) -> str:
413426
except KeyError:
414427
if optional:
415428
log.debug("Unresolved optional cross-reference: %s", identifier)
416-
if hover:
417-
return f'<span title="{identifier}">{title}</span>'
418-
return title
429+
return f'<span title="{identifier}">{title}</span>'
419430
unmapped.append((identifier, attrs.context))
420431
if title == identifier:
421432
return f"[{identifier}][]"
@@ -425,36 +436,57 @@ def inner(match: Match) -> str:
425436

426437
parsed = urlsplit(url)
427438
external = parsed.scheme or parsed.netloc
439+
428440
classes = (attrs.get("class") or "").strip().split()
429441
classes = ["autorefs", "autorefs-external" if external else "autorefs-internal", *classes]
430442
class_attr = " ".join(classes)
443+
431444
if remaining := attrs.remaining:
432445
remaining = f" {remaining}"
433-
if optional and hover:
434-
return f'<a class="{class_attr}" title="{identifier}" href="{escape(url)}"{remaining}>{title}</a>'
435-
return f'<a class="{class_attr}" href="{escape(url)}"{remaining}>{title}</a>'
446+
447+
title_attr = ""
448+
if link_titles is True or (link_titles == "external" and external):
449+
if optional:
450+
# The `optional` attribute is generally only added by mkdocstrings handlers,
451+
# for API objects, meaning we can and should append the full identifier.
452+
tooltip = _tooltip(identifier, original_title)
453+
else:
454+
# Autorefs without `optional` are generally user-written ones,
455+
# so we should only use the original title.
456+
tooltip = original_title or ""
457+
458+
if tooltip and tooltip not in f"<code>{title}</code>":
459+
title_attr = f' title="{escape(tooltip)}"'
460+
461+
return f'<a class="{class_attr}"{title_attr} href="{escape(url)}"{remaining}>{title}</a>'
436462

437463
return inner
438464

439465

440466
def fix_refs(
441467
html: str,
442468
url_mapper: Callable[[str], tuple[str, str | None]],
469+
*,
470+
link_titles: bool | Literal["external"] = True,
443471
# YORE: Bump 2: Remove line.
444-
_legacy_refs: bool = True, # noqa: FBT001, FBT002
472+
_legacy_refs: bool = True,
445473
) -> tuple[str, list[tuple[str, AutorefsHookInterface.Context | None]]]:
446474
"""Fix all references in the given HTML text.
447475
448476
Arguments:
449477
html: The text to fix.
450478
url_mapper: A callable that gets an object's site URL by its identifier,
451479
such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
480+
link_titles: How to set HTML titles on links. Always (`True`), never (`False`), or external-only (`"external"`).
452481
453482
Returns:
454483
The fixed HTML, and a list of unmapped identifiers (string and optional context).
455484
"""
456485
unmapped: list[tuple[str, AutorefsHookInterface.Context | None]] = []
457-
html = AUTOREF_RE.sub(fix_ref(url_mapper, unmapped), html)
486+
html = AUTOREF_RE.sub(
487+
fix_ref(url_mapper, unmapped, link_titles=link_titles),
488+
html,
489+
)
458490

459491
# YORE: Bump 2: Remove block.
460492
if _legacy_refs:

tests/test_plugin.py

+51
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
from __future__ import annotations
44

55
import functools
6+
from typing import Literal
67

78
import pytest
9+
from mkdocs.config.defaults import MkDocsConfig
10+
from mkdocs.theme import Theme
811

912
from mkdocs_autorefs.plugin import AutorefsConfig, AutorefsPlugin
1013
from mkdocs_autorefs.references import fix_refs
@@ -124,3 +127,51 @@ def test_use_closest_url(caplog: pytest.LogCaptureFixture, primary: bool) -> Non
124127
fix_refs('<autoref identifier="foo">Foo</autoref>', url_mapper, _legacy_refs=False)
125128
qualifier = "primary" if primary else "secondary"
126129
assert f"Multiple {qualifier} URLs found for 'foo': ['foo.html#foo', 'bar.html#foo']" not in caplog.text
130+
131+
def test_on_config_hook() -> None:
132+
"""Check that the `on_config` hook runs without issue."""
133+
plugin = AutorefsPlugin()
134+
plugin.config = AutorefsConfig()
135+
plugin.on_config(config=MkDocsConfig())
136+
137+
138+
def test_auto_link_titles_external() -> None:
139+
"""Check that `link_titles` are made external when automatic and Material is detected."""
140+
plugin = AutorefsPlugin()
141+
plugin.config = AutorefsConfig()
142+
plugin.config.link_titles = "auto"
143+
config = MkDocsConfig()
144+
config.theme = Theme(name="material", features=["navigation.instant.preview"])
145+
plugin.on_config(config=config)
146+
assert plugin._link_titles == "external"
147+
148+
149+
def test_auto_link_titles() -> None:
150+
"""Check that `link_titles` are made true when automatic and Material is not detected."""
151+
plugin = AutorefsPlugin()
152+
plugin.config = AutorefsConfig()
153+
plugin.config.link_titles = "auto"
154+
config = MkDocsConfig()
155+
156+
config.theme = Theme(name="material", features=[])
157+
plugin.on_config(config=config)
158+
assert plugin._link_titles is True
159+
160+
config.theme = Theme("mkdocs")
161+
plugin.on_config(config=config)
162+
assert plugin._link_titles is True
163+
164+
config.theme = Theme("readthedocs")
165+
plugin.on_config(config=config)
166+
assert plugin._link_titles is True
167+
168+
169+
@pytest.mark.parametrize("link_titles", ["external", True, False])
170+
def test_explicit_link_titles(link_titles: bool | Literal["external"]) -> None:
171+
"""Check that explicit `link_titles` are kept unchanged."""
172+
plugin = AutorefsPlugin()
173+
plugin.config = AutorefsConfig()
174+
plugin.config.link_titles = link_titles
175+
plugin.on_config(config=MkDocsConfig())
176+
assert plugin._link_titles is link_titles
177+

tests/test_references.py

+30-17
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from textwrap import dedent
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, Any
77

88
import markdown
99
import pytest
@@ -46,12 +46,13 @@ def test_relative_url(current_url: str, to_url: str, href_url: str) -> None:
4646

4747

4848
def run_references_test(
49-
url_map: dict[str, str],
49+
url_map: Mapping[str, str],
5050
source: str,
5151
output: str,
5252
unmapped: list[tuple[str, AutorefsHookInterface.Context | None]] | None = None,
5353
from_url: str = "page.html",
54-
extensions: Mapping = {},
54+
extensions: Mapping[str, Mapping[str, Any]] | None = None,
55+
title_map: Mapping[str, str] | None = None,
5556
) -> None:
5657
"""Help running tests about references.
5758
@@ -62,11 +63,13 @@ def run_references_test(
6263
unmapped: The expected unmapped list.
6364
from_url: The source page URL.
6465
"""
66+
extensions = extensions or {}
6567
md = markdown.Markdown(extensions=[AutorefsExtension(), *extensions], extension_configs=extensions)
6668
content = md.convert(source)
69+
title_map = title_map or {}
6770

6871
def url_mapper(identifier: str) -> tuple[str, str | None]:
69-
return relative_url(from_url, url_map[identifier]), None
72+
return relative_url(from_url, url_map[identifier]), title_map.get(identifier, None)
7073

7174
actual_output, actual_unmapped = fix_refs(content, url_mapper)
7275
assert actual_output == output
@@ -257,8 +260,8 @@ def test_custom_optional_reference() -> None:
257260
"""Check that optional HTML-based references are expanded and never reported missing."""
258261
run_references_test(
259262
url_map={"ok": "ok.html#ok"},
260-
source='<autoref optional identifier="bar">foo</autoref> <autoref identifier=ok optional>ok</autoref>',
261-
output='<p>foo <a class="autorefs autorefs-internal" href="ok.html#ok">ok</a></p>',
263+
source='<autoref optional identifier="foo">bar</autoref> <autoref optional identifier="ok">ok</autoref>',
264+
output='<p><span title="foo">bar</span> <a class="autorefs autorefs-internal" href="ok.html#ok">ok</a></p>',
262265
)
263266

264267

@@ -273,15 +276,6 @@ def test_legacy_custom_optional_hover_reference() -> None:
273276
)
274277

275278

276-
def test_custom_optional_hover_reference() -> None:
277-
"""Check that optional-hover HTML-based references are expanded and never reported missing."""
278-
run_references_test(
279-
url_map={"ok": "ok.html#ok"},
280-
source='<autoref optional hover identifier="bar">foo</autoref> <autoref optional identifier=ok hover>ok</autoref>',
281-
output='<p><span title="bar">foo</span> <a class="autorefs autorefs-internal" title="ok" href="ok.html#ok">ok</a></p>',
282-
)
283-
284-
285279
# YORE: Bump 2: Remove block.
286280
def test_legacy_external_references() -> None:
287281
"""Check that external references are marked as such."""
@@ -405,8 +399,8 @@ def test_keep_data_attributes() -> None:
405399
"""Keep HTML data attributes from autorefs spans."""
406400
run_references_test(
407401
url_map={"example": "https://e.com#a"},
408-
source='<autoref optional identifier="example" class="hi ho" data-foo data-bar="0">e</autoref>',
409-
output='<p><a class="autorefs autorefs-external hi ho" href="https://e.com#a" data-foo data-bar="0">e</a></p>',
402+
source='<autoref optional identifier="example" class="hi ho" data-foo data-bar="0">example</autoref>',
403+
output='<p><a class="autorefs autorefs-external hi ho" href="https://e.com#a" data-foo data-bar="0">example</a></p>',
410404
)
411405

412406

@@ -486,3 +480,22 @@ def test_no_fallback_for_provided_identifiers() -> None:
486480
output="<p>[Hello][Hello world]</p>",
487481
unmapped=[("Hello world", None)],
488482
)
483+
484+
485+
def test_title_use_identifier() -> None:
486+
"""Check that the identifier is used for the title."""
487+
run_references_test(
488+
url_map={"fully.qualified.name": "ok.html#fully.qualified.name"},
489+
source='<autoref optional identifier="fully.qualified.name">name</autoref>',
490+
output='<p><a class="autorefs autorefs-internal" title="fully.qualified.name" href="ok.html#fully.qualified.name">name</a></p>',
491+
)
492+
493+
494+
def test_title_append_identifier() -> None:
495+
"""Check that the identifier is appended to the title."""
496+
run_references_test(
497+
url_map={"fully.qualified.name": "ok.html#fully.qualified.name"},
498+
title_map={"fully.qualified.name": "Qualified Name"},
499+
source='<autoref optional identifier="fully.qualified.name">name</autoref>',
500+
output='<p><a class="autorefs autorefs-internal" title="Qualified Name (fully.qualified.name)" href="ok.html#fully.qualified.name">name</a></p>',
501+
)

0 commit comments

Comments
 (0)