Coverage for src/mkdocs_autorefs/_internal/references.py: 91.23%
309 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-24 14:21 +0100
« prev ^ index » next coverage.py v7.6.12, created at 2025-02-24 14:21 +0100
1# Cross-references module.
3from __future__ import annotations
5import logging
6import re
7import warnings
8from abc import ABC, abstractmethod
9from dataclasses import dataclass
10from functools import lru_cache
11from html import escape, unescape
12from html.parser import HTMLParser
13from io import StringIO
14from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal
15from urllib.parse import urlsplit
16from xml.etree.ElementTree import Element
18from markdown.core import Markdown
19from markdown.extensions import Extension
20from markdown.extensions.toc import slugify
21from markdown.inlinepatterns import REFERENCE_RE, ReferenceInlineProcessor
22from markdown.treeprocessors import Treeprocessor
23from markdown.util import HTML_PLACEHOLDER_RE, INLINE_PLACEHOLDER_RE
24from markupsafe import Markup
26from mkdocs_autorefs._internal.backlinks import BacklinksTreeProcessor
28if TYPE_CHECKING:
29 from collections.abc import Iterable
30 from pathlib import Path
31 from re import Match
33 from markdown import Markdown
35 from mkdocs_autorefs._internal.plugin import AutorefsPlugin
37try:
38 from mkdocs.plugins import get_plugin_logger
40 _log = get_plugin_logger(__name__)
41except ImportError:
42 # TODO: remove once support for MkDocs <1.5 is dropped
43 _log = logging.getLogger(f"mkdocs.plugins.{__name__}") # type: ignore[assignment]
46AUTOREF_RE = re.compile(r"<autoref (?P<attrs>.*?)>(?P<title>.*?)</autoref>", flags=re.DOTALL)
47"""The autoref HTML tag regular expression.
49A regular expression to match mkdocs-autorefs' special reference markers
50in the [`on_env` hook][mkdocs_autorefs.AutorefsPlugin.on_env].
51"""
54class AutorefsHookInterface(ABC):
55 """An interface for hooking into how AutoRef handles inline references."""
57 @dataclass
58 class Context:
59 """The context around an auto-reference."""
61 domain: str
62 """A domain like `py` or `js`."""
63 role: str
64 """A role like `class` or `function`."""
65 origin: str
66 """The origin of an autoref (an object identifier)."""
67 filepath: str | Path
68 """The path to the file containing the autoref."""
69 lineno: int
70 """The line number in the file containing the autoref."""
72 def as_dict(self) -> dict[str, str]:
73 """Convert the context to a dictionary of HTML attributes."""
74 return {
75 "domain": self.domain,
76 "role": self.role,
77 "origin": self.origin,
78 "filepath": str(self.filepath),
79 "lineno": str(self.lineno),
80 }
82 @abstractmethod
83 def expand_identifier(self, identifier: str) -> str:
84 """Expand an identifier in a given context.
86 Parameters:
87 identifier: The identifier to expand.
89 Returns:
90 The expanded identifier.
91 """
92 raise NotImplementedError
94 @abstractmethod
95 def get_context(self) -> AutorefsHookInterface.Context:
96 """Get the current context.
98 Returns:
99 The current context.
100 """
101 raise NotImplementedError
104class AutorefsInlineProcessor(ReferenceInlineProcessor):
105 """A Markdown extension to handle inline references."""
107 name: str = "mkdocs-autorefs"
108 """The name of the inline processor."""
110 hook: AutorefsHookInterface | None = None
111 """The hook to use for expanding identifiers or adding context to autorefs."""
113 def __init__(self, *args: Any, **kwargs: Any) -> None:
114 super().__init__(REFERENCE_RE, *args, **kwargs)
116 # Code based on
117 # https://github.com/Python-Markdown/markdown/blob/8e7528fa5c98bf4652deb13206d6e6241d61630b/markdown/inlinepatterns.py#L780
118 def handleMatch(self, m: Match[str], data: str) -> tuple[Element | None, int | None, int | None]: # type: ignore[override] # noqa: N802
119 """Handle an element that matched.
121 Arguments:
122 m: The match object.
123 data: The matched data.
125 Returns:
126 A new element or a tuple.
127 """
128 text, index, handled = self.getText(data, m.end(0))
129 if not handled: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 return None, None, None
132 identifier, slug, end, handled = self._eval_id(data, index, text)
133 if not handled or identifier is None:
134 return None, None, None
136 if slug is None and re.search(r"[\x00-\x1f]", identifier): 136 ↛ 139line 136 didn't jump to line 139 because the condition on line 136 was never true
137 # Do nothing if the matched reference still contains control characters (from 0 to 31 included)
138 # that weren't unstashed when trying to compute a slug of the title.
139 return None, m.start(0), end
141 return self._make_tag(identifier, text, slug=slug), m.start(0), end
143 def _unstash(self, identifier: str) -> str:
144 stashed_nodes: dict[str, Element | str] = self.md.treeprocessors["inline"].stashed_nodes # type: ignore[attr-defined]
146 def _repl(match: Match) -> str:
147 el = stashed_nodes.get(match[1])
148 if isinstance(el, Element):
149 return f"`{''.join(el.itertext())}`"
150 if el == "\x0296\x03": 150 ↛ 152line 150 didn't jump to line 152 because the condition on line 150 was always true
151 return "`"
152 return str(el)
154 return INLINE_PLACEHOLDER_RE.sub(_repl, identifier)
156 def _eval_id(self, data: str, index: int, text: str) -> tuple[str | None, str | None, int, bool]:
157 """Evaluate the id portion of `[ref][id]`.
159 If `[ref][]` use `[ref]`.
161 Arguments:
162 data: The data to evaluate.
163 index: The starting position.
164 text: The text to use when no identifier.
166 Returns:
167 A tuple containing the identifier, its optional slug, its end position, and whether it matched.
168 """
169 m = self.RE_LINK.match(data, pos=index)
170 if not m:
171 return None, None, index, False
173 if identifier := m.group(1):
174 # An identifier was provided, match it exactly (later).
175 slug = None
176 else:
177 # Only a title was provided, use it as identifier.
178 identifier = text
180 # Catch single stash entries, like the result of [`Foo`][].
181 if match := INLINE_PLACEHOLDER_RE.fullmatch(identifier):
182 stashed_nodes: dict[str, Element | str] = self.md.treeprocessors["inline"].stashed_nodes # type: ignore[attr-defined]
183 el = stashed_nodes.get(match[1])
184 if isinstance(el, Element) and el.tag == "code": 184 ↛ 202line 184 didn't jump to line 202 because the condition on line 184 was always true
185 # The title was wrapped in backticks, we only keep the content,
186 # and tell autorefs to match the identifier exactly.
187 identifier = "".join(el.itertext())
188 slug = None
189 # Special case: allow pymdownx.inlinehilite raw <code> snippets but strip them back to unhighlighted.
190 if match := HTML_PLACEHOLDER_RE.fullmatch(identifier):
191 stash_index = int(match.group(1))
192 html = self.md.htmlStash.rawHtmlBlocks[stash_index]
193 identifier = Markup(html).striptags()
194 self.md.htmlStash.rawHtmlBlocks[stash_index] = escape(identifier)
196 # In any other case, unstash the title and slugify it.
197 # Examples: ``[`Foo` and `Bar`]``, `[The *Foo*][]`.
198 else:
199 identifier = self._unstash(identifier)
200 slug = slugify(identifier, separator="-")
202 end = m.end(0)
203 return identifier, slug, end, True
205 def _make_tag(self, identifier: str, text: str, *, slug: str | None = None) -> Element:
206 """Create a tag that can be matched by `AUTO_REF_RE`.
208 Arguments:
209 identifier: The identifier to use in the HTML property.
210 text: The text to use in the HTML tag.
212 Returns:
213 A new element.
214 """
215 el = Element("autoref")
216 if self.hook: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 identifier = self.hook.expand_identifier(identifier)
218 el.attrib.update(self.hook.get_context().as_dict())
219 el.set("identifier", identifier)
220 el.text = text
221 if slug:
222 el.attrib["slug"] = slug
223 return el
226class AnchorScannerTreeProcessor(Treeprocessor):
227 """Tree processor to scan and register HTML anchors."""
229 name: str = "mkdocs-autorefs-anchors-scanner"
230 """The name of the tree processor."""
232 _htags: ClassVar[set[str]] = {"h1", "h2", "h3", "h4", "h5", "h6"}
234 def __init__(self, plugin: AutorefsPlugin, md: Markdown | None = None) -> None:
235 """Initialize the tree processor.
237 Parameters:
238 plugin: A reference to the autorefs plugin, to use its `register_anchor` method.
239 """
240 super().__init__(md)
241 self._plugin = plugin
243 def run(self, root: Element) -> None:
244 """Run the tree processor.
246 Arguments:
247 root: The root element of the tree.
248 """
249 if self._plugin.current_page is not None: 249 ↛ exitline 249 didn't return from function 'run' because the condition on line 249 was always true
250 pending_anchors = _PendingAnchors(self._plugin)
251 self._scan_anchors(root, pending_anchors)
252 pending_anchors.flush()
254 def _scan_anchors(self, parent: Element, pending_anchors: _PendingAnchors, last_heading: str | None = None) -> None:
255 for el in parent:
256 if el.tag == "a":
257 # We found an anchor. Record its id if it has one.
258 if anchor_id := el.get("id"): 258 ↛ 262line 258 didn't jump to line 262 because the condition on line 258 was always true
259 pending_anchors.append(anchor_id)
260 # If the element has text or a link, it's not an alias.
261 # Non-whitespace text after the element interrupts the chain, aliases can't apply.
262 if el.text or el.get("href") or (el.tail and el.tail.strip()):
263 pending_anchors.flush(title=last_heading)
265 elif el.tag == "p":
266 # A `p` tag is a no-op for our purposes, just recurse into it in the context
267 # of the current collection of anchors.
268 self._scan_anchors(el, pending_anchors, last_heading)
269 # Non-whitespace text after the element interrupts the chain, aliases can't apply.
270 if el.tail and el.tail.strip(): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 pending_anchors.flush()
273 elif el.tag in self._htags:
274 # If the element is a heading, that turns the pending anchors into aliases.
275 last_heading = el.text
276 pending_anchors.flush(el.get("id"), title=last_heading)
278 else:
279 # But if it's some other interruption, flush anchors anyway as non-aliases.
280 pending_anchors.flush(title=last_heading)
281 # Recurse into sub-elements, in a *separate* context.
282 self.run(el)
285class AutorefsExtension(Extension):
286 """Markdown extension that transforms unresolved references into auto-references.
288 Auto-references are then resolved later by the MkDocs plugin.
290 This extension also scans Markdown anchors (`[](){#some-id}`)
291 to register them with the MkDocs plugin.
292 """
294 def __init__(
295 self,
296 plugin: AutorefsPlugin | None = None,
297 **kwargs: Any,
298 ) -> None:
299 """Initialize the Markdown extension.
301 Parameters:
302 plugin: An optional reference to the autorefs plugin (to pass it to the anchor scanner tree processor).
303 **kwargs: Keyword arguments passed to the [base constructor][markdown.Extension].
304 """
305 super().__init__(**kwargs)
306 self.plugin = plugin
307 """A reference to the autorefs plugin."""
309 def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
310 """Register the extension.
312 Add an instance of our [`AutorefsInlineProcessor`][mkdocs_autorefs.AutorefsInlineProcessor] to the Markdown parser.
313 Also optionally add an instance of our [`AnchorScannerTreeProcessor`][mkdocs_autorefs.AnchorScannerTreeProcessor]
314 and [`BacklinksTreeProcessor`][mkdocs_autorefs.BacklinksTreeProcessor] to the Markdown parser
315 if a reference to the autorefs plugin was passed to this extension.
317 Arguments:
318 md: A `markdown.Markdown` instance.
319 """
320 md.inlinePatterns.register(
321 AutorefsInlineProcessor(md),
322 AutorefsInlineProcessor.name,
323 priority=168, # Right after markdown.inlinepatterns.ReferenceInlineProcessor
324 )
325 if self.plugin is not None:
326 # Markdown anchors require the `attr_list` extension.
327 if self.plugin.scan_toc and "attr_list" in md.treeprocessors: 327 ↛ 336line 327 didn't jump to line 336 because the condition on line 327 was always true
328 _log_enabling_markdown_anchors()
329 md.treeprocessors.register(
330 AnchorScannerTreeProcessor(self.plugin, md),
331 AnchorScannerTreeProcessor.name,
332 priority=0,
333 )
334 # Backlinks require IDs on headings, which are either set by `toc`,
335 # or manually by the user with `attr_list`.
336 if self.plugin.record_backlinks and ("attr_list" in md.treeprocessors or "toc" in md.treeprocessors):
337 _log_enabling_backlinks()
338 md.treeprocessors.register(
339 BacklinksTreeProcessor(self.plugin, md),
340 BacklinksTreeProcessor.name,
341 priority=0,
342 )
345class _PendingAnchors:
346 """A collection of HTML anchors that may or may not become aliased to an upcoming heading."""
348 def __init__(self, plugin: AutorefsPlugin):
349 self.plugin = plugin
350 self.anchors: list[str] = []
352 def append(self, anchor: str) -> None:
353 self.anchors.append(anchor)
355 def flush(self, alias_to: str | None = None, title: str | None = None) -> None:
356 if page := self.plugin.current_page: 356 ↛ exitline 356 didn't return from function 'flush' because the condition on line 356 was always true
357 for anchor in self.anchors:
358 self.plugin.register_anchor(page, anchor, alias_to, title=title, primary=True)
359 self.anchors.clear()
362class _AutorefsAttrs(dict):
363 _handled_attrs: ClassVar[set[str]] = {
364 "identifier",
365 "optional",
366 "hover", # TODO: Remove at some point.
367 "class",
368 "domain",
369 "role",
370 "origin",
371 "filepath",
372 "lineno",
373 "slug",
374 "backlink-type",
375 "backlink-anchor",
376 }
378 @property
379 def context(self) -> AutorefsHookInterface.Context | None:
380 try:
381 return AutorefsHookInterface.Context(
382 domain=self["domain"],
383 role=self["role"],
384 origin=self["origin"],
385 filepath=self["filepath"],
386 lineno=int(self["lineno"]),
387 )
388 except KeyError:
389 return None
391 @property
392 def remaining(self) -> str:
393 return " ".join(k if v is None else f'{k}="{v}"' for k, v in self.items() if k not in self._handled_attrs)
396class _HTMLAttrsParser(HTMLParser):
397 def __init__(self):
398 super().__init__()
399 self.attrs = {}
401 def parse(self, html: str) -> _AutorefsAttrs:
402 self.reset()
403 self.attrs.clear()
404 self.feed(html)
405 return _AutorefsAttrs(self.attrs)
407 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: # noqa: ARG002
408 self.attrs.update(attrs)
411class _HTMLTagStripper(HTMLParser):
412 def __init__(self) -> None:
413 super().__init__()
414 self.text = StringIO()
416 def strip(self, html: str) -> str:
417 self.reset()
418 self.text = StringIO()
419 self.feed(html)
420 return self.text.getvalue()
422 def handle_data(self, data: str) -> None:
423 self.text.write(data)
426def relative_url(url_a: str, url_b: str) -> str:
427 """Compute the relative path from URL A to URL B.
429 Arguments:
430 url_a: URL A.
431 url_b: URL B.
433 Returns:
434 The relative URL to go from A to B.
435 """
436 parts_a = url_a.split("/")
437 url_b, anchor = url_b.split("#", 1)
438 parts_b = url_b.split("/")
440 # Remove common left parts.
441 while parts_a and parts_b and parts_a[0] == parts_b[0]:
442 parts_a.pop(0)
443 parts_b.pop(0)
445 # Go up as many times as remaining a parts' depth.
446 levels = len(parts_a) - 1
447 parts_relative = [".."] * levels + parts_b
448 relative = "/".join(parts_relative)
449 return f"{relative}#{anchor}"
452def fix_ref(
453 url_mapper: Callable[[str], tuple[str, str | None]],
454 unmapped: list[tuple[str, AutorefsHookInterface.Context | None]],
455 record_backlink: Callable[[str, str, str], None] | None = None,
456 *,
457 link_titles: bool | Literal["external"] = True,
458 strip_title_tags: bool = False,
459) -> Callable:
460 """Return a `repl` function for [`re.sub`](https://docs.python.org/3/library/re.html#re.sub).
462 In our context, we match Markdown references and replace them with HTML links.
464 When the matched reference's identifier was not mapped to an URL, we append the identifier to the outer
465 `unmapped` list. It generally means the user is trying to cross-reference an object that was not collected
466 and rendered, making it impossible to link to it. We catch this exception in the caller to issue a warning.
468 Arguments:
469 url_mapper: A callable that gets an object's site URL by its identifier,
470 such as [mkdocs_autorefs.AutorefsPlugin.get_item_url][].
471 unmapped: A list to store unmapped identifiers.
472 record_backlink: A callable to record backlinks.
473 link_titles: How to set HTML titles on links. Always (`True`), never (`False`), or external-only (`"external"`).
474 strip_title_tags: Whether to strip HTML tags from link titles.
476 Returns:
477 The actual function accepting a [`Match` object](https://docs.python.org/3/library/re.html#match-objects)
478 and returning the replacement strings.
479 """
481 def inner(match: Match) -> str:
482 title = match["title"]
483 attrs = _html_attrs_parser.parse(f"<a {match['attrs']}>")
484 identifier: str = attrs["identifier"]
485 slug = attrs.get("slug", None)
486 optional = "optional" in attrs
488 identifiers = (identifier, slug) if slug else (identifier,)
490 if ( 490 ↛ 495line 490 didn't jump to line 495 because the condition on line 490 was never true
491 record_backlink
492 and (backlink_type := attrs.get("backlink-type"))
493 and (backlink_anchor := attrs.get("backlink-anchor"))
494 ):
495 record_backlink(identifier, backlink_type, backlink_anchor)
497 try:
498 url, original_title = _find_url(identifiers, url_mapper)
499 except KeyError:
500 if optional:
501 _log.debug("Unresolved optional cross-reference: %s", identifier)
502 return f'<span title="{identifier}">{title}</span>'
503 unmapped.append((identifier, attrs.context))
504 if title == identifier:
505 return f"[{identifier}][]"
506 if title == f"<code>{identifier}</code>" and not slug:
507 return f"[<code>{identifier}</code>][]"
508 return f"[{title}][{identifier}]"
510 parsed = urlsplit(url)
511 external = parsed.scheme or parsed.netloc
513 classes = (attrs.get("class") or "").strip().split()
514 classes = ["autorefs", "autorefs-external" if external else "autorefs-internal", *classes]
515 class_attr = " ".join(classes)
517 if remaining := attrs.remaining:
518 remaining = f" {remaining}"
520 title_attr = ""
521 if link_titles is True or (link_titles == "external" and external): 521 ↛ 534line 521 didn't jump to line 534 because the condition on line 521 was always true
522 if optional:
523 # The `optional` attribute is generally only added by mkdocstrings handlers,
524 # for API objects, meaning we can and should append the full identifier.
525 tooltip = _tooltip(identifier, original_title, strip_tags=strip_title_tags)
526 else:
527 # Autorefs without `optional` are generally user-written ones,
528 # so we should only use the original title.
529 tooltip = original_title or ""
531 if tooltip and tooltip not in f"<code>{title}</code>":
532 title_attr = f' title="{_html_tag_stripper.strip(tooltip) if strip_title_tags else escape(tooltip)}"'
534 return f'<a class="{class_attr}"{title_attr} href="{escape(url)}"{remaining}>{title}</a>'
536 return inner
539def fix_refs(
540 html: str,
541 url_mapper: Callable[[str], tuple[str, str | None]],
542 *,
543 record_backlink: Callable[[str, str, str], None] | None = None,
544 link_titles: bool | Literal["external"] = True,
545 strip_title_tags: bool = False,
546 # YORE: Bump 2: Remove line.
547 _legacy_refs: bool = True,
548) -> tuple[str, list[tuple[str, AutorefsHookInterface.Context | None]]]:
549 """Fix all references in the given HTML text.
551 Arguments:
552 html: The text to fix.
553 url_mapper: A callable that gets an object's site URL by its identifier,
554 such as [mkdocs_autorefs.AutorefsPlugin.get_item_url][].
555 record_backlink: A callable to record backlinks.
556 link_titles: How to set HTML titles on links. Always (`True`), never (`False`), or external-only (`"external"`).
557 strip_title_tags: Whether to strip HTML tags from link titles.
559 Returns:
560 The fixed HTML, and a list of unmapped identifiers (string and optional context).
561 """
562 unmapped: list[tuple[str, AutorefsHookInterface.Context | None]] = []
563 html = AUTOREF_RE.sub(
564 fix_ref(url_mapper, unmapped, record_backlink, link_titles=link_titles, strip_title_tags=strip_title_tags),
565 html,
566 )
568 # YORE: Bump 2: Remove block.
569 if _legacy_refs:
570 html = AUTO_REF_RE.sub(_legacy_fix_ref(url_mapper, unmapped), html)
572 return html, unmapped
575_html_attrs_parser = _HTMLAttrsParser()
576_html_tag_stripper = _HTMLTagStripper()
579def _find_url(
580 identifiers: Iterable[str],
581 url_mapper: Callable[[str], tuple[str, str | None]],
582) -> tuple[str, str | None]:
583 for identifier in identifiers:
584 try:
585 return url_mapper(identifier)
586 except KeyError:
587 pass
588 raise KeyError(f"None of the identifiers {identifiers} were found")
591def _tooltip(identifier: str, title: str | None, *, strip_tags: bool = False) -> str:
592 if title:
593 # Don't append identifier if it's already in the title.
594 if identifier in title: 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true
595 return title
596 # Append identifier (useful for API objects).
597 if strip_tags: 597 ↛ 599line 597 didn't jump to line 599 because the condition on line 597 was always true
598 return f"{title} ({identifier})"
599 return f"{title} (<code>{identifier}</code>)"
600 # No title, just return the identifier.
601 if strip_tags: 601 ↛ 603line 601 didn't jump to line 603 because the condition on line 601 was always true
602 return identifier
603 return f"<code>{identifier}</code>"
606@lru_cache
607def _log_enabling_markdown_anchors() -> None:
608 _log.debug("Enabling Markdown anchors feature")
611@lru_cache
612def _log_enabling_backlinks() -> None:
613 _log.debug("Enabling backlinks feature")
616# YORE: Bump 2: Remove block.
617_ATTR_VALUE = r'"[^"<>]+"|[^"<> ]+' # Possibly with double quotes around
618AUTO_REF_RE = re.compile(
619 rf"<span data-(?P<kind>autorefs-(?:identifier|optional|optional-hover))=(?P<identifier>{_ATTR_VALUE})"
620 rf"(?: class=(?P<class>{_ATTR_VALUE}))?(?P<attrs> [^<>]+)?>(?P<title>.*?)</span>",
621 flags=re.DOTALL,
622)
623"""Deprecated. Use [`AUTOREF_RE`][mkdocs_autorefs.AUTOREF_RE] instead."""
626# YORE: Bump 2: Remove block.
627def __getattr__(name: str) -> Any:
628 if name == "AutoRefInlineProcessor": 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true
629 warnings.warn("AutoRefInlineProcessor was renamed AutorefsInlineProcessor", DeprecationWarning, stacklevel=2)
630 return AutorefsInlineProcessor
631 raise AttributeError(f"module 'mkdocs_autorefs.references' has no attribute {name}")
634# YORE: Bump 2: Remove block.
635def _legacy_fix_ref(
636 url_mapper: Callable[[str], tuple[str, str | None]],
637 unmapped: list[tuple[str, AutorefsHookInterface.Context | None]],
638) -> Callable:
639 """Return a `repl` function for [`re.sub`](https://docs.python.org/3/library/re.html#re.sub).
641 In our context, we match Markdown references and replace them with HTML links.
643 When the matched reference's identifier was not mapped to an URL, we append the identifier to the outer
644 `unmapped` list. It generally means the user is trying to cross-reference an object that was not collected
645 and rendered, making it impossible to link to it. We catch this exception in the caller to issue a warning.
647 Arguments:
648 url_mapper: A callable that gets an object's site URL by its identifier,
649 such as [mkdocs_autorefs.AutorefsPlugin.get_item_url][].
650 unmapped: A list to store unmapped identifiers.
652 Returns:
653 The actual function accepting a [`Match` object](https://docs.python.org/3/library/re.html#match-objects)
654 and returning the replacement strings.
655 """
657 def inner(match: Match) -> str:
658 identifier = match["identifier"].strip('"')
659 title = match["title"]
660 kind = match["kind"]
661 attrs = match["attrs"] or ""
662 classes = (match["class"] or "").strip('"').split()
664 try:
665 url, _ = url_mapper(unescape(identifier))
666 except KeyError:
667 if kind == "autorefs-optional":
668 return title
669 if kind == "autorefs-optional-hover":
670 return f'<span title="{identifier}">{title}</span>'
671 unmapped.append((identifier, None))
672 if title == identifier: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true
673 return f"[{identifier}][]"
674 return f"[{title}][{identifier}]"
676 warnings.warn(
677 "autorefs `span` elements are deprecated in favor of `autoref` elements: "
678 f'`<span data-autorefs-identifier="{identifier}">...</span>` becomes `<autoref identifer="{identifier}">...</autoref>`',
679 DeprecationWarning,
680 stacklevel=1,
681 )
682 parsed = urlsplit(url)
683 external = parsed.scheme or parsed.netloc
684 classes = ["autorefs", "autorefs-external" if external else "autorefs-internal", *classes]
685 class_attr = " ".join(classes)
686 if kind == "autorefs-optional-hover":
687 return f'<a class="{class_attr}" title="{identifier}" href="{escape(url)}"{attrs}>{title}</a>'
688 return f'<a class="{class_attr}" href="{escape(url)}"{attrs}>{title}</a>'
690 return inner