Merge Insiders features · squidfunk/mkdocs-material@764178b
1+# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
2+3+# Permission is hereby granted, free of charge, to any person obtaining a copy
4+# of this software and associated documentation files (the "Software"), to
5+# deal in the Software without restriction, including without limitation the
6+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7+# sell copies of the Software, and to permit persons to whom the Software is
8+# furnished to do so, subject to the following conditions:
9+10+# The above copyright notice and this permission notice shall be included in
11+# all copies or substantial portions of the Software.
12+13+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
16+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
19+# IN THE SOFTWARE.
20+21+from __future__ import annotations
22+23+import logging
24+25+from material.utilities.filter import FileFilter, FilterConfig
26+from mkdocs.structure.pages import _RelativePathTreeprocessor
27+from markdown import Extension, Markdown
28+from markdown.treeprocessors import Treeprocessor
29+from mkdocs.exceptions import ConfigurationError
30+from urllib.parse import urlparse
31+from xml.etree.ElementTree import Element
32+33+# -----------------------------------------------------------------------------
34+# Classes
35+# -----------------------------------------------------------------------------
36+37+class PreviewProcessor(Treeprocessor):
38+"""
39+ A Markdown treeprocessor to enable instant previews on links.
40+41+ Note that this treeprocessor is dependent on the `relpath` treeprocessor
42+ registered programmatically by MkDocs before rendering a page.
43+ """
44+45+def __init__(self, md: Markdown, config: dict):
46+"""
47+ Initialize the treeprocessor.
48+49+ Arguments:
50+ md: The Markdown instance.
51+ config: The configuration.
52+ """
53+super().__init__(md)
54+self.config = config
55+56+def run(self, root: Element):
57+"""
58+ Run the treeprocessor.
59+60+ Arguments:
61+ root: The root element of the parsed Markdown document.
62+ """
63+at = self.md.treeprocessors.get_index_for_name("relpath")
64+65+# Hack: Python Markdown has no notion of where it is, i.e., which file
66+# is being processed. This seems to be a deliberate design decision, as
67+# it is not possible to access the file path of the current page, but
68+# it might also be an oversight that is now impossible to fix. However,
69+# since this extension is only useful in the context of Material for
70+# MkDocs, we can assume that the _RelativePathTreeprocessor is always
71+# present, telling us the file path of the current page. If that ever
72+# changes, we would need to wrap this extension in a plugin, but for
73+# the time being we are sneaky and will probably get away with it.
74+processor = self.md.treeprocessors[at]
75+if not isinstance(processor, _RelativePathTreeprocessor):
76+raise TypeError("Relative path processor not registered")
77+78+# Normalize configurations
79+configurations = self.config["configurations"]
80+configurations.append({
81+"sources": self.config.get("sources"),
82+"targets": self.config.get("targets")
83+ })
84+85+# Walk through all configurations - @todo refactor so that we don't
86+# iterate multiple times over the same elements
87+for configuration in configurations:
88+89+# Skip, if the configuration defines nothing – we could also fix
90+# this in the file filter, but we first fix it here and check if
91+# it generalizes well enough to other inclusion/exclusion sites,
92+# because here, it would hinder the ability to automaticaly
93+# include all sources, while excluding specific targets.
94+if (
95+not configuration.get("sources") and
96+not configuration.get("targets")
97+ ):
98+continue
99+100+# Skip if page should not be considered
101+filter = get_filter(configuration, "sources")
102+if not filter(processor.file):
103+continue
104+105+# Walk through all links and add preview attributes
106+filter = get_filter(configuration, "targets")
107+for el in root.iter("a"):
108+href = el.get("href")
109+if not href:
110+continue
111+112+# Skip footnotes
113+if "footnote-ref" in el.get("class", ""):
114+continue
115+116+# Skip external links
117+url = urlparse(href)
118+if url.scheme or url.netloc:
119+continue
120+121+# Add preview attribute to internal links
122+for path in processor._possible_target_uris(
123+processor.file, url.path,
124+processor.config.use_directory_urls
125+ ):
126+target = processor.files.get_file_from_path(path)
127+if not target:
128+continue
129+130+# Include, if filter matches
131+if filter(target):
132+el.set("data-preview", "")
133+134+# -----------------------------------------------------------------------------
135+136+class PreviewExtension(Extension):
137+"""
138+ A Markdown extension to enable instant previews on links.
139+140+ This extensions allows to automatically add the `data-preview` attribute to
141+ internal links matching specific criteria, so Material for MkDocs renders a
142+ nice preview on hover as part of a tooltip. It is the recommended way to
143+ add previews to links in a programmatic way.
144+ """
145+146+def __init__(self, *args, **kwargs):
147+"""
148+ """
149+self.config = {
150+"configurations": [[], "Filter configurations"],
151+"sources": [{}, "Link sources"],
152+"targets": [{}, "Link targets"]
153+ }
154+super().__init__(*args, **kwargs)
155+156+def extendMarkdown(self, md: Markdown):
157+"""
158+ Register Markdown extension.
159+160+ Arguments:
161+ md: The Markdown instance.
162+ """
163+md.registerExtension(self)
164+165+# Create and register treeprocessor - we use the same priority as the
166+# `relpath` treeprocessor, the latter of which is guaranteed to run
167+# after our treeprocessor, so we can check the original Markdown URIs
168+# before they are resolved to URLs.
169+processor = PreviewProcessor(md, self.getConfigs())
170+md.treeprocessors.register(processor, "preview", 0)
171+172+# -----------------------------------------------------------------------------
173+# Functions
174+# -----------------------------------------------------------------------------
175+176+def get_filter(settings: dict, key: str):
177+"""
178+ Get file filter from settings.
179+180+ Arguments:
181+ settings: The settings.
182+ key: The key in the settings.
183+184+ Returns:
185+ The file filter.
186+ """
187+config = FilterConfig()
188+config.load_dict(settings.get(key) or {})
189+190+# Validate filter configuration
191+errors, warnings = config.validate()
192+for _, w in warnings:
193+log.warning(
194+f"Error reading filter configuration in '{key}':\n"
195+f"{w}"
196+ )
197+for _, e in errors:
198+raise ConfigurationError(
199+f"Error reading filter configuration in '{key}':\n"
200+f"{e}"
201+ )
202+203+# Return file filter
204+return FileFilter(config = config) # type: ignore
205+206+def makeExtension(**kwargs):
207+"""
208+ Register Markdown extension.
209+210+ Arguments:
211+ **kwargs: Configuration options.
212+213+ Returns:
214+ The Markdown extension.
215+ """
216+return PreviewExtension(**kwargs)
217+218+# -----------------------------------------------------------------------------
219+# Data
220+# -----------------------------------------------------------------------------
221+222+# Set up logging
223+log = logging.getLogger("mkdocs.material.extensions.preview")