'
src = Path(__file__).parent.parent / "src"
for path in sorted(src.rglob("*.py")):
module_path = path.relative_to(src).with_suffix("")
doc_path = path.relative_to(src).with_suffix(".md")
full_doc_path = Path("reference", doc_path)
parts = tuple(module_path.parts)
if parts[-1] == "__init__":
parts = parts[:-1]
doc_path = doc_path.with_name("index.md")
full_doc_path = full_doc_path.with_name("index.md")
elif parts[-1].startswith("_"):
continue
nav_parts = [f"{mod_symbol} {part}" for part in parts]
nav[tuple(nav_parts)] = doc_path.as_posix()
with mkdocs_gen_files.open(full_doc_path, "w") as fd:
ident = ".".join(parts)
fd.write(f"::: {ident}")
mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path)
with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file:
nav_file.writelines(nav.build_literate_nav())
mkdocs-autorefs-1.0.1/scripts/setup.sh 0000775 0000000 0000000 00000001217 14570123734 0020014 0 ustar 00root root 0000000 0000000 #!/usr/bin/env bash
set -e
if ! command -v pdm &>/dev/null; then
if ! command -v pipx &>/dev/null; then
python3 -m pip install --user pipx
fi
pipx install pdm
fi
if ! pdm self list 2>/dev/null | grep -q pdm-multirun; then
pdm install --plugins
fi
if [ -n "${PDM_MULTIRUN_VERSIONS}" ]; then
if [ "${PDM_MULTIRUN_USE_VENVS}" -eq "1" ]; then
for version in ${PDM_MULTIRUN_VERSIONS}; do
if ! pdm venv --path "${version}" &>/dev/null; then
pdm venv create --name "${version}" "${version}"
fi
done
fi
pdm multirun -v pdm install -G:all
else
pdm install -G:all
fi
mkdocs-autorefs-1.0.1/src/ 0000775 0000000 0000000 00000000000 14570123734 0015414 5 ustar 00root root 0000000 0000000 mkdocs-autorefs-1.0.1/src/mkdocs_autorefs/ 0000775 0000000 0000000 00000000000 14570123734 0020604 5 ustar 00root root 0000000 0000000 mkdocs-autorefs-1.0.1/src/mkdocs_autorefs/__init__.py 0000664 0000000 0000000 00000000211 14570123734 0022707 0 ustar 00root root 0000000 0000000 """mkdocs-autorefs package.
Automatically link across pages in MkDocs.
"""
from __future__ import annotations
__all__: list[str] = []
mkdocs-autorefs-1.0.1/src/mkdocs_autorefs/debug.py 0000664 0000000 0000000 00000005234 14570123734 0022250 0 ustar 00root root 0000000 0000000 """Debugging utilities."""
from __future__ import annotations
import os
import platform
import sys
from dataclasses import dataclass
from importlib import metadata
@dataclass
class Variable:
"""Dataclass describing an environment variable."""
name: str
"""Variable name."""
value: str
"""Variable value."""
@dataclass
class Package:
"""Dataclass describing a Python package."""
name: str
"""Package name."""
version: str
"""Package version."""
@dataclass
class Environment:
"""Dataclass to store environment information."""
interpreter_name: str
"""Python interpreter name."""
interpreter_version: str
"""Python interpreter version."""
platform: str
"""Operating System."""
packages: list[Package]
"""Installed packages."""
variables: list[Variable]
"""Environment variables."""
def _interpreter_name_version() -> tuple[str, str]:
if hasattr(sys, "implementation"):
impl = sys.implementation.version
version = f"{impl.major}.{impl.minor}.{impl.micro}"
kind = impl.releaselevel
if kind != "final":
version += kind[0] + str(impl.serial)
return sys.implementation.name, version
return "", "0.0.0"
def get_version(dist: str = "mkdocs-autorefs") -> str:
"""Get version of the given distribution.
Parameters:
dist: A distribution name.
Returns:
A version number.
"""
try:
return metadata.version(dist)
except metadata.PackageNotFoundError:
return "0.0.0"
def get_debug_info() -> Environment:
"""Get debug/environment information.
Returns:
Environment information.
"""
py_name, py_version = _interpreter_name_version()
packages = ["mkdocs-autorefs"]
variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("MKDOCS_AUTOREFS")]]
return Environment(
interpreter_name=py_name,
interpreter_version=py_version,
platform=platform.platform(),
variables=[Variable(var, val) for var in variables if (val := os.getenv(var))],
packages=[Package(pkg, get_version(pkg)) for pkg in packages],
)
def print_debug_info() -> None:
"""Print debug/environment information."""
info = get_debug_info()
print(f"- __System__: {info.platform}")
print(f"- __Python__: {info.interpreter_name} {info.interpreter_version}")
print("- __Environment variables__:")
for var in info.variables:
print(f" - `{var.name}`: `{var.value}`")
print("- __Installed packages__:")
for pkg in info.packages:
print(f" - `{pkg.name}` v{pkg.version}")
if __name__ == "__main__":
print_debug_info()
mkdocs-autorefs-1.0.1/src/mkdocs_autorefs/plugin.py 0000664 0000000 0000000 00000021300 14570123734 0022450 0 ustar 00root root 0000000 0000000 """This module contains the "mkdocs-autorefs" plugin.
After each page is processed by the Markdown converter, this plugin stores absolute URLs of every HTML anchors
it finds to later be able to fix unresolved references.
It stores them during the [`on_page_content` event hook](https://www.mkdocs.org/user-guide/plugins/#on_page_content).
Just before writing the final HTML to the disc, during the
[`on_post_page` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_page),
this plugin searches for references of the form `[identifier][]` or `[title][identifier]` that were not resolved,
and fixes them using the previously stored identifier-URL mapping.
"""
from __future__ import annotations
import contextlib
import functools
import logging
from typing import TYPE_CHECKING, Any, Callable, Sequence
from urllib.parse import urlsplit
from mkdocs.plugins import BasePlugin
from mkdocs.structure.pages import Page
from mkdocs_autorefs.references import AutorefsExtension, fix_refs, relative_url
if TYPE_CHECKING:
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.pages import Page
from mkdocs.structure.toc import AnchorLink
try:
from mkdocs.plugins import get_plugin_logger
log = get_plugin_logger(__name__)
except ImportError:
# TODO: remove once support for MkDocs <1.5 is dropped
log = logging.getLogger(f"mkdocs.plugins.{__name__}") # type: ignore[assignment]
class AutorefsPlugin(BasePlugin):
"""An `mkdocs` plugin.
This plugin defines the following event hooks:
- `on_config`
- `on_page_content`
- `on_post_page`
Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs`
for more information about its plugin system.
"""
scan_toc: bool = True
current_page: str | None = None
def __init__(self) -> None:
"""Initialize the object."""
super().__init__()
self._url_map: dict[str, str] = {}
self._abs_url_map: dict[str, str] = {}
self.get_fallback_anchor: Callable[[str], tuple[str, ...]] | None = None
def register_anchor(self, page: str, identifier: str, anchor: str | None = None) -> None:
"""Register that an anchor corresponding to an identifier was encountered when rendering the page.
Arguments:
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}"
def register_url(self, identifier: str, url: str) -> None:
"""Register that the identifier should be turned into a link to this URL.
Arguments:
identifier: The new identifier.
url: The absolute URL (including anchor, if needed) where this item can be found.
"""
self._abs_url_map[identifier] = url
def _get_item_url(
self,
identifier: str,
fallback: Callable[[str], Sequence[str]] | None = None,
) -> str:
try:
return self._url_map[identifier]
except KeyError:
if identifier in self._abs_url_map:
return self._abs_url_map[identifier]
if fallback:
new_identifiers = fallback(identifier)
for new_identifier in new_identifiers:
with contextlib.suppress(KeyError):
url = self._get_item_url(new_identifier)
self._url_map[identifier] = url
return url
raise
def get_item_url(
self,
identifier: str,
from_url: str | None = None,
fallback: Callable[[str], Sequence[str]] | None = None,
) -> str:
"""Return a site-relative URL with anchor to the identifier, if it's present anywhere.
Arguments:
identifier: The anchor (without '#').
from_url: The URL of the base page, from which we link towards the targeted pages.
fallback: An optional function to suggest alternative anchors to try on failure.
Returns:
A site-relative URL.
"""
url = self._get_item_url(identifier, fallback)
if from_url is not None:
parsed = urlsplit(url)
if not parsed.scheme and not parsed.netloc:
return relative_url(from_url, url)
return url
def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
"""Instantiate our Markdown extension.
Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
In this hook, we instantiate our [`AutorefsExtension`][mkdocs_autorefs.references.AutorefsExtension]
and add it to the list of Markdown extensions used by `mkdocs`.
Arguments:
config: The MkDocs config object.
Returns:
The modified config.
"""
log.debug("Adding AutorefsExtension to the list")
config["markdown_extensions"].append(AutorefsExtension(self))
return config
def on_page_markdown(self, markdown: str, page: Page, **kwargs: Any) -> str: # noqa: ARG002
"""Remember which page is the current one.
Arguments:
markdown: Input Markdown.
page: The related MkDocs page instance.
kwargs: Additional arguments passed by MkDocs.
Returns:
The same Markdown. We only use this hook to keep a reference to the current page URL,
used during Markdown conversion by the anchor scanner tree processor.
"""
self.current_page = page.url
return markdown
def on_page_content(self, html: str, page: Page, **kwargs: Any) -> str: # noqa: ARG002
"""Map anchors to URLs.
Hook for the [`on_page_content` event](https://www.mkdocs.org/user-guide/plugins/#on_page_content).
In this hook, we map the IDs of every anchor found in the table of contents to the anchors absolute URLs.
This mapping will be used later to fix unresolved reference of the form `[title][identifier]` or
`[identifier][]`.
Arguments:
html: HTML converted from Markdown.
page: The related MkDocs page instance.
kwargs: Additional arguments passed by MkDocs.
Returns:
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}")
for item in page.toc.items:
self.map_urls(page.url, item)
return html
def map_urls(self, base_url: str, anchor: AnchorLink) -> None:
"""Recurse on every anchor to map its ID to its absolute URL.
This method populates `self.url_map` by side-effect.
Arguments:
base_url: The base URL to use as a prefix for each anchor's relative URL.
anchor: The anchor to process and to recurse on.
"""
self.register_anchor(base_url, anchor.id)
for child in anchor.children:
self.map_urls(base_url, child)
def on_post_page(self, output: str, page: Page, **kwargs: Any) -> str: # noqa: ARG002
"""Fix cross-references.
Hook for the [`on_post_page` event](https://www.mkdocs.org/user-guide/plugins/#on_post_page).
In this hook, we try to fix unresolved references of the form `[title][identifier]` or `[identifier][]`.
Doing that allows the user of `autorefs` to cross-reference objects in their documentation strings.
It uses the native Markdown syntax so it's easy to remember and use.
We log a warning for each reference that we couldn't map to an URL, but try to be smart and ignore identifiers
that do not look legitimate (sometimes documentation can contain strings matching
our [`AUTO_REF_RE`][mkdocs_autorefs.references.AUTO_REF_RE] regular expression that did not intend to reference anything).
We currently ignore references when their identifier contains a space or a slash.
Arguments:
output: HTML converted from Markdown.
page: The related MkDocs page instance.
kwargs: Additional arguments passed by MkDocs.
Returns:
Modified HTML.
"""
log.debug(f"Fixing references in page {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)
if unmapped and log.isEnabledFor(logging.WARNING):
for ref in unmapped:
log.warning(f"{page.file.src_path}: Could not find cross-reference target '[{ref}]'")
return fixed_output
mkdocs-autorefs-1.0.1/src/mkdocs_autorefs/py.typed 0000664 0000000 0000000 00000000000 14570123734 0022271 0 ustar 00root root 0000000 0000000 mkdocs-autorefs-1.0.1/src/mkdocs_autorefs/references.py 0000664 0000000 0000000 00000031532 14570123734 0023303 0 ustar 00root root 0000000 0000000 """Cross-references module."""
from __future__ import annotations
import logging
import re
from html import escape, unescape
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Match
from urllib.parse import urlsplit
from xml.etree.ElementTree import Element
import markupsafe
from markdown.core import Markdown
from markdown.extensions import Extension
from markdown.inlinepatterns import REFERENCE_RE, ReferenceInlineProcessor
from markdown.treeprocessors import Treeprocessor
from markdown.util import HTML_PLACEHOLDER_RE, INLINE_PLACEHOLDER_RE
if TYPE_CHECKING:
from markdown import Markdown
from mkdocs_autorefs.plugin import AutorefsPlugin
try:
from mkdocs.plugins import get_plugin_logger
log = get_plugin_logger(__name__)
except ImportError:
# TODO: remove once support for MkDocs <1.5 is dropped
log = logging.getLogger(f"mkdocs.plugins.{__name__}") # type: ignore[assignment]
_ATTR_VALUE = r'"[^"<>]+"|[^"<> ]+' # Possibly with double quotes around
AUTO_REF_RE = re.compile(
rf"autorefs-(?:identifier|optional|optional-hover))=(?P snippets but strip them back to unhighlighted.
if match := HTML_PLACEHOLDER_RE.fullmatch(identifier):
stash_index = int(match.group(1))
html = self.md.htmlStash.rawHtmlBlocks[stash_index]
identifier = markupsafe.Markup(html).striptags()
self.md.htmlStash.rawHtmlBlocks[stash_index] = escape(identifier)
end = m.end(0)
return identifier, end, True
def _make_tag(self, identifier: str, text: str) -> Element:
"""Create a tag that can be matched by `AUTO_REF_RE`.
Arguments:
identifier: The identifier to use in the HTML property.
text: The text to use in the HTML tag.
Returns:
A new element.
"""
el = Element("span")
el.set("data-autorefs-identifier", identifier)
el.text = text
return el
def relative_url(url_a: str, url_b: str) -> str:
"""Compute the relative path from URL A to URL B.
Arguments:
url_a: URL A.
url_b: URL B.
Returns:
The relative URL to go from A to B.
"""
parts_a = url_a.split("/")
url_b, anchor = url_b.split("#", 1)
parts_b = url_b.split("/")
# remove common left parts
while parts_a and parts_b and parts_a[0] == parts_b[0]:
parts_a.pop(0)
parts_b.pop(0)
# go up as many times as remaining a parts' depth
levels = len(parts_a) - 1
parts_relative = [".."] * levels + parts_b
relative = "/".join(parts_relative)
return f"{relative}#{anchor}"
def 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).
In our context, we match Markdown references and replace them with HTML links.
When the matched reference's identifier was not mapped to an URL, we append the identifier to the outer
`unmapped` list. It generally means the user is trying to cross-reference an object that was not collected
and rendered, making it impossible to link to it. We catch this exception in the caller to issue a warning.
Arguments:
url_mapper: A callable that gets an object's site URL by its identifier,
such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
unmapped: A list to store unmapped identifiers.
Returns:
The actual function accepting a [`Match` object](https://docs.python.org/3/library/re.html#match-objects)
and returning the replacement strings.
"""
def inner(match: Match) -> str:
identifier = match["identifier"].strip('"')
title = match["title"]
kind = match["kind"]
attrs = match["attrs"] or ""
classes = (match["class"] or "").strip('"').split()
try:
url = url_mapper(unescape(identifier))
except KeyError:
if kind == "autorefs-optional":
return title
if kind == "autorefs-optional-hover":
return f'{title}'
unmapped.append(identifier)
if title == identifier:
return f"[{identifier}][]"
return f"[{title}][{identifier}]"
parsed = urlsplit(url)
external = parsed.scheme or parsed.netloc
classes = ["autorefs", "autorefs-external" if external else "autorefs-internal", *classes]
class_attr = " ".join(classes)
if kind == "autorefs-optional-hover":
return f'{title}'
return f'{title}'
return inner
def fix_refs(html: str, url_mapper: Callable[[str], str]) -> tuple[str, list[str]]:
"""Fix all references in the given HTML text.
Arguments:
html: The text to fix.
url_mapper: A callable that gets an object's site URL by its identifier,
such as [mkdocs_autorefs.plugin.AutorefsPlugin.get_item_url][].
Returns:
The fixed HTML.
"""
unmapped: list[str] = []
html = AUTO_REF_RE.sub(fix_ref(url_mapper, unmapped), html)
return html, unmapped
class AnchorScannerTreeProcessor(Treeprocessor):
"""Tree processor to scan and register HTML anchors."""
_htags: ClassVar[set[str]] = {"h1", "h2", "h3", "h4", "h5", "h6"}
def __init__(self, plugin: AutorefsPlugin, md: Markdown | None = None) -> None:
"""Initialize the tree processor.
Parameters:
plugin: A reference to the autorefs plugin, to use its `register_anchor` method.
"""
super().__init__(md)
self.plugin = plugin
def run(self, root: Element) -> None: # noqa: D102
if self.plugin.current_page is not None:
pending_anchors = _PendingAnchors(self.plugin, self.plugin.current_page)
self._scan_anchors(root, pending_anchors)
pending_anchors.flush()
def _scan_anchors(self, parent: Element, pending_anchors: _PendingAnchors) -> None:
for el in parent:
if el.tag == "a":
# We found an anchor. Record its id if it has one.
if anchor_id := el.get("id"):
pending_anchors.append(anchor_id)
# If the element has text or a link, it's not an alias.
# Non-whitespace text after the element interrupts the chain, aliases can't apply.
if el.text or el.get("href") or (el.tail and el.tail.strip()):
pending_anchors.flush()
elif el.tag == "p":
# A `p` tag is a no-op for our purposes, just recurse into it in the context
# of the current collection of anchors.
self._scan_anchors(el, pending_anchors)
# Non-whitespace text after the element interrupts the chain, aliases can't apply.
if el.tail and el.tail.strip():
pending_anchors.flush()
elif el.tag in self._htags:
# If the element is a heading, that turns the pending anchors into aliases.
pending_anchors.flush(el.get("id"))
else:
# But if it's some other interruption, flush anchors anyway as non-aliases.
pending_anchors.flush()
# Recurse into sub-elements, in a *separate* context.
self.run(el)
class _PendingAnchors:
"""A collection of HTML anchors that may or may not become aliased to an upcoming heading."""
def __init__(self, plugin: AutorefsPlugin, current_page: str):
self.plugin = plugin
self.current_page = current_page
self.anchors: list[str] = []
def append(self, anchor: str) -> None:
self.anchors.append(anchor)
def flush(self, alias_to: str | None = None) -> None:
for anchor in self.anchors:
self.plugin.register_anchor(self.current_page, anchor, alias_to)
self.anchors.clear()
class AutorefsExtension(Extension):
"""Extension that inserts auto-references in Markdown."""
def __init__(
self,
plugin: AutorefsPlugin | None = None,
**kwargs: Any,
) -> None:
"""Initialize the Markdown extension.
Parameters:
plugin: An optional reference to the autorefs plugin (to pass it to the anchor scanner tree processor).
**kwargs: Keyword arguments passed to the [base constructor][markdown.extensions.Extension].
"""
super().__init__(**kwargs)
self.plugin = plugin
def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
"""Register the extension.
Add an instance of our [`AutoRefInlineProcessor`][mkdocs_autorefs.references.AutoRefInlineProcessor] to the Markdown parser.
Also optionally add an instance of our [`AnchorScannerTreeProcessor`][mkdocs_autorefs.references.AnchorScannerTreeProcessor]
to the Markdown parser if a reference to the autorefs plugin was passed to this extension.
Arguments:
md: A `markdown.Markdown` instance.
"""
md.inlinePatterns.register(
AutoRefInlineProcessor(md),
"mkdocs-autorefs",
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")
md.treeprocessors.register(
AnchorScannerTreeProcessor(self.plugin, md),
"mkdocs-autorefs-anchors-scanner",
priority=0,
)
mkdocs-autorefs-1.0.1/tests/ 0000775 0000000 0000000 00000000000 14570123734 0015767 5 ustar 00root root 0000000 0000000 mkdocs-autorefs-1.0.1/tests/__init__.py 0000664 0000000 0000000 00000000055 14570123734 0020100 0 ustar 00root root 0000000 0000000 """Tests for the mkdocs-autorefs package."""
mkdocs-autorefs-1.0.1/tests/conftest.py 0000664 0000000 0000000 00000000057 14570123734 0020170 0 ustar 00root root 0000000 0000000 """Configuration for the pytest test suite."""
mkdocs-autorefs-1.0.1/tests/test_plugin.py 0000664 0000000 0000000 00000005031 14570123734 0020675 0 ustar 00root root 0000000 0000000 """Tests for the plugin module."""
from __future__ import annotations
import pytest
from mkdocs_autorefs.plugin import AutorefsPlugin
def test_url_registration() -> None:
"""Check that URLs can be registered, then obtained."""
plugin = AutorefsPlugin()
plugin.register_anchor(identifier="foo", page="foo1.html")
plugin.register_url(identifier="bar", url="https://example.org/bar.html")
assert plugin.get_item_url("foo") == "foo1.html#foo"
assert plugin.get_item_url("bar") == "https://example.org/bar.html"
with pytest.raises(KeyError):
plugin.get_item_url("baz")
def test_url_registration_with_from_url() -> None:
"""Check that URLs can be registered, then obtained, relative to a page."""
plugin = AutorefsPlugin()
plugin.register_anchor(identifier="foo", page="foo1.html")
plugin.register_url(identifier="bar", url="https://example.org/bar.html")
assert plugin.get_item_url("foo", from_url="a/b.html") == "../foo1.html#foo"
assert plugin.get_item_url("bar", from_url="a/b.html") == "https://example.org/bar.html"
with pytest.raises(KeyError):
plugin.get_item_url("baz", from_url="a/b.html")
def test_url_registration_with_fallback() -> None:
"""Check that URLs can be registered, then obtained through a fallback."""
plugin = AutorefsPlugin()
plugin.register_anchor(identifier="foo", page="foo1.html")
plugin.register_url(identifier="bar", url="https://example.org/bar.html")
# URL map will be updated with baz -> foo1.html#foo
assert plugin.get_item_url("baz", fallback=lambda _: ("foo",)) == "foo1.html#foo"
# as expected, baz is now known as foo1.html#foo
assert plugin.get_item_url("baz", fallback=lambda _: ("bar",)) == "foo1.html#foo"
# unknown identifiers correctly fallback: qux -> https://example.org/bar.html
assert plugin.get_item_url("qux", fallback=lambda _: ("bar",)) == "https://example.org/bar.html"
with pytest.raises(KeyError):
plugin.get_item_url("foobar", fallback=lambda _: ("baaaa",))
with pytest.raises(KeyError):
plugin.get_item_url("foobar", fallback=lambda _: ())
def test_dont_make_relative_urls_relative_again() -> None:
"""Check that URLs are not made relative more than once."""
plugin = AutorefsPlugin()
plugin.register_anchor(identifier="foo.bar.baz", page="foo/bar/baz.html")
for _ in range(2):
assert (
plugin.get_item_url("hello", from_url="baz/bar/foo.html", fallback=lambda _: ("foo.bar.baz",))
== "../../foo/bar/baz.html#foo.bar.baz"
)
mkdocs-autorefs-1.0.1/tests/test_references.py 0000664 0000000 0000000 00000026766 14570123734 0021542 0 ustar 00root root 0000000 0000000 """Tests for the references module."""
from __future__ import annotations
from textwrap import dedent
from typing import Mapping
import markdown
import pytest
from mkdocs_autorefs.plugin import AutorefsPlugin
from mkdocs_autorefs.references import AutorefsExtension, fix_refs, relative_url
@pytest.mark.parametrize(
("current_url", "to_url", "href_url"),
[
("a/", "a#b", "#b"),
("a/", "a/b#c", "b#c"),
("a/b/", "a/b#c", "#c"),
("a/b/", "a/c#d", "../c#d"),
("a/b/", "a#c", "..#c"),
("a/b/c/", "d#e", "../../../d#e"),
("a/b/", "c/d/#e", "../../c/d/#e"),
("a/index.html", "a/index.html#b", "#b"),
("a/index.html", "a/b.html#c", "b.html#c"),
("a/b.html", "a/b.html#c", "#c"),
("a/b.html", "a/c.html#d", "c.html#d"),
("a/b.html", "a/index.html#c", "index.html#c"),
("a/b/c.html", "d.html#e", "../../d.html#e"),
("a/b.html", "c/d.html#e", "../c/d.html#e"),
("a/b/index.html", "a/b/c/d.html#e", "c/d.html#e"),
("", "#x", "#x"),
("a/", "#x", "../#x"),
("a/b.html", "#x", "../#x"),
("", "a/#x", "a/#x"),
("", "a/b.html#x", "a/b.html#x"),
],
)
def test_relative_url(current_url: str, to_url: str, href_url: str) -> None:
"""Compute relative URLs correctly."""
assert relative_url(current_url, to_url) == href_url
def run_references_test(
url_map: dict[str, str],
source: str,
output: str,
unmapped: list[str] | None = None,
from_url: str = "page.html",
extensions: Mapping = {},
) -> None:
"""Help running tests about references.
Arguments:
url_map: The URL mapping.
source: The source text.
output: The expected output.
unmapped: The expected unmapped list.
from_url: The source page URL.
"""
md = markdown.Markdown(extensions=[AutorefsExtension(), *extensions], extension_configs=extensions)
content = md.convert(source)
def url_mapper(identifier: str) -> str:
return relative_url(from_url, url_map[identifier])
actual_output, actual_unmapped = fix_refs(content, url_mapper)
assert actual_output == output
assert actual_unmapped == (unmapped or [])
def test_reference_implicit() -> None:
"""Check implicit references (identifier only)."""
run_references_test(
url_map={"Foo": "foo.html#Foo"},
source="This [Foo][].",
output='This Foo.
',
)
def test_reference_explicit_with_markdown_text() -> None:
"""Check explicit references with Markdown formatting."""
run_references_test(
url_map={"Foo": "foo.html#Foo"},
source="This [**Foo**][Foo].",
output='This Foo.
',
)
def test_reference_implicit_with_code() -> None:
"""Check implicit references (identifier only, wrapped in backticks)."""
run_references_test(
url_map={"Foo": "foo.html#Foo"},
source="This [`Foo`][].",
output='This Foo
.
',
)
def test_reference_implicit_with_code_inlinehilite_plain() -> None:
"""Check implicit references (identifier in backticks, wrapped by inlinehilite)."""
run_references_test(
extensions={"pymdownx.inlinehilite": {}},
url_map={"pathlib.Path": "pathlib.html#Path"},
source="This [`pathlib.Path`][].",
output='This pathlib.Path
.
',
)
def test_reference_implicit_with_code_inlinehilite_python() -> None:
"""Check implicit references (identifier in backticks, syntax-highlighted by inlinehilite)."""
run_references_test(
extensions={"pymdownx.inlinehilite": {"style_plain_text": "python"}, "pymdownx.highlight": {}},
url_map={"pathlib.Path": "pathlib.html#Path"},
source="This [`pathlib.Path`][].",
output='This pathlib.Path
.
',
)
def test_reference_with_punctuation() -> None:
"""Check references with punctuation."""
run_references_test(
url_map={'Foo&"bar': 'foo.html#Foo&"bar'},
source='This [Foo&"bar][].',
output='This Foo&"bar.
',
)
def test_reference_to_relative_path() -> None:
"""Check references from a page at a nested path."""
run_references_test(
from_url="sub/sub/page.html",
url_map={"zz": "foo.html#zz"},
source="This [zz][].",
output='This zz.
',
)
def test_multiline_links() -> None:
"""Check that links with multiline text are recognized."""
run_references_test(
url_map={"foo-bar": "foo.html#bar"},
source="This [Foo\nbar][foo-bar].",
output='This Foo\nbar.
',
)
def test_no_reference_with_space() -> None:
"""Check that references with spaces are not fixed."""
run_references_test(
url_map={"Foo bar": "foo.html#Foo bar"},
source="This [Foo bar][].",
output="This [Foo bar][].
",
)
def test_no_reference_inside_markdown() -> None:
"""Check that references inside code are not fixed."""
run_references_test(
url_map={"Foo": "foo.html#Foo"},
source="This `[Foo][]`.",
output="This [Foo][]
.
",
)
def test_missing_reference() -> None:
"""Check that implicit references are correctly seen as unmapped."""
run_references_test(
url_map={"NotFoo": "foo.html#NotFoo"},
source="[Foo][]",
output="[Foo][]
",
unmapped=["Foo"],
)
def test_missing_reference_with_markdown_text() -> None:
"""Check unmapped explicit references."""
run_references_test(
url_map={"NotFoo": "foo.html#NotFoo"},
source="[`Foo`][Foo]",
output="[Foo
][Foo]
",
unmapped=["Foo"],
)
def test_missing_reference_with_markdown_id() -> None:
"""Check unmapped explicit references with Markdown in the identifier."""
run_references_test(
url_map={"Foo": "foo.html#Foo", "NotFoo": "foo.html#NotFoo"},
source="[Foo][*NotFoo*]",
output="[Foo][*NotFoo*]
",
unmapped=["*NotFoo*"],
)
def test_missing_reference_with_markdown_implicit() -> None:
"""Check that implicit references are not fixed when the identifier is not the exact one."""
run_references_test(
url_map={"Foo-bar": "foo.html#Foo-bar"},
source="[*Foo-bar*][] and [`Foo`-bar][]",
output="[Foo-bar][*Foo-bar*] and [Foo
-bar][]
",
unmapped=["*Foo-bar*"],
)
def test_ignore_reference_with_special_char() -> None:
"""Check that references are not considered if there is a space character inside."""
run_references_test(
url_map={"a b": "foo.html#Foo"},
source="This [*a b*][].",
output="This [a b][].
",
)
def test_custom_required_reference() -> None:
"""Check that external HTML-based references are expanded or reported missing."""
url_map = {"ok": "ok.html#ok"}
source = "foo ok"
output, unmapped = fix_refs(source, url_map.__getitem__)
assert output == '[foo][bar] ok'
assert unmapped == ["bar"]
def test_custom_optional_reference() -> None:
"""Check that optional HTML-based references are expanded and never reported missing."""
url_map = {"ok": "ok.html#ok"}
source = 'foo ok'
output, unmapped = fix_refs(source, url_map.__getitem__)
assert output == 'foo ok'
assert unmapped == []
def test_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 = 'foo ok'
output, unmapped = fix_refs(source, url_map.__getitem__)
assert (
output
== 'foo ok'
)
assert unmapped == []
def test_external_references() -> None:
"""Check that external references are marked as such."""
url_map = {"example": "https://example.com"}
source = 'example'
output, unmapped = fix_refs(source, url_map.__getitem__)
assert output == 'example'
assert unmapped == []
def test_register_markdown_anchors() -> None:
"""Check that Markdown anchors are registered when enabled."""
plugin = AutorefsPlugin()
md = markdown.Markdown(extensions=["attr_list", "toc", AutorefsExtension(plugin)])
plugin.current_page = "page"
md.convert(
dedent(
"""
[](){#foo}
## Heading foo
Paragraph 1.
[](){#bar}
Paragraph 2.
[](){#alias1}
[](){#alias2}
## Heading bar
[](){#alias3}
Text.
[](){#alias4}
## Heading baz
[](){#alias5}
[](){#alias6}
Decoy.
## Heading more1
[](){#alias7}
[decoy](){#alias8}
[](){#alias9}
## Heading more2 {#heading-custom2}
[](){#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",
}
def test_register_markdown_anchors_with_admonition() -> None:
"""Check that Markdown anchors are registered inside a nested admonition element."""
plugin = AutorefsPlugin()
md = markdown.Markdown(extensions=["attr_list", "toc", "admonition", AutorefsExtension(plugin)])
plugin.current_page = "page"
md.convert(
dedent(
"""
[](){#alias1}
!!! note
## Heading foo
[](){#alias2}
## Heading bar
[](){#alias3}
## Heading baz
""",
),
)
assert plugin._url_map == {
"alias1": "page#alias1",
"alias2": "page#heading-bar",
"alias3": "page#alias3",
}
def test_keep_data_attributes() -> None:
"""Keep HTML data attributes from autorefs spans."""
url_map = {"example": "https://e.com"}
source = 'e'
output, _ = fix_refs(source, url_map.__getitem__)
assert output == 'e'