Skip to content

Commit 2916eb2

Browse files
committed
feat: Add option to resolve autorefs to closest URLs when multiple ones are found
Issue-52: #52
1 parent a927bab commit 2916eb2

File tree

3 files changed

+141
-3
lines changed

3 files changed

+141
-3
lines changed

README.md

+42-2
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,38 @@ We can [link to that heading][hello-world] from another page too.
4747
This works the same as [a normal link to that heading](../doc1.md#hello-world).
4848
```
4949

50-
Linking to a heading without needing to know the destination page can be useful if specifying that path is cumbersome, e.g. when the pages have deeply nested paths, are far apart, or are moved around frequently. And the issue is somewhat exacerbated by the fact that [MkDocs supports only *relative* links between pages](https://github.com/mkdocs/mkdocs/issues/1592).
50+
Linking to a heading without needing to know the destination page can be useful if specifying that path is cumbersome, e.g. when the pages have deeply nested paths, are far apart, or are moved around frequently.
5151

52-
Note that this plugin's behavior is undefined when trying to link to a heading title that appears several times throughout the site. Currently it arbitrarily chooses one of the pages. In such cases, use [Markdown anchors](#markdown-anchors) to add unique aliases to your headings.
52+
### Non-unique headings
53+
54+
When linking to a heading that appears several times throughout the site, this plugin will log a warning message stating that multiple URLs were found and that headings should be made unique, and will resolve the link using the first found URL.
55+
56+
To prevent getting warnings, use [Markdown anchors](#markdown-anchors) to add unique aliases to your headings, and use these aliases when referencing the headings.
57+
58+
If you cannot use Markdown anchors, for example because you inject the same generated contents in multiple locations (for example mkdocstrings' API documentation), then you can try to alleviate the warnings by enabling the `resolve_closest` option:
59+
60+
```yaml
61+
plugins:
62+
- autorefs:
63+
resolve_closest: true
64+
```
65+
66+
When `resolve_closest` is enabled, and multiple URLs are found for the same identifier, the plugin will try to resolve to the one that is "closest" to the current page (the page containing the link). By closest, we mean:
67+
68+
- URLs that are relative to the current page's URL, climbing up parents
69+
- if multiple URLs are relative to it, use the one at the shortest distance if possible.
70+
71+
If multiple relative URLs are at the same distance, the first of these URLs will be used. If no URL is relative to the current page's URL, the first URL of all found URLs will be used.
72+
73+
Examples:
74+
75+
Current page | Candidate URLs | Relative URLs | Winner
76+
------------ | -------------- | ------------- | ------
77+
` ` | `x/#b`, `#b` | `#b` | `#b` (only one relative)
78+
`a/` | `b/c/#d`, `c/#d` | none | `b/c/#d` (no relative, use first one, even if longer distance)
79+
`a/b/` | `x/#e`, `a/c/#e`, `a/d/#e` | `a/c/#e`, `a/d/#e` (relative to parent `a/`) | `a/c/#e` (same distance, use first one)
80+
`a/b/` | `x/#e`, `a/c/d/#e`, `a/c/#e` | `a/c/d/#e`, `a/c/#e` (relative to parent `a/`) | `a/c/#e` (shortest distance)
81+
`a/b/c/` | `x/#e`, `a/#e`, `a/b/#e`, `a/b/c/d/#e`, `a/b/c/#e` | `a/b/c/d/#e`, `a/b/c/#e` | `a/b/c/#e` (shortest distance)
5382

5483
### Markdown anchors
5584

@@ -143,3 +172,14 @@ You don't want to change headings and make them redundant, like `## Arch: Instal
143172
```
144173

145174
...changing `arch` by `debian`, `gentoo`, etc. in the other pages.
175+
176+
---
177+
178+
You can also change the actual identifier of a heading, thanks again to the `attr_list` Markdown extension:
179+
180+
```md
181+
## Install from sources { #arch-install-src }
182+
...
183+
```
184+
185+
...though note that this will impact the URL anchor too (and therefore the permalink to the heading).

src/mkdocs_autorefs/plugin.py

+75-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
import contextlib
1616
import functools
1717
import logging
18+
import sys
1819
from typing import TYPE_CHECKING, Any, Callable, Sequence
1920
from urllib.parse import urlsplit
2021

22+
from mkdocs.config.base import Config
23+
from mkdocs.config.config_options import Type
2124
from mkdocs.plugins import BasePlugin
2225
from mkdocs.structure.pages import Page
2326

@@ -37,6 +40,41 @@
3740
log = logging.getLogger(f"mkdocs.plugins.{__name__}") # type: ignore[assignment]
3841

3942

43+
# YORE: EOL 3.8: Remove block.
44+
if sys.version_info < (3, 9):
45+
from pathlib import PurePosixPath
46+
47+
class URL(PurePosixPath): # noqa: D101
48+
def is_relative_to(self, *args: Any) -> bool: # noqa: D102
49+
try:
50+
self.relative_to(*args)
51+
except ValueError:
52+
return False
53+
return True
54+
else:
55+
from pathlib import PurePosixPath as URL # noqa: N814
56+
57+
58+
class AutorefsConfig(Config):
59+
"""Configuration options for the `autorefs` plugin."""
60+
61+
resolve_closest = Type(bool, default=False)
62+
"""Whether to resolve an autoref to the closest URL when multiple URLs are found for an identifier.
63+
64+
By closest, we mean a combination of "relative to the current page" and "shortest distance from the current page".
65+
66+
For example, if you link to identifier `hello` from page `foo/bar/`,
67+
and the identifier is found in `foo/`, `foo/baz/` and `foo/bar/baz/qux/` pages,
68+
autorefs will resolve to `foo/bar/baz/qux`, which is the only URL relative to `foo/bar/`.
69+
70+
If multiple URLs are equally close, autorefs will resolve to the first of these equally close URLs.
71+
If autorefs cannot find any URL that is close to the current page, it will log a warning and resolve to the first URL found.
72+
73+
When false and multiple URLs are found for an identifier, autorefs will log a warning and resolve to the first URL.
74+
"""
75+
76+
77+
class AutorefsPlugin(BasePlugin[AutorefsConfig]):
4078
"""The `autorefs` plugin for `mkdocs`.
4179
4280
This plugin defines the following event hooks:
@@ -83,10 +121,44 @@ def register_url(self, identifier: str, url: str) -> None:
83121
"""
84122
self._abs_url_map[identifier] = url
85123

124+
@staticmethod
125+
def _get_closest_url(from_url: str, urls: list[str]) -> str:
126+
"""Return the closest URL to the current page.
127+
128+
Arguments:
129+
from_url: The URL of the base page, from which we link towards the targeted pages.
130+
urls: A list of URLs to choose from.
131+
132+
Returns:
133+
The closest URL to the current page.
134+
"""
135+
base_url = URL(from_url)
136+
137+
while True:
138+
if candidates := [url for url in urls if URL(url).is_relative_to(base_url)]:
139+
break
140+
base_url = base_url.parent
141+
if not base_url.name:
142+
break
143+
144+
if not candidates:
145+
log.warning(
146+
"Could not find closest URL (from %s, candidates: %s). "
147+
"Make sure to use unique headings, identifiers, or Markdown anchors (see our docs).",
148+
from_url,
149+
urls,
150+
)
151+
return urls[0]
152+
153+
winner = candidates[0] if len(candidates) == 1 else min(candidates, key=lambda c: c.count("/"))
154+
log.debug("Closest URL found: %s (from %s, candidates: %s)", winner, from_url, urls)
155+
return winner
156+
86157
def _get_item_url(
87158
self,
88159
identifier: str,
89160
fallback: Callable[[str], Sequence[str]] | None = None,
161+
from_url: str | None = None,
90162
) -> str:
91163
try:
92164
urls = self._url_map[identifier]
@@ -103,6 +175,8 @@ def _get_item_url(
103175
raise
104176

105177
if len(urls) > 1:
178+
if self.config.resolve_closest and from_url is not None:
179+
return self._get_closest_url(from_url, urls)
106180
log.warning(
107181
"Multiple URLs found for '%s': %s. "
108182
"Make sure to use unique headings, identifiers, or Markdown anchors (see our docs).",
@@ -127,7 +201,7 @@ def get_item_url(
127201
Returns:
128202
A site-relative URL.
129203
"""
130-
url = self._get_item_url(identifier, fallback)
204+
url = self._get_item_url(identifier, fallback, from_url)
131205
if from_url is not None:
132206
parsed = urlsplit(url)
133207
if not parsed.scheme and not parsed.netloc:

tests/test_plugin.py

+24
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,27 @@ def test_dont_make_relative_urls_relative_again() -> None:
6060
plugin.get_item_url("hello", from_url="baz/bar/foo.html", fallback=lambda _: ("foo.bar.baz",))
6161
== "../../foo/bar/baz.html#foo.bar.baz"
6262
)
63+
64+
65+
@pytest.mark.parametrize(
66+
("base", "urls", "expected"),
67+
[
68+
# One URL is closest.
69+
("", ["x/#b", "#b"], "#b"),
70+
# Several URLs are equally close.
71+
("a/b", ["x/#e", "a/c/#e", "a/d/#e"], "a/c/#e"),
72+
("a/b/", ["x/#e", "a/d/#e", "a/c/#e"], "a/d/#e"),
73+
# Two close URLs, one is shorter (closer).
74+
("a/b", ["x/#e", "a/c/#e", "a/c/d/#e"], "a/c/#e"),
75+
("a/b/", ["x/#e", "a/c/d/#e", "a/c/#e"], "a/c/#e"),
76+
# Deeper-nested URLs.
77+
("a/b/c", ["x/#e", "a/#e", "a/b/#e", "a/b/c/#e", "a/b/c/d/#e"], "a/b/c/#e"),
78+
("a/b/c/", ["x/#e", "a/#e", "a/b/#e", "a/b/c/d/#e", "a/b/c/#e"], "a/b/c/#e"),
79+
# No closest URL, use first one even if longer distance.
80+
("a", ["b/c/#d", "c/#d"], "b/c/#d"),
81+
("a/", ["c/#d", "b/c/#d"], "c/#d"),
82+
],
83+
)
84+
def test_find_closest_url(base: str, urls: list[str], expected: str) -> None:
85+
"""Find closest URLs given a list of URLs."""
86+
assert AutorefsPlugin._get_closest_url(base, urls) == expected

0 commit comments

Comments
 (0)