From 9f8a84172272c104cd765bf1e69bb81ddaef20fd Mon Sep 17 00:00:00 2001 From: Florian Scheuner Date: Sat, 18 Apr 2026 12:37:56 +0300 Subject: [PATCH] fix: inhibit page previews on footnote backrefs --- local_extensions/__init__.py | 11 ++ local_extensions/preview.py | 194 +++++++++++++++++++++++++++++++++++ pyproject.toml | 7 ++ uv.lock | 2 +- zensical.toml | 2 +- 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 local_extensions/__init__.py create mode 100644 local_extensions/preview.py diff --git a/local_extensions/__init__.py b/local_extensions/__init__.py new file mode 100644 index 0000000..7158e78 --- /dev/null +++ b/local_extensions/__init__.py @@ -0,0 +1,11 @@ +from .preview import PreviewExtension + + +def make_extension(**kwargs): + # zensical.toml passes nested settings under [project.markdown_extensions.local_extensions.preview] + return PreviewExtension(**kwargs.get("preview", {})) + + +makeExtension = make_extension + +__all__ = ["makeExtension", "make_extension", "PreviewExtension"] diff --git a/local_extensions/preview.py b/local_extensions/preview.py new file mode 100644 index 0000000..9424d5c --- /dev/null +++ b/local_extensions/preview.py @@ -0,0 +1,194 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import posixpath +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from markdown import Extension, Markdown +from markdown.treeprocessors import Treeprocessor + +from zensical.extensions.links import LinksProcessor +from zensical.extensions.utilities.filter import Filter + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + + +class PreviewProcessor(Treeprocessor): + """A Markdown treeprocessor to enable instant previews on links. + + Note that this treeprocessor is dependent on the `links` treeprocessor + registered programmatically before rendering a page. + """ + + def __init__(self, md: Markdown, config: dict): + """Initialize the treeprocessor.""" + super().__init__(md) + self.config = config + + def run(self, root: Element) -> None: + """Run the treeprocessor.""" + at = self.md.treeprocessors.get_index_for_name("zrelpath") + + # Hack: Python Markdown has no notion of where it is, i.e., which file + # is being processed. This seems to be a deliberate design decision, as + # it is not possible to access the file path of the current page, but + # it might also be an oversight that is now impossible to fix. However, + # since this extension is only useful in the context of Material for + # MkDocs, we can assume that the _RelativePathTreeprocessor is always + # present, telling us the file path of the current page. If that ever + # changes, we would need to wrap this extension in a plugin, but for + # the time being we are sneaky and will probably get away with it. + processor = self.md.treeprocessors[at] + if not isinstance(processor, LinksProcessor): + raise TypeError("Links processor not registered") + + # Normalize configurations + configurations = self.config["configurations"] + configurations.append( + { + "sources": self.config.get("sources"), + "targets": self.config.get("targets"), + } + ) + + # Walk through all configurations - @todo refactor so that we don't + # iterate multiple times over the same elements + for configuration in configurations: + if not configuration.get("sources") and not configuration.get( + "targets" + ): + continue + + # Skip if page should not be considered + filter = get_filter(configuration, "sources") + if not filter(processor.path): + continue + + # Walk through all links and add preview attributes + filter = get_filter(configuration, "targets") + for el in root.iter("a"): + href = el.get("href") + if not href: + continue + + # Skip footnotes + if "footnote-ref" in el.get("class", ""): + continue + if "footnote-backref" in el.get("class", ""): + continue + + # Skip headerlinks + if "headerlink" in el.get("class", ""): + continue + + # Skip external links + url = urlparse(href) + if url.scheme or url.netloc: + continue + + # An empty url.path means we're targetting the current page + url_path = url.path or processor.path + + # Include, if filter matches + path = resolve(processor.path, url_path) + if path and filter(path): + el.set("data-preview", "") + + +# ----------------------------------------------------------------------------- + + +class PreviewExtension(Extension): + """A Markdown extension to enable instant previews on links. + + This extensions allows to automatically add the `data-preview` attribute to + internal links matching specific criteria, so Material for MkDocs renders a + nice preview on hover as part of a tooltip. It is the recommended way to + add previews to links in a programmatic way. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the extension.""" + self.config = { + "configurations": [[], "Filter configurations"], + "sources": [{}, "Link sources"], + "targets": [{}, "Link targets"], + } + super().__init__(*args, **kwargs) + + def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 + """Register Markdown extension.""" + md.registerExtension(self) + + # Create and register treeprocessor - we use the same priority as the + # `relpath` treeprocessor, the latter of which is guaranteed to run + # after our treeprocessor, so we can check the original Markdown URIs + # before they are resolved to URLs. + processor = PreviewProcessor(md, self.getConfigs()) + md.treeprocessors.register(processor, "preview", 0) + + +# ----------------------------------------------------------------------------- +# Functions +# ----------------------------------------------------------------------------- + + +def get_filter(settings: dict, key: str) -> Filter: + """Get file filter from settings.""" + return Filter(config=settings.get(key, {})) + + +def resolve(processor_path: str, url_path: str) -> str: + """Resolve a relative URL path against the processor path.""" + # Remove the file name from the processor path to get the directory + base_path = posixpath.dirname(processor_path) + + # Split the base path and URL path into segments + base_segments = base_path.split("/") + url_segments = url_path.split("/") + + # Process each segment in the URL path + for segment in url_segments: + if segment == "..": + # Remove the last segment from the base path if possible + if base_segments: + base_segments.pop() + elif segment and segment != ".": + # Add non-empty, non-current directory segments + base_segments.append(segment) + + # Join the base segments into the resolved path + return posixpath.join(*base_segments) + + +def makeExtension(**kwargs: Any) -> PreviewExtension: # noqa: N802 + """Register Markdown extension.""" + return PreviewExtension(**kwargs) diff --git a/pyproject.toml b/pyproject.toml index c23a4f4..9200334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + [project] name = "Wiki" version = "0.1.0" @@ -6,6 +10,9 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [] +[tool.setuptools.packages.find] +include = ["local_extensions*"] + [dependency-groups] dev = [ "rumdl>=0.1.72", diff --git a/uv.lock b/uv.lock index f344183..8c57d06 100644 --- a/uv.lock +++ b/uv.lock @@ -127,7 +127,7 @@ wheels = [ [[package]] name = "wiki" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } [package.dev-dependencies] dev = [ diff --git a/zensical.toml b/zensical.toml index 339ecaf..d5ac771 100644 --- a/zensical.toml +++ b/zensical.toml @@ -419,5 +419,5 @@ combine_header_slug = true custom_checkbox = true [project.markdown_extensions.pymdownx.tilde] -[project.markdown_extensions.zensical.extensions.preview] +[project.markdown_extensions.local_extensions.preview] targets.include = ["*"]