Switch to unified view

a b/docs/scripts/autorefs/plugin.py
1
# ruff: noqa: E501
2
"""
3
# -----------
4
VENDORED https://github.com/mkdocstrings/autorefs/blob/e19b9fa47dac136a529c2be0d7969106ca5d5106/src/mkdocs_autorefs/
5
Waiting for the following PR to be merged: https://github.com/mkdocstrings/autorefs/pull/25
6
# -----------
7
8
This module contains the "mkdocs-autorefs" plugin.
9
10
After each page is processed by the Markdown converter, this plugin stores absolute URLs of every HTML anchors
11
it finds to later be able to fix unresolved references.
12
It stores them during the [`on_page_content` event hook](https://www.mkdocs.org/user-guide/plugins/#on_page_content).
13
14
Just before writing the final HTML to the disc, during the
15
[`on_post_page` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_page),
16
this plugin searches for references of the form `[identifier][]` or `[title][identifier]` that were not resolved,
17
and fixes them using the previously stored identifier-URL mapping.
18
"""
19
import contextlib
20
import functools
21
import logging
22
import os
23
import re
24
from html import escape, unescape
25
from typing import Any, Callable, Dict, List, Match, Optional, Sequence, Tuple, Union
26
from urllib.parse import urlsplit
27
from xml.etree.ElementTree import Element
28
29
from markdown import Markdown
30
from markdown.extensions import Extension
31
from markdown.inlinepatterns import REFERENCE_RE, ReferenceInlineProcessor
32
from markdown.util import INLINE_PLACEHOLDER_RE
33
from mkdocs.config import Config
34
from mkdocs.config import config_options as c
35
from mkdocs.plugins import BasePlugin
36
from mkdocs.structure.pages import Page
37
from mkdocs.structure.toc import AnchorLink
38
from mkdocs.utils import warning_filter
39
40
AUTO_REF_RE = re.compile(
41
    r"<span data-(?P<kind>autorefs-identifier|autorefs-optional|autorefs-optional-hover)="
42
    r'("?)(?P<identifier>[^"<>]*)\2>(?P<title>.*?)</span>'
43
)
44
"""A regular expression to match mkdocs-autorefs' special reference markers
45
in the [`on_post_page` hook][mkdocs_autorefs.plugin.AutorefsPlugin.on_post_page].
46
"""
47
48
EvalIDType = Tuple[Any, Any, Any]
49
50
51
class AutoRefInlineProcessor(ReferenceInlineProcessor):
52
    """A Markdown extension."""
53
54
    def __init__(self, *args, **kwargs):  # noqa: D107
55
        super().__init__(REFERENCE_RE, *args, **kwargs)
56
57
    # Code based on
58
    # https://github.com/Python-Markdown/markdown/blob/8e7528fa5c98bf4652deb13206d6e6241d61630b/markdown/inlinepatterns.py#L780
59
60
    def handleMatch(self, m, data) -> Union[Element, EvalIDType]:  # type: ignore[override]  # noqa: N802,WPS111
61
        """Handle an element that matched.
62
63
        Arguments:
64
            m: The match object.
65
            data: The matched data.
66
67
        Returns:
68
            A new element or a tuple.
69
        """
70
        text, index, handled = self.getText(data, m.end(0))
71
        if not handled:
72
            return None, None, None
73
74
        identifier, end, handled = self.evalId(data, index, text)
75
        if not handled:
76
            return None, None, None
77
78
        if re.search(r"[/ \x00-\x1f]", identifier):
79
            # Do nothing if the matched reference contains:
80
            # - a space, slash or control character (considered unintended);
81
            # - specifically \x01 is used by Python-Markdown HTML stash when there's inline formatting,
82
            #   but references with Markdown formatting are not possible anyway.
83
            return None, m.start(0), end
84
85
        return self.makeTag(identifier, text), m.start(0), end
86
87
    def evalId(
88
        self, data: str, index: int, text: str
89
    ) -> EvalIDType:  # noqa: N802 (parent's casing)
90
        """Evaluate the id portion of `[ref][id]`.
91
92
        If `[ref][]` use `[ref]`.
93
94
        Arguments:
95
            data: The data to evaluate.
96
            index: The starting position.
97
            text: The text to use when no identifier.
98
99
        Returns:
100
            A tuple containing the identifier, its end position, and whether it matched.
101
        """
102
        m = self.RE_LINK.match(data, pos=index)  # noqa: WPS111
103
        if not m:
104
            return None, index, False
105
106
        identifier = m.group(1)
107
        if not identifier:
108
            identifier = text
109
            # Allow the entire content to be one placeholder, with the intent of catching things like [`Foo`][].
110
            # It doesn't catch [*Foo*][] though, just due to the priority order.
111
            # https://github.com/Python-Markdown/markdown/blob/1858c1b601ead62ed49646ae0d99298f41b1a271/markdown/inlinepatterns.py#L78
112
            if INLINE_PLACEHOLDER_RE.fullmatch(identifier):
113
                identifier = self.unescape(identifier)
114
115
        end = m.end(0)
116
        return identifier, end, True
117
118
    def makeTag(self, identifier: str, text: str) -> Element:  # type: ignore[override]  # noqa: N802,W0221
119
        """Create a tag that can be matched by `AUTO_REF_RE`.
120
121
        Arguments:
122
            identifier: The identifier to use in the HTML property.
123
            text: The text to use in the HTML tag.
124
125
        Returns:
126
            A new element.
127
        """
128
        el = Element("span")
129
        el.set("data-autorefs-identifier", identifier)
130
        el.text = text
131
        return el
132
133
134
def relative_url(url_a: str, url_b: str) -> str:
135
    """Compute the relative path from URL A to URL B.
136
137
    Arguments:
138
        url_a: URL A.
139
        url_b: URL B.
140
141
    Returns:
142
        The relative URL to go from A to B.
143
    """
144
    parts_a = url_a.split("/")
145
    url_b, anchor = url_b.split("#", 1)
146
    parts_b = url_b.split("/")
147
148
    # remove common left parts
149
    while parts_a and parts_b and parts_a[0] == parts_b[0]:
150
        parts_a.pop(0)
151
        parts_b.pop(0)
152
153
    # go up as many times as remaining a parts' depth
154
    levels = len(parts_a) - 1
155
    parts_relative = [".."] * levels + parts_b  # noqa: WPS435
156
    relative = "/".join(parts_relative)
157
    return f"{relative}#{anchor}"
158
159
160
def fix_ref(
161
    url_mapper: Callable[[str], str], unmapped: List[str]
162
) -> Callable:  # noqa: WPS212,WPS231
163
    """Return a `repl` function for [`re.sub`](https://docs.python.org/3/library/re.html#re.sub).
164
165
    In our context, we match Markdown references and replace them with HTML links.
166
167
    When the matched reference's identifier was not mapped to an URL, we append the identifier to the outer
168
    `unmapped` list. It generally means the user is trying to cross-reference an object that was not collected
169
    and rendered, making it impossible to link to it. We catch this exception in the caller to issue a warning.
170
171
    Arguments:
172
        url_mapper: A callable that gets an object's site URL by its identifier,
173
            such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
174
        unmapped: A list to store unmapped identifiers.
175
176
    Returns:
177
        The actual function accepting a [`Match` object](https://docs.python.org/3/library/re.html#match-objects)
178
        and returning the replacement strings.
179
    """
180
181
    def inner(match: Match):  # noqa: WPS212,WPS430
182
        identifier = match["identifier"]
183
        title = match["title"]
184
        kind = match["kind"]
185
186
        try:
187
            url = url_mapper(unescape(identifier))
188
        except KeyError:
189
            if kind == "autorefs-optional":
190
                return title
191
            elif kind == "autorefs-optional-hover":
192
                return f'<span title="{identifier}">{title}</span>'
193
            unmapped.append(identifier)
194
            if title == identifier:
195
                return f"[{identifier}][]"
196
            return f"[{title}][{identifier}]"
197
198
        parsed = urlsplit(url)
199
        external = parsed.scheme or parsed.netloc
200
        classes = ["autorefs", "autorefs-external" if external else "autorefs-internal"]
201
        class_attr = " ".join(classes)
202
        if kind == "autorefs-optional-hover":
203
            return f'<a class="{class_attr}" title="{identifier}" href="{escape(url)}">{title}</a>'
204
        return f'<a class="{class_attr}" href="{escape(url)}">{title}</a>'
205
206
    return inner
207
208
209
def fix_refs(html: str, url_mapper: Callable[[str], str]) -> Tuple[str, List[str]]:
210
    """Fix all references in the given HTML text.
211
212
    Arguments:
213
        html: The text to fix.
214
        url_mapper: A callable that gets an object's site URL by its identifier,
215
            such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
216
217
    Returns:
218
        The fixed HTML.
219
    """
220
    unmapped = []  # type: ignore
221
    html = AUTO_REF_RE.sub(fix_ref(url_mapper, unmapped), html)
222
    return html, unmapped
223
224
225
class AutorefsExtension(Extension):
226
    """Extension that inserts auto-references in Markdown."""
227
228
    def extendMarkdown(
229
        self, md: Markdown
230
    ) -> None:  # noqa: N802 (casing: parent method's name)
231
        """Register the extension.
232
233
        Add an instance of our [`AutoRefInlineProcessor`][mkdocs_autorefs.references.AutoRefInlineProcessor] to the Markdown parser.
234
235
        Arguments:
236
            md: A `markdown.Markdown` instance.
237
        """
238
        md.inlinePatterns.register(
239
            AutoRefInlineProcessor(md),
240
            "mkdocs-autorefs",
241
            priority=168,  # noqa: WPS432  # Right after markdown.inlinepatterns.ReferenceInlineProcessor
242
        )
243
244
245
log = logging.getLogger(f"mkdocs.plugins.{__name__}")
246
log.addFilter(warning_filter)
247
248
249
class AutorefsPlugin(BasePlugin):
250
    """An `mkdocs` plugin.
251
252
    This plugin defines the following event hooks:
253
254
    - `on_config`
255
    - `on_page_content`
256
    - `on_post_page`
257
258
    Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs`
259
    for more information about its plugin system.
260
    """
261
262
    scan_toc: bool = True
263
    current_page: Optional[str] = None
264
    config_scheme = (("priority", c.ListOfItems(c.Type(str), default=[])),)
265
266
    def __init__(self) -> None:
267
        """Initialize the object."""
268
        super().__init__()
269
        self._url_map: Dict[str, str] = {}
270
        self._abs_url_map: Dict[str, str] = {}
271
        self.get_fallback_anchor: Optional[
272
            Callable[[str], Optional[str]]
273
        ] = None  # noqa: WPS234
274
        self._priority_patterns = None
275
276
    @property
277
    def priority_patterns(self):
278
        if self._priority_patterns is None:
279
            self._priority_patterns = [
280
                os.path.join("/", pat) for pat in self.config.get("priority")
281
            ]
282
        return self._priority_patterns
283
284
    def register_anchor(self, url: str, identifier: str):
285
        """Register that an anchor corresponding to an identifier was encountered when rendering the page.
286
287
        Arguments:
288
            url: The relative URL of the current page. Examples: `'foo/bar/'`, `'foo/index.html'`
289
            identifier: The HTML anchor (without '#') as a string.
290
        """
291
292
        new_url = os.path.join("/", f"{url}#{identifier}")
293
        old_url = os.path.join("/", self._url_map.get(identifier, "")).split("#")[0]
294
295
        if identifier in self._url_map and not old_url == new_url:
296
            rev_patterns = list(enumerate(self.priority_patterns))[::-1]
297
            old_priority_idx = next(
298
                (i for i, pat in rev_patterns if re.match(pat, old_url)),
299
                len(rev_patterns),
300
            )
301
            new_priority_idx = next(
302
                (i for i, pat in rev_patterns if re.match(pat, new_url)),
303
                len(rev_patterns),
304
            )
305
            if new_priority_idx >= old_priority_idx:
306
                return
307
            if "reference" not in new_url:
308
                raise Exception("URL WTF", new_url)
309
310
        self._url_map[identifier] = new_url
311
312
    def register_url(self, identifier: str, url: str):
313
        """Register that the identifier should be turned into a link to this URL.
314
315
        Arguments:
316
            identifier: The new identifier.
317
            url: The absolute URL (including anchor, if needed) where this item can be found.
318
        """
319
        self._abs_url_map[identifier] = url
320
321
    def _get_item_url(  # noqa: WPS234
322
        self,
323
        identifier: str,
324
        fallback: Optional[Callable[[str], Sequence[str]]] = None,
325
    ) -> str:
326
        try:
327
            return self._url_map[identifier]
328
        except KeyError:
329
            if identifier in self._abs_url_map:
330
                return self._abs_url_map[identifier]
331
            if fallback:
332
                new_identifiers = fallback(identifier)
333
                for new_identifier in new_identifiers:
334
                    with contextlib.suppress(KeyError):
335
                        url = self._get_item_url(new_identifier)
336
                        self._url_map[identifier] = url
337
                        return url
338
            raise
339
340
    def get_item_url(  # noqa: WPS234
341
        self,
342
        identifier: str,
343
        from_url: Optional[str] = None,
344
        fallback: Optional[Callable[[str], Sequence[str]]] = None,
345
    ) -> str:
346
        """Return a site-relative URL with anchor to the identifier, if it's present anywhere.
347
348
        Arguments:
349
            identifier: The anchor (without '#').
350
            from_url: The URL of the base page, from which we link towards the targeted pages.
351
            fallback: An optional function to suggest alternative anchors to try on failure.
352
353
        Returns:
354
            A site-relative URL.
355
        """
356
        return self._get_item_url(identifier, fallback)
357
358
    def on_config(
359
        self, config: Config, **kwargs
360
    ) -> Config:  # noqa: W0613,R0201 (unused arguments, cannot be static)
361
        """Instantiate our Markdown extension.
362
363
        Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
364
        In this hook, we instantiate our [`AutorefsExtension`][mkdocs_autorefs.references.AutorefsExtension]
365
        and add it to the list of Markdown extensions used by `mkdocs`.
366
367
        Arguments:
368
            config: The MkDocs config object.
369
            kwargs: Additional arguments passed by MkDocs.
370
371
        Returns:
372
            The modified config.
373
        """
374
        log.debug(f"{__name__}: Adding AutorefsExtension to the list")
375
        config["markdown_extensions"].append(AutorefsExtension())
376
        return config
377
378
    def on_page_markdown(
379
        self, markdown: str, page: Page, **kwargs
380
    ) -> str:  # noqa: W0613 (unused arguments)
381
        """Remember which page is the current one.
382
383
        Arguments:
384
            markdown: Input Markdown.
385
            page: The related MkDocs page instance.
386
            kwargs: Additional arguments passed by MkDocs.
387
388
        Returns:
389
            The same Markdown. We only use this hook to map anchors to URLs.
390
        """
391
        self.current_page = page.url  # noqa: WPS601
392
        return markdown
393
394
    def on_page_content(
395
        self, html: str, page: Page, **kwargs
396
    ) -> str:  # noqa: W0613 (unused arguments)
397
        """Map anchors to URLs.
398
399
        Hook for the [`on_page_content` event](https://www.mkdocs.org/user-guide/plugins/#on_page_content).
400
        In this hook, we map the IDs of every anchor found in the table of contents to the anchors absolute URLs.
401
        This mapping will be used later to fix unresolved reference of the form `[title][identifier]` or
402
        `[identifier][]`.
403
404
        Arguments:
405
            html: HTML converted from Markdown.
406
            page: The related MkDocs page instance.
407
            kwargs: Additional arguments passed by MkDocs.
408
409
        Returns:
410
            The same HTML. We only use this hook to map anchors to URLs.
411
        """
412
        if self.scan_toc:
413
            log.debug(
414
                f"{__name__}: Mapping identifiers to URLs for page {page.file.src_path}"
415
            )
416
            for item in page.toc.items:
417
                self.map_urls(page, item)
418
        return html
419
420
    def map_urls(self, page: Page, anchor: AnchorLink) -> None:
421
        """Recurse on every anchor to map its ID to its absolute URL.
422
423
        This method populates `self.url_map` by side-effect.
424
425
        Arguments:
426
            base_url: The base URL to use as a prefix for each anchor's relative URL.
427
            anchor: The anchor to process and to recurse on.
428
        """
429
        abs_url = os.path.join("/", page.file.url)
430
        self.register_anchor(abs_url, anchor.id)
431
        for child in anchor.children:
432
            self.map_urls(page, child)
433
434
    def on_post_page(
435
        self, output: str, page: Page, **kwargs
436
    ) -> str:  # noqa: W0613 (unused arguments)
437
        """Fix cross-references.
438
439
        Hook for the [`on_post_page` event](https://www.mkdocs.org/user-guide/plugins/#on_post_page).
440
        In this hook, we try to fix unresolved references of the form `[title][identifier]` or `[identifier][]`.
441
        Doing that allows the user of `autorefs` to cross-reference objects in their documentation strings.
442
        It uses the native Markdown syntax so it's easy to remember and use.
443
444
        We log a warning for each reference that we couldn't map to an URL, but try to be smart and ignore identifiers
445
        that do not look legitimate (sometimes documentation can contain strings matching
446
        our [`AUTO_REF_RE`][mkdocs_autorefs.references.AUTO_REF_RE] regular expression that did not intend to reference anything).
447
        We currently ignore references when their identifier contains a space or a slash.
448
449
        Arguments:
450
            output: HTML converted from Markdown.
451
            page: The related MkDocs page instance.
452
            kwargs: Additional arguments passed by MkDocs.
453
454
        Returns:
455
            Modified HTML.
456
        """
457
        log.debug(f"{__name__}: Fixing references in page {page.file.src_path}")
458
459
        url_mapper = functools.partial(
460
            self.get_item_url, from_url=page.url, fallback=self.get_fallback_anchor
461
        )
462
        fixed_output, unmapped = fix_refs(output, url_mapper)
463
464
        if unmapped and log.isEnabledFor(logging.WARNING):
465
            for ref in unmapped:
466
                log.warning(
467
                    f"{__name__}: {page.file.src_path}: Could not find cross-reference target '[{ref}]'",
468
                )
469
470
        return fixed_output