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")