diff --git a/.copier-answers.yml b/.copier-answers.yml index d37055b..e481816 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: 1.2.8 +_commit: 1.4.0 _src_path: gh:pawamoy/copier-uv author_email: dev@pawamoy.fr author_fullname: Timothée Mazzucotelli diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/1-bug.md similarity index 98% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/1-bug.md index d086173..83120f1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/1-bug.md @@ -53,7 +53,7 @@ PASTE TRACEBACK HERE python -m mkdocs_autorefs.debug # | xclip -selection clipboard ``` -PASTE OUTPUT HERE +PASTE MARKDOWN OUTPUT HERE ### Additional context + +### Relevant code snippets + + +### Link to the relevant documentation section + diff --git a/.github/ISSUE_TEMPLATE/4-change.md b/.github/ISSUE_TEMPLATE/4-change.md new file mode 100644 index 0000000..dc9a8f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-change.md @@ -0,0 +1,18 @@ +--- +name: Change request +about: Suggest any other kind of change for this project. +title: "change: " +assignees: pawamoy +--- + +### Is your change request related to a problem? Please describe. + + +### Describe the solution you'd like + + +### Describe alternatives you've considered + + +### Additional context + diff --git a/CHANGELOG.md b/CHANGELOG.md index c67f87b..18b10a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.1.0](https://github.com/mkdocstrings/autorefs/releases/tag/1.1.0) - 2024-08-20 + +[Compare with 1.0.1](https://github.com/mkdocstrings/autorefs/compare/1.0.1...1.1.0) + +### Deprecations + +- `AUTO_REF_RE` is renamed `AUTOREF_RE` (and updated for an improved version of `fix_refs`) +- `AutoRefInlineProcessor` is renamed `AutorefsInlineProcessor` + +### Features + +- Warn when multiple URLs are found for the same identifier ([c630354](https://github.com/mkdocstrings/autorefs/commit/c6303542018ca835f6941c070accb582f851f6b1) by Markus B). [Issue-35](https://github.com/mkdocstrings/autorefs/issues/35), [PR-50](https://github.com/mkdocstrings/autorefs/pull/50), Co-authored-by: Timothée Mazzucotelli + +### Bug Fixes + +- Only log "Markdown anchors feature enabled" once ([1c9bda1](https://github.com/mkdocstrings/autorefs/commit/1c9bda1ab4f13c9a5cf5d202de755e5296729654) by Timothée Mazzucotelli). [Issue-44](https://github.com/mkdocstrings/autorefs/issues/44) + +### Code Refactoring + +- Use a custom autoref HTML tag ([e142023](https://github.com/mkdocstrings/autorefs/commit/e14202317dc13dd5eed93b5d7cfd183c87de893f) by Timothée Mazzucotelli). [PR-48](https://github.com/mkdocstrings/autorefs/pull/48) +- Rename AutoRefInlineProcessor to AutorefsInlineProcessor ([ffcaa01](https://github.com/mkdocstrings/autorefs/commit/ffcaa0178b642e423acdc66d35f1e6b207099dc7) by Timothée Mazzucotelli). +- Attach name to processors for easier retrieval ([036b825](https://github.com/mkdocstrings/autorefs/commit/036b825c7994b2586564e8707fbc0b3627c29569) by Timothée Mazzucotelli). + ## [1.0.1](https://github.com/mkdocstrings/autorefs/releases/tag/1.0.1) - 2024-02-29 [Compare with 1.0.0](https://github.com/mkdocstrings/autorefs/compare/1.0.0...1.0.1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d162b4a..45bc0f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,13 +36,11 @@ Run `make help` to see all the available actions! ## Tasks -This project uses [duty](https://github.com/pawamoy/duty) to run tasks. -A Makefile is also provided. The Makefile will try to run certain tasks -on multiple Python versions. If for some reason you don't want to run the task -on multiple Python versions, you run the task directly with `make run duty TASK`. - -The Makefile detects if a virtual environment is activated, -so `make` will work the same with the virtualenv activated or not. +The entry-point to run commands and tasks is the `make` Python script, +located in the `scripts` directory. Try running `make` to show the available commands and tasks. +The *commands* do not need the Python dependencies to be installed, +while the *tasks* do. +The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). If you work in VSCode, we provide [an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) diff --git a/README.md b/README.md index c315800..b424123 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![pypi version](https://img.shields.io/pypi/v/mkdocs-autorefs.svg)](https://pypi.org/project/mkdocs-autorefs/) [![conda version](https://img.shields.io/conda/vn/conda-forge/mkdocs-autorefs.svg)](https://anaconda.org/conda-forge/mkdocs-autorefs) [![gitpod](https://img.shields.io/badge/gitpod-workspace-708FCC.svg?style=flat)](https://gitpod.io/#https://github.com/mkdocstrings/autorefs) -[![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#autorefs:gitter.im) +[![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#mkdocstrings/autorefs:gitter.im) Automatically link across pages in MkDocs. @@ -47,9 +47,38 @@ We can [link to that heading][hello-world] from another page too. This works the same as [a normal link to that heading](../doc1.md#hello-world). ``` -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). +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. -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. +### Non-unique headings + +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. + +To prevent getting warnings, use [Markdown anchors](#markdown-anchors) to add unique aliases to your headings, and use these aliases when referencing the headings. + +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: + +```yaml +plugins: +- autorefs: + resolve_closest: true +``` + +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: + +- URLs that are relative to the current page's URL, climbing up parents +- if multiple URLs are relative to it, use the one at the shortest distance if possible. + +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. + +Examples: + +Current page | Candidate URLs | Relative URLs | Winner +------------ | -------------- | ------------- | ------ +` ` | `x/#b`, `#b` | `#b` | `#b` (only one relative) +`a/` | `b/c/#d`, `c/#d` | none | `b/c/#d` (no relative, use first one, even if longer distance) +`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) +`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) +`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) ### Markdown anchors @@ -90,7 +119,7 @@ Paragraph about foobar. ```md In any document. -Check out the [paragraph about foobar][foobar-pararaph]. +Check out the [paragraph about foobar][foobar-paragraph]. ``` If you add a Markdown anchor right above a heading, this anchor will redirect to the heading itself: @@ -143,3 +172,14 @@ You don't want to change headings and make them redundant, like `## Arch: Instal ``` ...changing `arch` by `debian`, `gentoo`, etc. in the other pages. + +--- + +You can also change the actual identifier of a heading, thanks again to the `attr_list` Markdown extension: + +```md +## Install from sources { #arch-install-src } +... +``` + +...though note that this will impact the URL anchor too (and therefore the permalink to the heading). diff --git a/devdeps.txt b/devdeps.txt index 1552d56..94df649 100644 --- a/devdeps.txt +++ b/devdeps.txt @@ -4,7 +4,7 @@ editables>=0.5 # maintenance build>=1.2 git-changelog>=2.5 -twine>=5.1; python_version < '3.13' +twine>=5.0; python_version < '3.13' # ci duty>=1.4 diff --git a/docs/.overrides/partials/comments.html b/docs/.overrides/partials/comments.html new file mode 100644 index 0000000..009c4a7 --- /dev/null +++ b/docs/.overrides/partials/comments.html @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 612c7a5..8e6f2fb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1,6 @@ +--- +hide: +- feedback +--- + --8<-- "README.md" diff --git a/docs/js/feedback.js b/docs/js/feedback.js new file mode 100644 index 0000000..f97321a --- /dev/null +++ b/docs/js/feedback.js @@ -0,0 +1,14 @@ +const feedback = document.forms.feedback; +feedback.hidden = false; + +feedback.addEventListener("submit", function(ev) { + ev.preventDefault(); + const commentElement = document.getElementById("feedback"); + commentElement.style.display = "block"; + feedback.firstElementChild.disabled = true; + const data = ev.submitter.getAttribute("data-md-value"); + const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); + if (note) { + note.hidden = false; + } +}) diff --git a/docs/license.md b/docs/license.md index a873d2b..e81c0ed 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,8 @@ +--- +hide: +- feedback +--- + # License ``` diff --git a/duties.py b/duties.py index 4e7cf7a..e9832ac 100644 --- a/duties.py +++ b/duties.py @@ -53,8 +53,8 @@ def changelog(ctx: Context, bump: str = "") -> None: ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") -@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"]) -def check(ctx: Context) -> None: # noqa: ARG001 +@duty(pre=["check_quality", "check_types", "check_docs", "check-api"]) +def check(ctx: Context) -> None: """Check it all!""" diff --git a/mkdocs.yml b/mkdocs.yml index c120b5b..7ffc1e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,9 @@ extra_css: - css/material.css - css/mkdocstrings.css +extra_javascript: +- js/feedback.js + markdown_extensions: - attr_list - admonition @@ -146,3 +149,15 @@ extra: link: https://gitter.im/mkdocstrings/autorefs - icon: fontawesome/brands/python link: https://pypi.org/project/mkdocs-autorefs/ + analytics: + feedback: + title: Was this page helpful? + ratings: + - icon: material/emoticon-happy-outline + name: This page was helpful + data: 1 + note: Thanks for your feedback! + - icon: material/emoticon-sad-outline + name: This page could be improved + data: 0 + note: Let us know how we can improve this page. diff --git a/pyproject.toml b/pyproject.toml index f1442b9..500eb5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,19 @@ version = {source = "scm"} [tool.pdm.build] package-dir = "src" editable-backend = "editables" -source-includes = ["share"] +excludes = ["**/.pytest_cache"] +source-includes = [ + "config", + "docs", + "scripts", + "share", + "tests", + "devdeps.txt", + "duties.py", + "mkdocs.yml", + "*.md", + "LICENSE", +] [tool.pdm.build.wheel-data] data = [ diff --git a/scripts/make b/scripts/make index c097985..d898022 100755 --- a/scripts/make +++ b/scripts/make @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Management commands.""" +from __future__ import annotations + import os import shutil import subprocess @@ -15,9 +17,12 @@ exe = "" prefix = "" -def shell(cmd: str) -> None: +def shell(cmd: str, capture_output: bool = False, **kwargs: Any) -> str | None: """Run a shell command.""" - subprocess.run(cmd, shell=True, check=True) # noqa: S602 + if capture_output: + return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 + subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 + return None @contextmanager @@ -37,8 +42,8 @@ def uv_install() -> None: uv_opts = "" if "UV_RESOLUTION" in os.environ: uv_opts = f"--resolution={os.getenv('UV_RESOLUTION')}" - cmd = f"uv pip compile {uv_opts} pyproject.toml devdeps.txt | uv pip install -r -" - shell(cmd) + requirements = shell(f"uv pip compile {uv_opts} pyproject.toml devdeps.txt", capture_output=True) + shell("uv pip install -r -", input=requirements, text=True) if "CI" not in os.environ: shell("uv pip install --no-deps -e .") else: @@ -199,5 +204,7 @@ def main() -> int: if __name__ == "__main__": try: sys.exit(main()) - except Exception: # noqa: BLE001 - sys.exit(1) + except subprocess.CalledProcessError as process: + if process.output: + print(process.output, file=sys.stderr) # noqa: T201 + sys.exit(process.returncode) diff --git a/src/mkdocs_autorefs/plugin.py b/src/mkdocs_autorefs/plugin.py index 52e60fa..57b441a 100644 --- a/src/mkdocs_autorefs/plugin.py +++ b/src/mkdocs_autorefs/plugin.py @@ -15,9 +15,12 @@ import contextlib import functools import logging +import sys from typing import TYPE_CHECKING, Any, Callable, Sequence from urllib.parse import urlsplit +from mkdocs.config.base import Config +from mkdocs.config.config_options import Type from mkdocs.plugins import BasePlugin from mkdocs.structure.pages import Page @@ -37,8 +40,42 @@ log = logging.getLogger(f"mkdocs.plugins.{__name__}") # type: ignore[assignment] -class AutorefsPlugin(BasePlugin): - """An `mkdocs` plugin. +# YORE: EOL 3.8: Remove block. +if sys.version_info < (3, 9): + from pathlib import PurePosixPath + + class URL(PurePosixPath): # noqa: D101 + def is_relative_to(self, *args: Any) -> bool: # noqa: D102 + try: + self.relative_to(*args) + except ValueError: + return False + return True +else: + from pathlib import PurePosixPath as URL # noqa: N814 + + +class AutorefsConfig(Config): + """Configuration options for the `autorefs` plugin.""" + + resolve_closest = Type(bool, default=False) + """Whether to resolve an autoref to the closest URL when multiple URLs are found for an identifier. + + By closest, we mean a combination of "relative to the current page" and "shortest distance from the current page". + + For example, if you link to identifier `hello` from page `foo/bar/`, + and the identifier is found in `foo/`, `foo/baz/` and `foo/bar/baz/qux/` pages, + autorefs will resolve to `foo/bar/baz/qux`, which is the only URL relative to `foo/bar/`. + + If multiple URLs are equally close, autorefs will resolve to the first of these equally close URLs. + 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. + + When false and multiple URLs are found for an identifier, autorefs will log a warning and resolve to the first URL. + """ + + +class AutorefsPlugin(BasePlugin[AutorefsConfig]): + """The `autorefs` plugin for `mkdocs`. This plugin defines the following event hooks: @@ -57,7 +94,7 @@ class AutorefsPlugin(BasePlugin): def __init__(self) -> None: """Initialize the object.""" super().__init__() - self._url_map: dict[str, str] = {} + self._url_map: dict[str, list[str]] = {} self._abs_url_map: dict[str, str] = {} self.get_fallback_anchor: Callable[[str], tuple[str, ...]] | None = None @@ -68,7 +105,12 @@ def register_anchor(self, page: str, identifier: str, anchor: str | None = None) page: The relative URL of the current page. Examples: `'foo/bar/'`, `'foo/index.html'` identifier: The HTML anchor (without '#') as a string. """ - self._url_map[identifier] = f"{page}#{anchor or identifier}" + page_anchor = f"{page}#{anchor or identifier}" + if identifier in self._url_map: + if page_anchor not in self._url_map[identifier]: + self._url_map[identifier].append(page_anchor) + else: + self._url_map[identifier] = [page_anchor] def register_url(self, identifier: str, url: str) -> None: """Register that the identifier should be turned into a link to this URL. @@ -79,13 +121,47 @@ def register_url(self, identifier: str, url: str) -> None: """ self._abs_url_map[identifier] = url + @staticmethod + def _get_closest_url(from_url: str, urls: list[str]) -> str: + """Return the closest URL to the current page. + + Arguments: + from_url: The URL of the base page, from which we link towards the targeted pages. + urls: A list of URLs to choose from. + + Returns: + The closest URL to the current page. + """ + base_url = URL(from_url) + + while True: + if candidates := [url for url in urls if URL(url).is_relative_to(base_url)]: + break + base_url = base_url.parent + if not base_url.name: + break + + if not candidates: + log.warning( + "Could not find closest URL (from %s, candidates: %s). " + "Make sure to use unique headings, identifiers, or Markdown anchors (see our docs).", + from_url, + urls, + ) + return urls[0] + + winner = candidates[0] if len(candidates) == 1 else min(candidates, key=lambda c: c.count("/")) + log.debug("Closest URL found: %s (from %s, candidates: %s)", winner, from_url, urls) + return winner + def _get_item_url( self, identifier: str, fallback: Callable[[str], Sequence[str]] | None = None, + from_url: str | None = None, ) -> str: try: - return self._url_map[identifier] + urls = self._url_map[identifier] except KeyError: if identifier in self._abs_url_map: return self._abs_url_map[identifier] @@ -94,10 +170,21 @@ def _get_item_url( for new_identifier in new_identifiers: with contextlib.suppress(KeyError): url = self._get_item_url(new_identifier) - self._url_map[identifier] = url + self._url_map[identifier] = [url] return url raise + if len(urls) > 1: + if self.config.resolve_closest and from_url is not None: + return self._get_closest_url(from_url, urls) + log.warning( + "Multiple URLs found for '%s': %s. " + "Make sure to use unique headings, identifiers, or Markdown anchors (see our docs).", + identifier, + urls, + ) + return urls[0] + def get_item_url( self, identifier: str, @@ -114,7 +201,7 @@ def get_item_url( Returns: A site-relative URL. """ - url = self._get_item_url(identifier, fallback) + url = self._get_item_url(identifier, fallback, from_url) if from_url is not None: parsed = urlsplit(url) if not parsed.scheme and not parsed.netloc: @@ -170,7 +257,7 @@ def on_page_content(self, html: str, page: Page, **kwargs: Any) -> str: # noqa: The same HTML. We only use this hook to map anchors to URLs. """ if self.scan_toc: - log.debug(f"Mapping identifiers to URLs for page {page.file.src_path}") + log.debug("Mapping identifiers to URLs for page %s", page.file.src_path) for item in page.toc.items: self.map_urls(page.url, item) return html @@ -209,7 +296,7 @@ def on_post_page(self, output: str, page: Page, **kwargs: Any) -> str: # noqa: Returns: Modified HTML. """ - log.debug(f"Fixing references in page {page.file.src_path}") + log.debug("Fixing references in page %s", page.file.src_path) url_mapper = functools.partial(self.get_item_url, from_url=page.url, fallback=self.get_fallback_anchor) fixed_output, unmapped = fix_refs(output, url_mapper, _legacy_refs=self.legacy_refs) diff --git a/src/mkdocs_autorefs/references.py b/src/mkdocs_autorefs/references.py index bc72c81..f0f7723 100644 --- a/src/mkdocs_autorefs/references.py +++ b/src/mkdocs_autorefs/references.py @@ -7,6 +7,7 @@ import warnings from abc import ABC, abstractmethod from dataclasses import dataclass +from functools import lru_cache from html import escape, unescape from html.parser import HTMLParser from typing import TYPE_CHECKING, Any, Callable, ClassVar, Match @@ -36,6 +37,7 @@ log = logging.getLogger(f"mkdocs.plugins.{__name__}") # type: ignore[assignment] +# YORE: Bump 2: Remove block. def __getattr__(name: str) -> Any: if name == "AutoRefInlineProcessor": warnings.warn("AutoRefInlineProcessor was renamed AutorefsInlineProcessor", DeprecationWarning, stacklevel=2) @@ -44,6 +46,8 @@ def __getattr__(name: str) -> Any: _ATTR_VALUE = r'"[^"<>]+"|[^"<> ]+' # Possibly with double quotes around + +# YORE: Bump 2: Remove block. AUTO_REF_RE = re.compile( rf"autorefs-(?:identifier|optional|optional-hover))=(?P{_ATTR_VALUE})" rf"(?: class=(?P{_ATTR_VALUE}))?(?P [^<>]+)?>(?P.*?)</span>", @@ -134,11 +138,10 @@ def handleMatch(self, m: Match[str], data: str) -> tuple[Element | None, int | N if not handled or identifier is None: return None, None, None - if re.search(r"[/ \x00-\x1f]", identifier): - # Do nothing if the matched reference contains: - # - a space, slash or control character (considered unintended); - # - specifically \x01 is used by Python-Markdown HTML stash when there's inline formatting, - # but references with Markdown formatting are not possible anyway. + if re.search(r"[\x00-\x1f]", identifier): + # Do nothing if the matched reference contains control characters (from 0 to 31 included). + # Specifically `\x01` is used by Python-Markdown HTML stash when there's inline formatting, + # but references with Markdown formatting are not possible anyway. return None, m.start(0), end return self._make_tag(identifier, text), m.start(0), end @@ -226,6 +229,7 @@ def relative_url(url_a: str, url_b: str) -> str: return f"{relative}#{anchor}" +# YORE: Bump 2: Remove block. def _legacy_fix_ref(url_mapper: Callable[[str], str], unmapped: list[str]) -> Callable: """Return a `repl` function for [`re.sub`](https://docs.python.org/3/library/re.html#re.sub). @@ -264,6 +268,12 @@ def inner(match: Match) -> str: return f"[{identifier}][]" return f"[{title}][{identifier}]" + warnings.warn( + "autorefs `span` elements are deprecated in favor of `autoref` elements: " + f'`<span data-autorefs-identifier="{identifier}">...</span>` becomes `<autoref identifer="{identifier}">...</autoref>`', + DeprecationWarning, + stacklevel=1, + ) parsed = urlsplit(url) external = parsed.scheme or parsed.netloc classes = ["autorefs", "autorefs-external" if external else "autorefs-internal", *classes] @@ -352,6 +362,7 @@ def inner(match: Match) -> str: return inner +# YORE: Bump 2: Replace `, *, _legacy_refs: bool = True` with `` within line. def fix_refs(html: str, url_mapper: Callable[[str], str], *, _legacy_refs: bool = True) -> tuple[str, list[str]]: """Fix all references in the given HTML text. @@ -365,8 +376,11 @@ def fix_refs(html: str, url_mapper: Callable[[str], str], *, _legacy_refs: bool """ unmapped: list[str] = [] html = AUTOREF_RE.sub(fix_ref(url_mapper, unmapped), html) + + # YORE: Bump 2: Remove block. if _legacy_refs: html = AUTO_REF_RE.sub(_legacy_fix_ref(url_mapper, unmapped), html) + return html, unmapped @@ -438,6 +452,11 @@ def flush(self, alias_to: str | None = None) -> None: self.anchors.clear() +@lru_cache +def _log_enabling_markdown_anchors() -> None: + log.debug("Enabling Markdown anchors feature") + + class AutorefsExtension(Extension): """Markdown extension that transforms unresolved references into auto-references. @@ -477,7 +496,7 @@ def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent me priority=168, # Right after markdown.inlinepatterns.ReferenceInlineProcessor ) if self.plugin is not None and self.plugin.scan_toc and "attr_list" in md.treeprocessors: - log.debug("Enabling Markdown anchors feature") + _log_enabling_markdown_anchors() md.treeprocessors.register( AnchorScannerTreeProcessor(self.plugin, md), AnchorScannerTreeProcessor.name, diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 8acd446..2a23655 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -60,3 +60,27 @@ def test_dont_make_relative_urls_relative_again() -> None: plugin.get_item_url("hello", from_url="baz/bar/foo.html", fallback=lambda _: ("foo.bar.baz",)) == "../../foo/bar/baz.html#foo.bar.baz" ) + + +@pytest.mark.parametrize( + ("base", "urls", "expected"), + [ + # One URL is closest. + ("", ["x/#b", "#b"], "#b"), + # Several URLs are equally close. + ("a/b", ["x/#e", "a/c/#e", "a/d/#e"], "a/c/#e"), + ("a/b/", ["x/#e", "a/d/#e", "a/c/#e"], "a/d/#e"), + # Two close URLs, one is shorter (closer). + ("a/b", ["x/#e", "a/c/#e", "a/c/d/#e"], "a/c/#e"), + ("a/b/", ["x/#e", "a/c/d/#e", "a/c/#e"], "a/c/#e"), + # Deeper-nested URLs. + ("a/b/c", ["x/#e", "a/#e", "a/b/#e", "a/b/c/#e", "a/b/c/d/#e"], "a/b/c/#e"), + ("a/b/c/", ["x/#e", "a/#e", "a/b/#e", "a/b/c/d/#e", "a/b/c/#e"], "a/b/c/#e"), + # No closest URL, use first one even if longer distance. + ("a", ["b/c/#d", "c/#d"], "b/c/#d"), + ("a/", ["c/#d", "b/c/#d"], "c/#d"), + ], +) +def test_find_closest_url(base: str, urls: list[str], expected: str) -> None: + """Find closest URLs given a list of URLs.""" + assert AutorefsPlugin._get_closest_url(base, urls) == expected diff --git a/tests/test_references.py b/tests/test_references.py index 748eacf..3eab1f0 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -146,11 +146,11 @@ def test_multiline_links() -> None: def test_no_reference_with_space() -> None: - """Check that references with spaces are not fixed.""" + """Check that references with spaces are fixed.""" run_references_test( - url_map={"Foo bar": "foo.html#Foo bar"}, + url_map={"Foo bar": "foo.html#bar"}, source="This [Foo bar][].", - output="<p>This [Foo bar][].</p>", + output='<p>This <a class="autorefs autorefs-internal" href="foo.html#bar">Foo bar</a>.</p>', ) @@ -203,12 +203,17 @@ def test_missing_reference_with_markdown_implicit() -> None: ) -def test_ignore_reference_with_special_char() -> None: - """Check that references are not considered if there is a space character inside.""" +def test_reference_with_markup() -> None: + """Check that references with markup are resolved (and need escaping to prevent rendering).""" run_references_test( - url_map={"a b": "foo.html#Foo"}, + url_map={"*a b*": "foo.html#Foo"}, source="This [*a b*][].", - output="<p>This [<em>a b</em>][].</p>", + output='<p>This <a class="autorefs autorefs-internal" href="foo.html#Foo"><em>a b</em></a>.</p>', + ) + run_references_test( + url_map={"*a/b*": "foo.html#Foo"}, + source="This [`*a/b*`][].", + output='<p>This <a class="autorefs autorefs-internal" href="foo.html#Foo"><code>*a/b*</code></a>.</p>', ) @@ -216,7 +221,8 @@ def test_legacy_custom_required_reference() -> None: """Check that external HTML-based references are expanded or reported missing.""" url_map = {"ok": "ok.html#ok"} source = "<span data-autorefs-identifier=bar>foo</span> <span data-autorefs-identifier=ok>ok</span>" - output, unmapped = fix_refs(source, url_map.__getitem__) + with pytest.warns(DeprecationWarning, match="`span` elements are deprecated"): + output, unmapped = fix_refs(source, url_map.__getitem__) assert output == '[foo][bar] <a class="autorefs autorefs-internal" href="ok.html#ok">ok</a>' assert unmapped == ["bar"] @@ -234,7 +240,8 @@ def test_legacy_custom_optional_reference() -> None: """Check that optional HTML-based references are expanded and never reported missing.""" url_map = {"ok": "ok.html#ok"} source = '<span data-autorefs-optional="bar">foo</span> <span data-autorefs-optional=ok>ok</span>' - output, unmapped = fix_refs(source, url_map.__getitem__) + with pytest.warns(DeprecationWarning, match="`span` elements are deprecated"): + output, unmapped = fix_refs(source, url_map.__getitem__) assert output == 'foo <a class="autorefs autorefs-internal" href="ok.html#ok">ok</a>' assert unmapped == [] @@ -252,7 +259,8 @@ def test_legacy_custom_optional_hover_reference() -> None: """Check that optional-hover HTML-based references are expanded and never reported missing.""" url_map = {"ok": "ok.html#ok"} source = '<span data-autorefs-optional-hover="bar">foo</span> <span data-autorefs-optional-hover=ok>ok</span>' - output, unmapped = fix_refs(source, url_map.__getitem__) + with pytest.warns(DeprecationWarning, match="`span` elements are deprecated"): + output, unmapped = fix_refs(source, url_map.__getitem__) assert ( output == '<span title="bar">foo</span> <a class="autorefs autorefs-internal" title="ok" href="ok.html#ok">ok</a>' @@ -276,7 +284,8 @@ def test_legacy_external_references() -> None: """Check that external references are marked as such.""" url_map = {"example": "https://example.com"} source = '<span data-autorefs-optional="example">example</span>' - output, unmapped = fix_refs(source, url_map.__getitem__) + with pytest.warns(DeprecationWarning, match="`span` elements are deprecated"): + output, unmapped = fix_refs(source, url_map.__getitem__) assert output == '<a class="autorefs autorefs-external" href="https://example.com">example</a>' assert unmapped == [] @@ -325,23 +334,29 @@ def test_register_markdown_anchors() -> None: [](){#alias9} ## Heading more2 {#heading-custom2} + [](){#aliasSame} + ## Same heading 1 + [](){#aliasSame} + ## Same heading 2 + [](){#alias10} """, ), ) assert plugin._url_map == { - "foo": "page#heading-foo", - "bar": "page#bar", - "alias1": "page#heading-bar", - "alias2": "page#heading-bar", - "alias3": "page#alias3", - "alias4": "page#heading-baz", - "alias5": "page#alias5", - "alias6": "page#alias6", - "alias7": "page#alias7", - "alias8": "page#alias8", - "alias9": "page#heading-custom2", - "alias10": "page#alias10", + "foo": ["page#heading-foo"], + "bar": ["page#bar"], + "alias1": ["page#heading-bar"], + "alias2": ["page#heading-bar"], + "alias3": ["page#alias3"], + "alias4": ["page#heading-baz"], + "alias5": ["page#alias5"], + "alias6": ["page#alias6"], + "alias7": ["page#alias7"], + "alias8": ["page#alias8"], + "alias9": ["page#heading-custom2"], + "alias10": ["page#alias10"], + "aliasSame": ["page#same-heading-1", "page#same-heading-2"], } @@ -366,9 +381,9 @@ def test_register_markdown_anchors_with_admonition() -> None: ), ) assert plugin._url_map == { - "alias1": "page#alias1", - "alias2": "page#heading-bar", - "alias3": "page#alias3", + "alias1": ["page#alias1"], + "alias2": ["page#heading-bar"], + "alias3": ["page#alias3"], } @@ -376,7 +391,8 @@ def test_legacy_keep_data_attributes() -> None: """Keep HTML data attributes from autorefs spans.""" url_map = {"example": "https://e.com"} source = '<span data-autorefs-optional="example" class="hi ho" data-foo data-bar="0">e</span>' - output, _ = fix_refs(source, url_map.__getitem__) + with pytest.warns(DeprecationWarning, match="`span` elements are deprecated"): + output, _ = fix_refs(source, url_map.__getitem__) assert output == '<a class="autorefs autorefs-external hi ho" href="https://e.com" data-foo data-bar="0">e</a>'