Skip to content

Commit eeedcdf

Browse files
committed
fixup! wip
1 parent c980d18 commit eeedcdf

File tree

5 files changed

+128
-71
lines changed

5 files changed

+128
-71
lines changed

src/mkdocs_autorefs/plugin.py

+54-25
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
from __future__ import annotations
1414

1515
import contextlib
16-
from dataclasses import dataclass
1716
import functools
1817
import logging
1918
from collections import defaultdict
19+
from dataclasses import dataclass
2020
from pathlib import PurePosixPath as URL # noqa: N814
2121
from typing import TYPE_CHECKING, Any, Callable
2222
from urllib.parse import urlsplit
@@ -26,16 +26,16 @@
2626
from mkdocs.config.config_options import Type
2727
from mkdocs.plugins import BasePlugin, event_priority
2828
from mkdocs.structure.pages import Page
29-
from mkdocs.structure.files import Files
30-
from mkdocs.structure.nav import Section
31-
from jinja2.environment import Environment
3229

33-
from mkdocs_autorefs.references import AutorefsExtension, URLAndTitle, _find_backlinks, fix_refs, relative_url
30+
from mkdocs_autorefs.references import AutorefsExtension, URLAndTitle, fix_refs, relative_url
3431

3532
if TYPE_CHECKING:
3633
from collections.abc import Sequence
3734

35+
from jinja2.environment import Environment
3836
from mkdocs.config.defaults import MkDocsConfig
37+
from mkdocs.structure.files import Files
38+
from mkdocs.structure.nav import Section
3939
from mkdocs.structure.pages import Page
4040
from mkdocs.structure.toc import AnchorLink
4141

@@ -48,15 +48,19 @@
4848
log = logging.getLogger(f"mkdocs.plugins.{__name__}") # type: ignore[assignment]
4949

5050

51-
@dataclass(order=True)
51+
@dataclass(eq=True, frozen=True, order=True)
5252
class BacklinkCrumb:
53+
"""A navigation breadcrumb for a backlink."""
54+
5355
title: str
5456
url: str
5557

5658

57-
@dataclass(order=True)
59+
@dataclass(eq=True, frozen=True, order=True)
5860
class Backlink:
59-
crumbs: list[BacklinkCrumb]
61+
"""A backlink (list of breadcrumbs)."""
62+
63+
crumbs: tuple[BacklinkCrumb, ...]
6064

6165

6266
class AutorefsConfig(Config):
@@ -77,6 +81,17 @@ class AutorefsConfig(Config):
7781
When false and multiple URLs are found for an identifier, autorefs will log a warning and resolve to the first URL.
7882
"""
7983

84+
link_titles = Type(bool, default=True)
85+
"""Whether to set titles on links.
86+
87+
When true, and when the link's text is different from the linked object's full id,
88+
autorefs will set the title HTML attribute on the link, using the linked object's full id.
89+
Such title attributes are displayed as tooltips when hovering over the links.
90+
91+
The presence of titles prevents the instant preview feature of Material for MkDocs from working,
92+
so it might be useful to disable this option when using this Material for MkDocs feature.
93+
"""
94+
8095

8196
class AutorefsPlugin(BasePlugin[AutorefsConfig]):
8297
"""The `autorefs` plugin for `mkdocs`.
@@ -164,26 +179,26 @@ def _record_backlink(self, identifier: str, backlink_type: str, backlink_anchor:
164179
if identifier in self._primary_url_map or identifier in self._secondary_url_map:
165180
self._backlinks[identifier][backlink_type].add(f"{page_url}#{backlink_anchor}")
166181

167-
def get_backlinks(self, *identifiers: str, from_url: str) -> dict[str, list[Backlink]]:
182+
def get_backlinks(self, *identifiers: str, from_url: str) -> dict[str, set[Backlink]]:
168183
"""Return the backlinks to an identifier relative to the given URL.
169184
170185
Arguments:
171186
*identifiers: The identifiers to get backlinks for.
172187
from_url: The URL of the page where backlinks are rendered.
173188
174189
Returns:
175-
A dictionary of backlinks, with the type of reference as key and a list of backlinks as balue.
176-
Each backlink is a list of (URL, title) tuples forming navigation breadcrumbs in reverse.
190+
A dictionary of backlinks, with the type of reference as key and a set of backlinks as value.
191+
Each backlink is a tuple of (URL, title) tuples forming navigation breadcrumbs.
177192
"""
178-
relative_backlinks: dict[str, list[Backlink]] = defaultdict(list)
179-
for identifier in identifiers:
193+
relative_backlinks: dict[str, set[Backlink]] = defaultdict(set)
194+
for identifier in set(identifiers):
180195
backlinks = self._backlinks.get(identifier, {})
181196
for backlink_type, backlink_urls in backlinks.items():
182197
for backlink_url in backlink_urls:
183-
relative_backlinks[backlink_type].append(self._nav(from_url, backlink_url))
198+
relative_backlinks[backlink_type].add(self._crumbs(from_url, backlink_url))
184199
return relative_backlinks
185200

186-
def _nav(self, from_url: str, backlink_url: str) -> Backlink:
201+
def _crumbs(self, from_url: str, backlink_url: str) -> Backlink:
187202
backlink_page: Page = self._backlink_page_map[backlink_url]
188203
backlink_title = self._title_map[backlink_url]
189204
crumbs: list[BacklinkCrumb] = [
@@ -196,10 +211,11 @@ def _nav(self, from_url: str, backlink_url: str) -> Backlink:
196211
if url := getattr(page, "url", ""):
197212
url = relative_url(from_url, url + "#")
198213
crumbs.append(BacklinkCrumb(page.title, url))
199-
return Backlink(crumbs)
214+
return Backlink(tuple(reversed(crumbs)))
200215

201216
def register_anchor(
202217
self,
218+
page: Page,
203219
identifier: str,
204220
anchor: str | None = None,
205221
*,
@@ -209,12 +225,12 @@ def register_anchor(
209225
"""Register that an anchor corresponding to an identifier was encountered when rendering the page.
210226
211227
Arguments:
228+
page: The page where the anchor was found.
212229
identifier: The identifier to register.
213230
anchor: The anchor on the page, without `#`. If not provided, defaults to the identifier.
214231
title: The title of the anchor (optional).
215232
primary: Whether this anchor is the primary one for the identifier.
216233
"""
217-
page: Page = self.current_page # type: ignore[assignment]
218234
url = f"{page.url}#{anchor or identifier}"
219235
url_map = self._primary_url_map if primary else self._secondary_url_map
220236
if identifier in url_map:
@@ -389,23 +405,24 @@ def on_page_content(self, html: str, page: Page, **kwargs: Any) -> str: # noqa:
389405
if self.scan_toc:
390406
log.debug("Mapping identifiers to URLs for page %s", page.file.src_path)
391407
for item in page.toc.items:
392-
self.map_urls(item)
408+
self.map_urls(page, item)
393409
return html
394410

395-
def map_urls(self, anchor: AnchorLink) -> None:
411+
def map_urls(self, page: Page, anchor: AnchorLink) -> None:
396412
"""Recurse on every anchor to map its ID to its absolute URL.
397413
398414
This method populates `self._primary_url_map` by side-effect.
399415
400416
Arguments:
417+
page: The page containing the anchors.
401418
anchor: The anchor to process and to recurse on.
402419
"""
403-
self.register_anchor(anchor.id, title=anchor.title, primary=True)
420+
self.register_anchor(page, anchor.id, title=anchor.title, primary=True)
404421
for child in anchor.children:
405-
self.map_urls(child)
422+
self.map_urls(page, child)
406423

407424
@event_priority(-50) # Late, after mkdocstrings has finished loading inventories.
408-
def on_env(self, env: Environment, /, *, config: MkDocsConfig, files: Files) -> Environment:
425+
def on_env(self, env: Environment, /, *, config: MkDocsConfig, files: Files) -> Environment: # noqa: ARG002
409426
"""Apply cross-references and collect backlinks.
410427
411428
Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env).
@@ -431,14 +448,26 @@ def on_env(self, env: Environment, /, *, config: MkDocsConfig, files: Files) ->
431448
log.debug("Applying cross-refs in page %s", file.page.file.src_path)
432449

433450
# YORE: Bump 2: Replace `, fallback=self.get_fallback_anchor` with `` within line.
434-
url_mapper = functools.partial(self.get_item_url, from_url=file.page.url, fallback=self.get_fallback_anchor)
451+
url_mapper = functools.partial(
452+
self.get_item_url,
453+
from_url=file.page.url,
454+
fallback=self.get_fallback_anchor,
455+
)
435456
backlink_recorder = functools.partial(self._record_backlink, page_url=file.page.url)
436457
# YORE: Bump 2: Replace `, _legacy_refs=self.legacy_refs` with `` within line.
437-
file.page.content, unmapped = fix_refs(file.page.content, url_mapper, record_backlink=backlink_recorder, _legacy_refs=self.legacy_refs)
458+
file.page.content, unmapped = fix_refs(
459+
file.page.content,
460+
url_mapper,
461+
record_backlink=backlink_recorder,
462+
link_titles=self.config.link_titles,
463+
_legacy_refs=self.legacy_refs,
464+
)
438465

439466
if unmapped and log.isEnabledFor(logging.WARNING):
440467
for ref, context in unmapped:
441468
message = f"from {context.filepath}:{context.lineno}: ({context.origin}) " if context else ""
442-
log.warning(f"{file.page.file.src_path}: {message}Could not find cross-reference target '{ref}'")
469+
log.warning(
470+
f"{file.page.file.src_path}: {message}Could not find cross-reference target '{ref}'",
471+
)
443472

444473
return env

src/mkdocs_autorefs/references.py

+22-13
Original file line numberDiff line numberDiff line change
@@ -388,15 +388,17 @@ def _find_backlinks(html: str) -> Iterator[tuple[str, str, str]]:
388388
def _tooltip(identifier: str, title: str | None) -> str:
389389
if title:
390390
if title == identifier:
391-
return title
392-
return f"{title} ({identifier})"
393-
return identifier
391+
return escape(title)
392+
return escape(f"{title} ({identifier})")
393+
return escape(identifier)
394394

395395

396396
def fix_ref(
397397
url_mapper: Callable[[str], URLAndTitle],
398398
unmapped: list[tuple[str, AutorefsHookInterface.Context | None]],
399399
record_backlink: Callable[[str, str, str], None] | None = None,
400+
*,
401+
link_titles: bool = True,
400402
) -> Callable:
401403
"""Return a `repl` function for [`re.sub`](https://docs.python.org/3/library/re.html#re.sub).
402404
@@ -411,6 +413,7 @@ def fix_ref(
411413
such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
412414
unmapped: A list to store unmapped identifiers.
413415
record_backlink: A callable to record backlinks.
416+
link_titles: Whether to set HTML titles on links.
414417
415418
Returns:
416419
The actual function accepting a [`Match` object](https://docs.python.org/3/library/re.html#match-objects)
@@ -427,7 +430,11 @@ def inner(match: Match) -> str:
427430

428431
identifiers = (identifier, slug) if slug else (identifier,)
429432

430-
if record_backlink and (backlink_type := attrs.get("backlink-type")) and (backlink_anchor := attrs.get("backlink-anchor")):
433+
if (
434+
record_backlink
435+
and (backlink_type := attrs.get("backlink-type"))
436+
and (backlink_anchor := attrs.get("backlink-anchor"))
437+
):
431438
record_backlink(identifier, backlink_type, backlink_anchor)
432439

433440
try:
@@ -452,9 +459,8 @@ def inner(match: Match) -> str:
452459
class_attr = " ".join(classes)
453460
if remaining := attrs.remaining:
454461
remaining = f" {remaining}"
455-
if optional and hover:
456-
return f'<a class="{class_attr}" href="{escape(url)}"{remaining}>{title}</a>'
457-
return f'<a class="{class_attr}" href="{escape(url)}"{remaining}>{title}</a>'
462+
title_attr = f' title="{_tooltip(identifier, original_title)}"' if hover and link_titles else ""
463+
return f'<a class="{class_attr}"{title_attr} href="{escape(url)}"{remaining}>{title}</a>'
458464

459465
return inner
460466

@@ -465,8 +471,9 @@ def fix_refs(
465471
# YORE: Bump 2: Remove line.
466472
*,
467473
record_backlink: Callable[[str, str, str], None] | None = None,
474+
link_titles: bool = True,
468475
# YORE: Bump 2: Remove line.
469-
_legacy_refs: bool = True, # noqa: FBT001, FBT002
476+
_legacy_refs: bool = True,
470477
) -> tuple[str, list[tuple[str, AutorefsHookInterface.Context | None]]]:
471478
"""Fix all references in the given HTML text.
472479
@@ -475,12 +482,13 @@ def fix_refs(
475482
url_mapper: A callable that gets an object's site URL by its identifier,
476483
such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
477484
record_backlink: A callable to record backlinks.
485+
link_titles: Whether to set HTML titles on links.
478486
479487
Returns:
480488
The fixed HTML, and a list of unmapped identifiers (string and optional context).
481489
"""
482490
unmapped: list[tuple[str, AutorefsHookInterface.Context | None]] = []
483-
html = AUTOREF_RE.sub(fix_ref(url_mapper, unmapped, record_backlink), html)
491+
html = AUTOREF_RE.sub(fix_ref(url_mapper, unmapped, record_backlink, link_titles=link_titles), html)
484492

485493
# YORE: Bump 2: Remove block.
486494
if _legacy_refs:
@@ -552,9 +560,10 @@ def append(self, anchor: str) -> None:
552560
self.anchors.append(anchor)
553561

554562
def flush(self, alias_to: str | None = None, title: str | None = None) -> None:
555-
for anchor in self.anchors:
556-
self.plugin.register_anchor(anchor, alias_to, title=title, primary=True)
557-
self.anchors.clear()
563+
if page := self.plugin.current_page:
564+
for anchor in self.anchors:
565+
self.plugin.register_anchor(page, anchor, alias_to, title=title, primary=True)
566+
self.anchors.clear()
558567

559568

560569
class BacklinksTreeProcessor(Treeprocessor):
@@ -589,7 +598,7 @@ def _enhance_autorefs(self, parent: Element) -> None:
589598
if not (el.text or el.get("href") or (el.tail and el.tail.strip())) and (anchor_id := el.get("id")):
590599
self.last_heading_id = anchor_id
591600
elif el.tag in self._htags: # Heading.
592-
self.last_heading_id = el.get("id")
601+
self.last_heading_id = el.get("id")
593602
elif el.tag == "autoref":
594603
if "backlink-type" not in el.attrib:
595604
el.set("backlink-type", "referenced-by")

tests/helpers.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Helper functions for the tests."""
2+
3+
from mkdocs.config.defaults import MkDocsConfig
4+
from mkdocs.structure.files import File
5+
from mkdocs.structure.pages import Page
6+
7+
8+
def create_page(url: str) -> Page:
9+
"""Create a page with the given URL."""
10+
return Page(
11+
title=url,
12+
file=File(url, "docs", "site", use_directory_urls=False),
13+
config=MkDocsConfig(),
14+
)

0 commit comments

Comments
 (0)