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

1# Cross-references module. 

2 

3from __future__ import annotations 

4 

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 

17 

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 

25 

26from mkdocs_autorefs._internal.backlinks import BacklinksTreeProcessor 

27 

28if TYPE_CHECKING: 

29 from collections.abc import Iterable 

30 from pathlib import Path 

31 from re import Match 

32 

33 from markdown import Markdown 

34 

35 from mkdocs_autorefs._internal.plugin import AutorefsPlugin 

36 

37try: 

38 from mkdocs.plugins import get_plugin_logger 

39 

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] 

44 

45 

46AUTOREF_RE = re.compile(r"<autoref (?P<attrs>.*?)>(?P<title>.*?)</autoref>", flags=re.DOTALL) 

47"""The autoref HTML tag regular expression. 

48 

49A regular expression to match mkdocs-autorefs' special reference markers 

50in the [`on_env` hook][mkdocs_autorefs.AutorefsPlugin.on_env]. 

51""" 

52 

53 

54class AutorefsHookInterface(ABC): 

55 """An interface for hooking into how AutoRef handles inline references.""" 

56 

57 @dataclass 

58 class Context: 

59 """The context around an auto-reference.""" 

60 

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.""" 

71 

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 } 

81 

82 @abstractmethod 

83 def expand_identifier(self, identifier: str) -> str: 

84 """Expand an identifier in a given context. 

85 

86 Parameters: 

87 identifier: The identifier to expand. 

88 

89 Returns: 

90 The expanded identifier. 

91 """ 

92 raise NotImplementedError 

93 

94 @abstractmethod 

95 def get_context(self) -> AutorefsHookInterface.Context: 

96 """Get the current context. 

97 

98 Returns: 

99 The current context. 

100 """ 

101 raise NotImplementedError 

102 

103 

104class AutorefsInlineProcessor(ReferenceInlineProcessor): 

105 """A Markdown extension to handle inline references.""" 

106 

107 name: str = "mkdocs-autorefs" 

108 """The name of the inline processor.""" 

109 

110 hook: AutorefsHookInterface | None = None 

111 """The hook to use for expanding identifiers or adding context to autorefs.""" 

112 

113 def __init__(self, *args: Any, **kwargs: Any) -> None: 

114 super().__init__(REFERENCE_RE, *args, **kwargs) 

115 

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. 

120 

121 Arguments: 

122 m: The match object. 

123 data: The matched data. 

124 

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 

131 

132 identifier, slug, end, handled = self._eval_id(data, index, text) 

133 if not handled or identifier is None: 

134 return None, None, None 

135 

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 

140 

141 return self._make_tag(identifier, text, slug=slug), m.start(0), end 

142 

143 def _unstash(self, identifier: str) -> str: 

144 stashed_nodes: dict[str, Element | str] = self.md.treeprocessors["inline"].stashed_nodes # type: ignore[attr-defined] 

145 

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) 

153 

154 return INLINE_PLACEHOLDER_RE.sub(_repl, identifier) 

155 

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]`. 

158 

159 If `[ref][]` use `[ref]`. 

160 

161 Arguments: 

162 data: The data to evaluate. 

163 index: The starting position. 

164 text: The text to use when no identifier. 

165 

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 

172 

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 

179 

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) 

195 

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="-") 

201 

202 end = m.end(0) 

203 return identifier, slug, end, True 

204 

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`. 

207 

208 Arguments: 

209 identifier: The identifier to use in the HTML property. 

210 text: The text to use in the HTML tag. 

211 

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 

224 

225 

226class AnchorScannerTreeProcessor(Treeprocessor): 

227 """Tree processor to scan and register HTML anchors.""" 

228 

229 name: str = "mkdocs-autorefs-anchors-scanner" 

230 """The name of the tree processor.""" 

231 

232 _htags: ClassVar[set[str]] = {"h1", "h2", "h3", "h4", "h5", "h6"} 

233 

234 def __init__(self, plugin: AutorefsPlugin, md: Markdown | None = None) -> None: 

235 """Initialize the tree processor. 

236 

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 

242 

243 def run(self, root: Element) -> None: 

244 """Run the tree processor. 

245 

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() 

253 

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) 

264 

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() 

272 

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) 

277 

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) 

283 

284 

285class AutorefsExtension(Extension): 

286 """Markdown extension that transforms unresolved references into auto-references. 

287 

288 Auto-references are then resolved later by the MkDocs plugin. 

289 

290 This extension also scans Markdown anchors (`[](){#some-id}`) 

291 to register them with the MkDocs plugin. 

292 """ 

293 

294 def __init__( 

295 self, 

296 plugin: AutorefsPlugin | None = None, 

297 **kwargs: Any, 

298 ) -> None: 

299 """Initialize the Markdown extension. 

300 

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.""" 

308 

309 def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name) 

310 """Register the extension. 

311 

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. 

316 

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 ) 

343 

344 

345class _PendingAnchors: 

346 """A collection of HTML anchors that may or may not become aliased to an upcoming heading.""" 

347 

348 def __init__(self, plugin: AutorefsPlugin): 

349 self.plugin = plugin 

350 self.anchors: list[str] = [] 

351 

352 def append(self, anchor: str) -> None: 

353 self.anchors.append(anchor) 

354 

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() 

360 

361 

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 } 

377 

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 

390 

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) 

394 

395 

396class _HTMLAttrsParser(HTMLParser): 

397 def __init__(self): 

398 super().__init__() 

399 self.attrs = {} 

400 

401 def parse(self, html: str) -> _AutorefsAttrs: 

402 self.reset() 

403 self.attrs.clear() 

404 self.feed(html) 

405 return _AutorefsAttrs(self.attrs) 

406 

407 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: # noqa: ARG002 

408 self.attrs.update(attrs) 

409 

410 

411class _HTMLTagStripper(HTMLParser): 

412 def __init__(self) -> None: 

413 super().__init__() 

414 self.text = StringIO() 

415 

416 def strip(self, html: str) -> str: 

417 self.reset() 

418 self.text = StringIO() 

419 self.feed(html) 

420 return self.text.getvalue() 

421 

422 def handle_data(self, data: str) -> None: 

423 self.text.write(data) 

424 

425 

426def relative_url(url_a: str, url_b: str) -> str: 

427 """Compute the relative path from URL A to URL B. 

428 

429 Arguments: 

430 url_a: URL A. 

431 url_b: URL B. 

432 

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("/") 

439 

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) 

444 

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}" 

450 

451 

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). 

461 

462 In our context, we match Markdown references and replace them with HTML links. 

463 

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. 

467 

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. 

475 

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 """ 

480 

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 

487 

488 identifiers = (identifier, slug) if slug else (identifier,) 

489 

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) 

496 

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}]" 

509 

510 parsed = urlsplit(url) 

511 external = parsed.scheme or parsed.netloc 

512 

513 classes = (attrs.get("class") or "").strip().split() 

514 classes = ["autorefs", "autorefs-external" if external else "autorefs-internal", *classes] 

515 class_attr = " ".join(classes) 

516 

517 if remaining := attrs.remaining: 

518 remaining = f" {remaining}" 

519 

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 "" 

530 

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)}"' 

533 

534 return f'<a class="{class_attr}"{title_attr} href="{escape(url)}"{remaining}>{title}</a>' 

535 

536 return inner 

537 

538 

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. 

550 

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. 

558 

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 ) 

567 

568 # YORE: Bump 2: Remove block. 

569 if _legacy_refs: 

570 html = AUTO_REF_RE.sub(_legacy_fix_ref(url_mapper, unmapped), html) 

571 

572 return html, unmapped 

573 

574 

575_html_attrs_parser = _HTMLAttrsParser() 

576_html_tag_stripper = _HTMLTagStripper() 

577 

578 

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") 

589 

590 

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>" 

604 

605 

606@lru_cache 

607def _log_enabling_markdown_anchors() -> None: 

608 _log.debug("Enabling Markdown anchors feature") 

609 

610 

611@lru_cache 

612def _log_enabling_backlinks() -> None: 

613 _log.debug("Enabling backlinks feature") 

614 

615 

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.""" 

624 

625 

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}") 

632 

633 

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). 

640 

641 In our context, we match Markdown references and replace them with HTML links. 

642 

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. 

646 

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. 

651 

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 """ 

656 

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() 

663 

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}]" 

675 

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>' 

689 

690 return inner