diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e3659e..826873b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ 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). +## [0.29.0](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.29.0) - 2025-03-10 + +[Compare with 0.28.3](https://github.com/mkdocstrings/mkdocstrings/compare/0.28.3...0.29.0) + +**This is the last version before v1!** + +### Build + +- Depend on MkDocs 1.6 ([11bc400](https://github.com/mkdocstrings/mkdocstrings/commit/11bc400ab7089a47755f24a790c08f2f904c570b) by Timothée Mazzucotelli). + +### Features + +- Support rendering backlinks through handlers ([d4c7b9c](https://github.com/mkdocstrings/mkdocstrings/commit/d4c7b9c42f2de5df234c1ffefae0405a120e383c) by Timothée Mazzucotelli). [Issue-723](https://github.com/mkdocstrings/mkdocstrings/issues/723), [Issue-mkdocstrings-python-153](https://github.com/mkdocstrings/python/issues/153), [PR-739](https://github.com/mkdocstrings/mkdocstrings/pull/739) + +### Code Refactoring + +- Save and forward titles to autorefs ([f49fb29](https://github.com/mkdocstrings/mkdocstrings/commit/f49fb29582714795ca03febf1ee243aa2992917e) by Timothée Mazzucotelli). +- Use a combined event (each split with a different priority) for `on_env` ([8d1dd75](https://github.com/mkdocstrings/mkdocstrings/commit/8d1dd754b4babd3c4f9e6c1d8856be57fe4ba9ea) by Timothée Mazzucotelli). + ## [0.28.3](https://github.com/mkdocstrings/mkdocstrings/releases/tag/0.28.3) - 2025-03-08 [Compare with 0.28.2](https://github.com/mkdocstrings/mkdocstrings/compare/0.28.2...0.28.3) diff --git a/pyproject.toml b/pyproject.toml index 07dfa630..c3087f61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,8 @@ dependencies = [ "Jinja2>=2.11.1", "Markdown>=3.6", "MarkupSafe>=1.1", - "mkdocs>=1.4", + "mkdocs>=1.6", "mkdocs-autorefs>=1.4", - "mkdocs-get-deps>=0.2", # TODO: Remove when we depend on mkdocs>=1.5. "pymdown-extensions>=6.3", "importlib-metadata>=4.6; python_version < '3.10'", "typing-extensions>=4.1; python_version < '3.10'", diff --git a/src/mkdocstrings/_internal/extension.py b/src/mkdocstrings/_internal/extension.py index 35ceca41..182fc563 100644 --- a/src/mkdocstrings/_internal/extension.py +++ b/src/mkdocstrings/_internal/extension.py @@ -240,54 +240,56 @@ def _process_headings(self, handler: BaseHandler, element: Element) -> None: # If we were in an inner handler layer, we wouldn't do any of this # and would just let headings bubble up to the outer handler layer. - page = self._autorefs.current_page - if page is not None: - for heading in headings: - rendered_id = heading.attrib["id"] - self._autorefs.register_anchor(page, rendered_id, primary=True) - - # Register all identifiers for this object - # both in the autorefs plugin and in the inventory. - aliases: tuple[str, ...] - # YORE: Bump 1: Replace block with line 16. - if hasattr(handler, "get_anchors"): - warn( - "The `get_anchors` method is deprecated. " - "Declare a `get_aliases` method instead, accepting a string (identifier) " - "instead of a collected object.", - DeprecationWarning, - stacklevel=1, - ) - try: - data_object = handler.collect(rendered_id, getattr(handler, "fallback_config", {})) - except CollectionError: - aliases = () - else: - aliases = handler.get_anchors(data_object) + if (page := self._autorefs.current_page) is None: + return + + for heading in headings: + rendered_id = heading.attrib["id"] + # The title is registered to be used as tooltip by autorefs. + self._autorefs.register_anchor(page, rendered_id, title=heading.text, primary=True) + + # Register all identifiers for this object + # both in the autorefs plugin and in the inventory. + aliases: tuple[str, ...] + # YORE: Bump 1: Replace block with line 16. + if hasattr(handler, "get_anchors"): + warn( + "The `get_anchors` method is deprecated. " + "Declare a `get_aliases` method instead, accepting a string (identifier) " + "instead of a collected object.", + DeprecationWarning, + stacklevel=1, + ) + try: + data_object = handler.collect(rendered_id, getattr(handler, "fallback_config", {})) + except CollectionError: + aliases = () else: - aliases = handler.get_aliases(rendered_id) - + aliases = handler.get_anchors(data_object) + else: + aliases = handler.get_aliases(rendered_id) + + for alias in aliases: + if alias != rendered_id: + self._autorefs.register_anchor(page, alias, rendered_id, primary=False) + + if "data-role" in heading.attrib: + self._handlers.inventory.register( + name=rendered_id, + domain=handler.domain, + role=heading.attrib["data-role"], + priority=1, # Register with standard priority. + uri=f"{page.url}#{rendered_id}", + ) for alias in aliases: - if alias != rendered_id: - self._autorefs.register_anchor(page, alias, rendered_id, primary=False) - - if "data-role" in heading.attrib: - self._handlers.inventory.register( - name=rendered_id, - domain=handler.domain, - role=heading.attrib["data-role"], - priority=1, # Register with standard priority. - uri=f"{page}#{rendered_id}", - ) - for alias in aliases: - if alias not in self._handlers.inventory: - self._handlers.inventory.register( - name=alias, - domain=handler.domain, - role=heading.attrib["data-role"], - priority=2, # Register with lower priority. - uri=f"{page}#{rendered_id}", - ) + if alias not in self._handlers.inventory: + self._handlers.inventory.register( + name=alias, + domain=handler.domain, + role=heading.attrib["data-role"], + priority=2, # Register with lower priority. + uri=f"{page.url}#{rendered_id}", + ) class _HeadingsPostProcessor(Treeprocessor): diff --git a/src/mkdocstrings/_internal/handlers/base.py b/src/mkdocstrings/_internal/handlers/base.py index 32a4576e..7784f007 100644 --- a/src/mkdocstrings/_internal/handlers/base.py +++ b/src/mkdocstrings/_internal/handlers/base.py @@ -19,10 +19,8 @@ from markdown import Markdown from markdown.extensions.toc import TocTreeprocessor from markupsafe import Markup -from mkdocs_autorefs import AutorefsInlineProcessor - -# TODO: Replace with `from mkdocs.utils.cache import download_and_cache_url` when we depend on mkdocs>=1.5. -from mkdocs_get_deps.cache import download_and_cache_url +from mkdocs.utils.cache import download_and_cache_url +from mkdocs_autorefs import AutorefsInlineProcessor, BacklinksTreeProcessor from mkdocstrings._internal.download import _download_url_with_gz from mkdocstrings._internal.handlers.rendering import ( @@ -45,7 +43,7 @@ from collections.abc import Iterable, Iterator, Mapping, Sequence from markdown import Extension - from mkdocs_autorefs import AutorefsHookInterface + from mkdocs_autorefs import AutorefsHookInterface, Backlink _logger = get_logger(__name__) @@ -333,6 +331,10 @@ def render(self, data: CollectorItem, options: HandlerOptions) -> str: """ raise NotImplementedError + def render_backlinks(self, backlinks: Mapping[str, Iterable[Backlink]]) -> str: # noqa: ARG002 + """Render backlinks.""" + return "" + def teardown(self) -> None: """Teardown the handler. @@ -411,7 +413,7 @@ def do_convert_markdown( text: The text to convert. heading_level: The base heading level to start all Markdown headings from. html_id: The HTML id of the element that's considered the parent of this element. - strip_paragraph: Whether to exclude the

tag from around the whole output. + strip_paragraph: Whether to exclude the `

` tag from around the whole output. Returns: An HTML string. @@ -422,6 +424,8 @@ def do_convert_markdown( treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = heading_level # type: ignore[attr-defined] treeprocessors[IdPrependingTreeprocessor.name].id_prefix = html_id and html_id + "--" # type: ignore[attr-defined] treeprocessors[ParagraphStrippingTreeprocessor.name].strip = strip_paragraph # type: ignore[attr-defined] + if BacklinksTreeProcessor.name in treeprocessors: + treeprocessors[BacklinksTreeProcessor.name].initial_id = html_id # type: ignore[attr-defined] if autoref_hook: self.md.inlinePatterns[AutorefsInlineProcessor.name].hook = autoref_hook # type: ignore[attr-defined] @@ -432,6 +436,8 @@ def do_convert_markdown( treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0 # type: ignore[attr-defined] treeprocessors[IdPrependingTreeprocessor.name].id_prefix = "" # type: ignore[attr-defined] treeprocessors[ParagraphStrippingTreeprocessor.name].strip = False # type: ignore[attr-defined] + if BacklinksTreeProcessor.name in treeprocessors: + treeprocessors[BacklinksTreeProcessor.name].initial_id = None # type: ignore[attr-defined] self.md.inlinePatterns[AutorefsInlineProcessor.name].hook = None # type: ignore[attr-defined] self.md.reset() _markdown_conversion_layer -= 1 @@ -475,6 +481,8 @@ def do_heading( el.set("data-toc-label", toc_label) if role: el.set("data-role", role) + if content: + el.text = str(content).strip() self._headings.append(el) if hidden: diff --git a/src/mkdocstrings/_internal/handlers/rendering.py b/src/mkdocstrings/_internal/handlers/rendering.py index 23b444ec..25db87e1 100644 --- a/src/mkdocstrings/_internal/handlers/rendering.py +++ b/src/mkdocstrings/_internal/handlers/rendering.py @@ -252,7 +252,7 @@ def run(self, root: Element) -> None: class ParagraphStrippingTreeprocessor(Treeprocessor): - """Unwraps the

element around the whole output.""" + """Unwraps the `

` element around the whole output.""" name: str = "mkdocstrings_strip_paragraph" """The name of the treeprocessor.""" @@ -263,7 +263,7 @@ class ParagraphStrippingTreeprocessor(Treeprocessor): def run(self, root: Element) -> Element | None: """Unwrap the root element if it's a single `

` element.""" if self.strip and len(root) == 1 and root[0].tag == "p": - # Turn the single

element into the root element and inherit its tag name (it's significant!) + # Turn the single `

` element into the root element and inherit its tag name (it's significant!) root[0].tag = root.tag return root[0] return None diff --git a/src/mkdocstrings/_internal/plugin.py b/src/mkdocstrings/_internal/plugin.py index 7cb5b9ad..d7adf1c6 100644 --- a/src/mkdocstrings/_internal/plugin.py +++ b/src/mkdocstrings/_internal/plugin.py @@ -14,13 +14,14 @@ from __future__ import annotations import os -import sys +import re +from re import Match from typing import TYPE_CHECKING, Any from warnings import catch_warnings, simplefilter from mkdocs.config import Config from mkdocs.config import config_options as opt -from mkdocs.plugins import BasePlugin +from mkdocs.plugins import BasePlugin, CombinedEvent, event_priority from mkdocs.utils import write_file from mkdocs_autorefs import AutorefsConfig, AutorefsPlugin @@ -28,14 +29,10 @@ from mkdocstrings._internal.handlers.base import BaseHandler, Handlers from mkdocstrings._internal.loggers import get_logger -if sys.version_info < (3, 10): - pass -else: - pass - if TYPE_CHECKING: from jinja2.environment import Environment from mkdocs.config.defaults import MkDocsConfig + from mkdocs.structure.files import Files _logger = get_logger(__name__) @@ -148,6 +145,7 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None: handlers._download_inventories() + AutorefsPlugin.record_backlinks = True autorefs: AutorefsPlugin try: # If autorefs plugin is explicitly enabled, just use it. @@ -170,6 +168,7 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None: config.extra_css.insert(0, self.css_filename) # So that it has lower priority than user files. + self._autorefs = autorefs self._handlers = handlers return config @@ -194,29 +193,65 @@ def plugin_enabled(self) -> bool: """ return self.config.enabled - def on_env(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 - """Extra actions that need to happen after all Markdown rendering and before HTML rendering. - - Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env). - - - Write mkdocstrings' extra files into the site dir. - - Gather results from background inventory download tasks. - """ - if not self.plugin_enabled: - return + @event_priority(50) # Early, before autorefs' starts applying cross-refs and collecting backlinks. + def _on_env_load_inventories(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 + if self.plugin_enabled and self._handlers: + register = config.plugins["autorefs"].register_url # type: ignore[attr-defined] + for identifier, url in self._handlers._yield_inventory_items(): + register(identifier, url) - if self._handlers: + @event_priority(-20) # Late, not important. + def _on_env_add_css(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 + if self.plugin_enabled and self._handlers: css_content = "\n".join(handler.extra_css for handler in self.handlers.seen_handlers) write_file(css_content.encode("utf-8"), os.path.join(config.site_dir, self.css_filename)) - if self.inventory_enabled: - _logger.debug("Creating inventory file objects.inv") - inv_contents = self.handlers.inventory.format_sphinx() - write_file(inv_contents, os.path.join(config.site_dir, "objects.inv")) + @event_priority(-20) # Late, not important. + def _on_env_write_inventory(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 + if self.plugin_enabled and self._handlers and self.inventory_enabled: + _logger.debug("Creating inventory file objects.inv") + inv_contents = self.handlers.inventory.format_sphinx() + write_file(inv_contents, os.path.join(config.site_dir, "objects.inv")) - register = config.plugins["autorefs"].register_url # type: ignore[attr-defined] - for identifier, url in self._handlers._yield_inventory_items(): - register(identifier, url) + @event_priority(-100) # Last, after autorefs has finished applying cross-refs and collecting backlinks. + def _on_env_apply_backlinks(self, env: Environment, /, *, config: MkDocsConfig, files: Files) -> Environment: # noqa: ARG002 + regex = re.compile(r"") + + def repl(match: Match) -> str: + handler_name = match.group(2) + handler = self.handlers.get_handler(handler_name) + + # The handler doesn't implement backlinks, + # return early to avoid computing them. + if handler.render_backlinks.__func__ is BaseHandler.render_backlinks: # type: ignore[attr-defined] + return "" + + identifier = match.group(1) + aliases = handler.get_aliases(identifier) + backlinks = self._autorefs.get_backlinks(identifier, *aliases, from_url=file.page.url) # type: ignore[union-attr] + + # No backlinks, avoid calling the handler's method. + if not backlinks: + return "" + + return handler.render_backlinks(backlinks) + + for file in files: + if file.page and file.page.content: + _logger.debug("Applying backlinks in page %s", file.page.file.src_path) + file.page.content = regex.sub(repl, file.page.content) + + return env + + on_env = CombinedEvent(_on_env_load_inventories, _on_env_add_css, _on_env_write_inventory, _on_env_apply_backlinks) + """Extra actions that need to happen after all Markdown-to-HTML page rendering. + + Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env). + + - Gather results from background inventory download tasks. + - Write mkdocstrings' extra files (CSS, inventory) into the site directory. + - Apply backlinks to the HTML output of each page. + """ def on_post_build( self,