mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2024-06-14 11:52:32 +03:00
Merged features tied to 'Goat's Horn' funding goal
This commit is contained in:
parent
560bb9035d
commit
f855a67384
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -23,5 +23,5 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="{{ 'assets/javascripts/custom.fe17d8dd.min.js' | url }}"></script>
|
||||
<script src="{{ 'assets/javascripts/custom.2340dcd7.min.js' | url }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -112,7 +112,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
root = posixpath.normpath(self.config.blog_dir)
|
||||
site = config.site_dir
|
||||
|
||||
# Compute path to posts directory
|
||||
# Compute and normalize path to posts directory
|
||||
path = self.config.post_dir.format(blog = root)
|
||||
path = posixpath.normpath(path)
|
||||
|
||||
@ -265,10 +265,11 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
# is not already present, so we can remove footnotes or other content
|
||||
# from the excerpt without affecting the content of the excerpt
|
||||
if separator not in page.markdown:
|
||||
path = page.file.src_path
|
||||
if self.config.post_excerpt == "required":
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(page.file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't find '{separator}' separator in '{path}'"
|
||||
f"Couldn't find '{separator}' in post '{path}' in '{docs}'"
|
||||
)
|
||||
else:
|
||||
page.markdown += f"\n\n{separator}"
|
||||
|
@ -61,7 +61,7 @@ class Post(Page):
|
||||
self.markdown = f.read()
|
||||
|
||||
# Sadly, MkDocs swallows any exceptions that occur during parsing.
|
||||
# As we want to provide the best possible authoring experience, we
|
||||
# Since we want to provide the best possible user experience, we
|
||||
# need to catch errors early and display them nicely. We decided to
|
||||
# drop support for MkDocs' MultiMarkdown syntax, because it is not
|
||||
# correctly implemented anyway. When using MultiMarkdown syntax, all
|
||||
@ -80,7 +80,7 @@ class Post(Page):
|
||||
self.markdown = self.markdown[match.end():].lstrip("\n")
|
||||
|
||||
# The post's metadata could not be parsed because of a syntax error,
|
||||
# which we display to the user with a nice error message
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading metadata of post '{path}' in '{docs}':\n"
|
||||
|
@ -53,10 +53,10 @@ class PostDate(BaseConfigOption[DateDict]):
|
||||
# Normalize the supported types for post dates to datetime
|
||||
def pre_validation(self, config: Config, key_name: str):
|
||||
|
||||
# If the date points to a scalar value, convert it to a dictionary,
|
||||
# since we want to allow the user to specify custom and arbitrary date
|
||||
# values for posts. Currently, only the `created` date is mandatory,
|
||||
# because it's needed to sort posts for views.
|
||||
# If the date points to a scalar value, convert it to a dictionary, as
|
||||
# we want to allow the author to specify custom and arbitrary dates for
|
||||
# posts. Currently, only the `created` date is mandatory, because it's
|
||||
# needed to sort posts for views.
|
||||
if not isinstance(config[key_name], dict):
|
||||
config[key_name] = { "created": config[key_name] }
|
||||
|
||||
|
@ -57,7 +57,7 @@ class InfoPlugin(BasePlugin[InfoConfig]):
|
||||
# Create a self-contained example (run earliest) - determine all files that
|
||||
# are visible to MkDocs and are used to build the site, create an archive
|
||||
# that contains all of them, and print a summary of the archive contents.
|
||||
# The user must attach this archive to the bug report.
|
||||
# The author must attach this archive to the bug report.
|
||||
@event_priority(100)
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
@ -103,10 +103,10 @@ class InfoPlugin(BasePlugin[InfoConfig]):
|
||||
log.error("Please remove 'hooks' setting.")
|
||||
self._help_on_customizations_and_exit()
|
||||
|
||||
# Create in-memory archive and prompt user to enter a short descriptive
|
||||
# Create in-memory archive and prompt author for a short descriptive
|
||||
# name for the archive, which is also used as the directory name. Note
|
||||
# that the name is slugified for better readability and stripped of any
|
||||
# file extension that the user might have entered.
|
||||
# file extension that the author might have entered.
|
||||
archive = BytesIO()
|
||||
example = input("\nPlease name your bug report (2-4 words): ")
|
||||
example, _ = os.path.splitext(example)
|
||||
|
19
material/plugins/privacy/__init__.py
Normal file
19
material/plugins/privacy/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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.
|
43
material/plugins/privacy/config.py
Normal file
43
material/plugins/privacy/config.py
Normal file
@ -0,0 +1,43 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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.
|
||||
|
||||
import os
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import Deprecated, DictOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin configuration
|
||||
class PrivacyConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
|
||||
|
||||
# Settings for caching
|
||||
cache = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/privacy")
|
||||
|
||||
# Settings for external assets
|
||||
assets = Type(bool, default = True)
|
||||
assets_fetch = Type(bool, default = True)
|
||||
assets_fetch_dir = Type(str, default = "assets/external")
|
||||
assets_expr_map = DictOfItems(Type(str), default = {})
|
41
material/plugins/privacy/parser.py
Normal file
41
material/plugins/privacy/parser.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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 html.parser import HTMLParser
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Fragment parser - previously, we used lxml for fault-tolerant HTML5 parsing,
|
||||
# but it blows up the size of the Docker image by 20 MB. We can't just use the
|
||||
# built-in XML parser, as it doesn't handle HTML5 (because, yeah, it's not XML),
|
||||
# so we use a streaming parser and construct the element ourselves.
|
||||
class FragmentParser(HTMLParser):
|
||||
|
||||
# Initialize parser
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs = True)
|
||||
self.result = None
|
||||
|
||||
# Create element
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.result = Element(tag, dict(attrs))
|
550
material/plugins/privacy/plugin.py
Normal file
550
material/plugins/privacy/plugin.py
Normal file
@ -0,0 +1,550 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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 errno
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
|
||||
from colorama import Fore, Style
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
||||
from hashlib import sha1
|
||||
from mkdocs.config.config_options import ExtraScriptValue
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure.files import File, Files
|
||||
from mkdocs.utils import is_error_template
|
||||
from re import Match
|
||||
from urllib.parse import ParseResult as URL, urlparse
|
||||
from xml.etree.ElementTree import Element, tostring
|
||||
|
||||
from .config import PrivacyConfig
|
||||
from .parser import FragmentParser
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin
|
||||
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
|
||||
# Initialize thread pools and asset collections
|
||||
def on_config(self, config):
|
||||
self.site = urlparse(config.site_url or "")
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize thread pool
|
||||
self.pool = ThreadPoolExecutor(self.config.concurrency)
|
||||
self.pool_jobs: list[Future] = []
|
||||
|
||||
# Initialize collections of external assets
|
||||
self.assets = Files([])
|
||||
self.assets_expr_map = dict({
|
||||
".css": r"url\((\s*http?[^)]+)\)",
|
||||
".js": r"[\"'](http[^\"']+\.(?:css|js(?:on)?))[\"']",
|
||||
**self.config.assets_expr_map
|
||||
})
|
||||
|
||||
# Process external style sheets and scripts (run latest) - run this after
|
||||
# all other plugins, so they can add additional assets
|
||||
@event_priority(-100)
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return
|
||||
|
||||
# Find all external style sheet and script files that are provided as
|
||||
# part of the build (= already known to MkDocs on startup)
|
||||
for initiator in files.media_files():
|
||||
file = None
|
||||
|
||||
# Check if the file has dependent external assets that must be
|
||||
# downloaded. Create and enqueue a job for each external asset.
|
||||
for url in self._parse_media(initiator):
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config, concurrent = True)
|
||||
|
||||
# If site URL is not given, ensure that Mermaid.js is always
|
||||
# present. This is a special case, as Material for MkDocs
|
||||
# automatically loads Mermaid.js when a Mermaid diagram is
|
||||
# found in the page - https://bit.ly/36tZXsA.
|
||||
if "mermaid.min.js" in url.path and not config.site_url:
|
||||
path = url.geturl()
|
||||
if path not in config.extra_javascript:
|
||||
config.extra_javascript.append(
|
||||
ExtraScriptValue(path)
|
||||
)
|
||||
|
||||
# The local asset references at least one external asset, which
|
||||
# means we must download and replace them later
|
||||
if file:
|
||||
self.assets.append(initiator)
|
||||
files.remove(initiator)
|
||||
|
||||
# Process external style sheet files
|
||||
for path in config.extra_css:
|
||||
url = urlparse(path)
|
||||
if not self._is_excluded(url):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external script files
|
||||
for script in config.extra_javascript:
|
||||
if isinstance(script, str):
|
||||
script = ExtraScriptValue(script)
|
||||
|
||||
# Enqueue a job if the script needs to downloaded
|
||||
url = urlparse(script.path)
|
||||
if not self._is_excluded(url):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external images in page (run latest) - this stage is the earliest
|
||||
# we can start processing external images, since images are the most common
|
||||
# type of external asset when writing. Thus, we create and enqueue a job for
|
||||
# each image we find that checks if the image needs to be downloaded.
|
||||
@event_priority(-100)
|
||||
def on_page_content(self, html, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return
|
||||
|
||||
# Find all external images and download them if not excluded
|
||||
for match in re.findall(
|
||||
r"<img[^>]+src=['\"]?http[^>]+>",
|
||||
html, flags = re.I | re.M
|
||||
):
|
||||
el = self._parse_fragment(match)
|
||||
|
||||
# Create and enqueue job to fetch external image
|
||||
url = urlparse(el.get("src"))
|
||||
if not self._is_excluded(url, page.file):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external assets in template (run later)
|
||||
@event_priority(-50)
|
||||
def on_post_template(self, output_content, *, template_name, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip sitemap.xml and other non-HTML files
|
||||
if not template_name.endswith(".html"):
|
||||
return
|
||||
|
||||
# Parse and replace links to external assets in template
|
||||
initiator = File(template_name, config.docs_dir, config.site_dir, False)
|
||||
return self._parse_html(output_content, initiator, config)
|
||||
|
||||
# Process external assets in page (run later)
|
||||
@event_priority(-50)
|
||||
def on_post_page(self, output, *, page, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Parse and replace links to external assets
|
||||
return self._parse_html(output, page.file, config)
|
||||
|
||||
# Reconcile jobs (run earlier)
|
||||
@event_priority(50)
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
|
||||
# same thread pool for patching all links to external assets
|
||||
wait(self.pool_jobs)
|
||||
self.pool_jobs.clear()
|
||||
|
||||
# Spawn concurrent job to patch all links to dependent external asset
|
||||
# in all style sheet and script files
|
||||
for file in self.assets:
|
||||
_, extension = posixpath.splitext(file.dest_uri)
|
||||
if extension in [".css", ".js"]:
|
||||
self.pool_jobs.append(self.pool.submit(
|
||||
self._patch, file
|
||||
))
|
||||
|
||||
# Otherwise just copy external asset to output directory
|
||||
else:
|
||||
file.copy_file()
|
||||
|
||||
# Reconcile concurrent jobs for the last time, so the plugins following
|
||||
# in the build process always have a consistent state to work with
|
||||
wait(self.pool_jobs)
|
||||
self.pool.shutdown()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Check if the given URL is external
|
||||
def _is_external(self, url: URL):
|
||||
hostname = url.hostname or self.site.hostname
|
||||
return hostname != self.site.hostname
|
||||
|
||||
# Check if the given URL is excluded
|
||||
def _is_excluded(self, url: URL, initiator: File | None = None):
|
||||
if not self._is_external(url):
|
||||
return True
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return True
|
||||
|
||||
# If initiator is given, format for printing
|
||||
via = ""
|
||||
if initiator:
|
||||
via = "".join([
|
||||
Fore.WHITE, Style.DIM,
|
||||
f"in '{initiator.src_uri}' ",
|
||||
Style.RESET_ALL
|
||||
])
|
||||
|
||||
# Print warning if fetching is not enabled
|
||||
if not self.config.assets_fetch:
|
||||
log.warning(f"External file: {url.geturl()} {via}")
|
||||
return True
|
||||
|
||||
# File is not excluded
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Parse a fragment
|
||||
def _parse_fragment(self, fragment: str):
|
||||
parser = FragmentParser()
|
||||
parser.feed(fragment)
|
||||
parser.close()
|
||||
|
||||
# Return element
|
||||
assert isinstance(parser.result, Element)
|
||||
return parser.result
|
||||
|
||||
# Parse and extract all external assets from a media file using a preset
|
||||
# regular expression, and return all URLs found.
|
||||
def _parse_media(self, initiator: File) -> "list[URL]":
|
||||
_, extension = posixpath.splitext(initiator.dest_uri)
|
||||
if extension not in self.assets_expr_map:
|
||||
return []
|
||||
|
||||
# Find and extract all external asset URLs
|
||||
expr = re.compile(self.assets_expr_map[extension], flags = re.I | re.M)
|
||||
with open(initiator.abs_src_path, encoding = "utf-8") as f:
|
||||
return [urlparse(url) for url in re.findall(expr, f.read())]
|
||||
|
||||
# Parse template or page HTML and find all external links that need to be
|
||||
# replaced. Many of the assets should already be downloaded earlier, i.e.,
|
||||
# everything that was directly referenced in the document, but there may
|
||||
# still exist external assets that were added by third-party plugins.
|
||||
def _parse_html(self, output: str, initiator: File, config: MkDocsConfig):
|
||||
|
||||
# Resolve callback
|
||||
def resolve(file: File):
|
||||
if is_error_template(initiator.src_uri):
|
||||
base = urlparse(config.site_url or "/")
|
||||
return posixpath.join(base.path, file.url)
|
||||
else:
|
||||
return file.url_relative_to(initiator)
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
el = self._parse_fragment(match.group())
|
||||
|
||||
# Handle external style sheet or preconnect hint
|
||||
if el.tag == "link":
|
||||
url = urlparse(el.get("href"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
rel = el.get("rel", "")
|
||||
|
||||
# Replace external preconnect hint
|
||||
if rel == "preconnect":
|
||||
return ""
|
||||
|
||||
# Replace external style sheet or favicon
|
||||
if rel == "stylesheet" or rel == "icon":
|
||||
file = self._queue(url, config)
|
||||
el.set("href", resolve(file))
|
||||
|
||||
# Handle external script or image
|
||||
if el.tag == "script" or el.tag == "img":
|
||||
url = urlparse(el.get("src"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config)
|
||||
el.set("src", resolve(file))
|
||||
|
||||
# Return element as string
|
||||
return self._print(el)
|
||||
|
||||
# Find and replace all external asset URLs in current page
|
||||
return re.sub(
|
||||
r"<(?:(?:a|link)[^>]+href|(?:script|img)[^>]+src)=['\"]?http[^>]+>",
|
||||
replace, output, flags = re.I | re.M
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Print element as string - what could possibly go wrong? We're parsing
|
||||
# HTML5 with an XML parser, and XML doesn't allow for boolean attributes,
|
||||
# which is why we must add a dummy value to all attributes that are not
|
||||
# strings before printing the element as string.
|
||||
def _print(self, el: Element):
|
||||
temp = "__temp__"
|
||||
for name in el.attrib:
|
||||
if not isinstance(el.attrib[name], str):
|
||||
el.attrib[name] = temp
|
||||
|
||||
# Return void or opening tag as string, strip closing tag
|
||||
data = tostring(el, encoding = "unicode")
|
||||
return data.replace(" />", ">").replace(f"\"{temp}\"", "")
|
||||
|
||||
# Enqueue external asset for download, if not already done
|
||||
def _queue(self, url: URL, config: MkDocsConfig, concurrent = False):
|
||||
path = self._path_from_url(url)
|
||||
full = posixpath.join(self.config.assets_fetch_dir, path)
|
||||
|
||||
# Try to retrieve existing file
|
||||
file = self.assets.get_file_from_path(full)
|
||||
if not file:
|
||||
|
||||
# Compute path to external asset, which is sourced from the cache
|
||||
# directory, and generate file to register it with MkDocs as soon
|
||||
# as it was downloaded. This allows other plugins to apply
|
||||
# additional processing.
|
||||
file = self._path_to_file(path, config)
|
||||
file.url = url.geturl()
|
||||
|
||||
# Spawn concurrent job to fetch external asset if the extension is
|
||||
# known and the concurrent flag is set. In that case, this function
|
||||
# is called in a context where no replacements are carried out, so
|
||||
# the caller must only ensure to reconcile the concurrent jobs.
|
||||
_, extension = posixpath.splitext(url.path)
|
||||
if extension and concurrent:
|
||||
self.pool_jobs.append(self.pool.submit(
|
||||
self._fetch, file, config
|
||||
))
|
||||
|
||||
# Fetch external asset synchronously, as it either has no extension
|
||||
# or is fetched from a context in which replacements are done
|
||||
else:
|
||||
self._fetch(file, config)
|
||||
|
||||
# Register external asset as file
|
||||
self.assets.append(file)
|
||||
|
||||
# If the URL of the external asset includes a hash fragment, add it to
|
||||
# the returned file, e.g. for dark/light images - see https://t.ly/7b16Y
|
||||
if url.fragment:
|
||||
file.url += f"#{url.fragment}"
|
||||
|
||||
# Return file associated with external asset
|
||||
return file
|
||||
|
||||
# Fetch external asset referenced through the given file
|
||||
def _fetch(self, file: File, config: MkDocsConfig):
|
||||
|
||||
# Check if external asset needs to be downloaded
|
||||
if not os.path.isfile(file.abs_src_path) or not self.config.cache:
|
||||
path = file.abs_src_path
|
||||
|
||||
# Download external asset
|
||||
log.info(f"Downloading external file: {file.url}")
|
||||
res = requests.get(file.url, headers = {
|
||||
|
||||
# Set user agent explicitly, so Google Fonts gives us *.woff2
|
||||
# files, which according to caniuse.com is the only format we
|
||||
# need to download as it covers the entire range of browsers
|
||||
# we're officially supporting.
|
||||
"User-Agent": " ".join([
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko)",
|
||||
"Chrome/98.0.4758.102 Safari/537.36"
|
||||
])
|
||||
})
|
||||
|
||||
# Compute expected file extension and append if missing
|
||||
mime = res.headers["content-type"].split(";")[0]
|
||||
extension = extensions.get(mime)
|
||||
if extension and not path.endswith(extension):
|
||||
path += extension
|
||||
|
||||
# Save to file and create symlink if no extension was present
|
||||
self._save_to_file(path, res.content)
|
||||
if path != file.abs_src_path:
|
||||
|
||||
# Creating symlinks might fail on Windows. Thus, we just print
|
||||
# a warning and continue - see https://bit.ly/3xYFzcZ
|
||||
try:
|
||||
os.symlink(os.path.basename(path), file.abs_src_path)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EEXIST:
|
||||
log.warning(
|
||||
f"Couldn't create symbolic link: {file.src_uri}"
|
||||
)
|
||||
|
||||
# Fall back for when the symlink could not be created. This
|
||||
# means that the plugin will download the original file on
|
||||
# every build, as the content type cannot be resolved from
|
||||
# the file extension.
|
||||
file.abs_src_path = path
|
||||
|
||||
# Resolve destination if file points to a symlink
|
||||
_, extension = os.path.splitext(file.abs_src_path)
|
||||
if os.path.isfile(file.abs_src_path):
|
||||
file.abs_src_path = os.path.realpath(file.abs_src_path)
|
||||
_, extension = os.path.splitext(file.abs_src_path)
|
||||
|
||||
# If the symlink could not be created, we already set the correct
|
||||
# extension, so we need to make sure not to append it again
|
||||
if not file.abs_dest_path.endswith(extension):
|
||||
file.src_uri += extension
|
||||
|
||||
# Compute destination file system path
|
||||
file.dest_uri += extension
|
||||
file.abs_dest_path += extension
|
||||
|
||||
# Compute destination URL
|
||||
file.url = file.dest_uri
|
||||
|
||||
# Parse and enqueue dependent external assets
|
||||
for url in self._parse_media(file):
|
||||
if not self._is_excluded(url, file):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Patch all links to external assets in the given file
|
||||
def _patch(self, initiator: File):
|
||||
with open(initiator.abs_src_path, encoding = "utf-8") as f:
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
value = match.group(1)
|
||||
|
||||
# Map URL to canonical path
|
||||
path = self._path_from_url(urlparse(value))
|
||||
full = posixpath.join(self.config.assets_fetch_dir, path)
|
||||
|
||||
# Try to retrieve existing file
|
||||
file = self.assets.get_file_from_path(full)
|
||||
if not file:
|
||||
name = os.readlink(os.path.join(self.config.cache_dir, full))
|
||||
full = posixpath.join(posixpath.dirname(full), name)
|
||||
|
||||
# Try again after resolving symlink
|
||||
file = self.assets.get_file_from_path(full)
|
||||
|
||||
# This can theoretically never happen, as we're sure that we
|
||||
# only replace files that we successfully extracted. However,
|
||||
# we might have missed several cases, so it's better to throw
|
||||
# here than to swallow the error.
|
||||
if not file:
|
||||
log.error(
|
||||
"File not found. This is likely a bug in the built-in "
|
||||
"privacy plugin. Please create an issue with a minimal "
|
||||
"reproduction."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Create absolute URL for asset in script
|
||||
if file.url.endswith(".js"):
|
||||
url = posixpath.join(self.site.geturl(), file.url)
|
||||
|
||||
# Create relative URL for everything else
|
||||
else:
|
||||
url = file.url_relative_to(initiator)
|
||||
|
||||
# Switch external asset URL to local path
|
||||
return match.group().replace(value, url)
|
||||
|
||||
# Resolve replacement expression according to asset type
|
||||
_, extension = posixpath.splitext(initiator.dest_uri)
|
||||
expr = re.compile(self.assets_expr_map[extension], re.I | re.M)
|
||||
|
||||
# Resolve links to external assets in file
|
||||
self._save_to_file(
|
||||
initiator.abs_dest_path,
|
||||
expr.sub(replace, f.read())
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Normalize (= canonicalize) path by removing trailing slashes, and ensure
|
||||
# that hidden folders (`.` after `/`) are unhidden. Otherwise MkDocs will
|
||||
# not consider them being part of the build and refuse to copy them.
|
||||
def _path_from_url(self, url: URL):
|
||||
path = posixpath.normpath(url.path)
|
||||
path = re.sub(r"/\.", "/_", path)
|
||||
|
||||
# Compute digest of query string, as some URLs yield different results
|
||||
# for different query strings, e.g. https://unsplash.com/random?Coffee
|
||||
if url.query:
|
||||
name, extension = posixpath.splitext(path)
|
||||
|
||||
# Inject digest after file name and before file extension, as
|
||||
# done for style sheet and script files as well
|
||||
digest = sha1(url.query.encode("utf-8")).hexdigest()[:8]
|
||||
path = f"{name}.{digest}{extension}"
|
||||
|
||||
# Create and return URL without leading double slashes
|
||||
url = url._replace(scheme = "", query = "", fragment = "", path = path)
|
||||
return url.geturl()[2:]
|
||||
|
||||
# Create a file for the given path
|
||||
def _path_to_file(self, path: str, config: MkDocsConfig):
|
||||
return File(
|
||||
posixpath.join(self.config.assets_fetch_dir, path),
|
||||
os.path.abspath(self.config.cache_dir),
|
||||
config.site_dir,
|
||||
False
|
||||
)
|
||||
|
||||
# Create a file on the system with the given content
|
||||
def _save_to_file(self, path: str, content: str | bytes):
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
if isinstance(content, str):
|
||||
content = bytes(content, "utf-8")
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.privacy")
|
||||
|
||||
# Expected file extensions
|
||||
extensions = dict({
|
||||
"application/javascript": ".js",
|
||||
"image/avif": ".avif",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"text/javascript": ".js",
|
||||
"text/css": ".css"
|
||||
})
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
29
material/templates/assets/javascripts/bundle.d7c377c4.min.js
vendored
Normal file
29
material/templates/assets/javascripts/bundle.d7c377c4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
material/templates/assets/stylesheets/main.45e1311d.min.css
vendored
Normal file
1
material/templates/assets/stylesheets/main.45e1311d.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
material/templates/assets/stylesheets/palette.06af60db.min.css
vendored
Normal file
1
material/templates/assets/stylesheets/palette.06af60db.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1 @@
|
||||
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"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","file":"palette.css"}
|
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"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","file":"palette.css"}
|
@ -44,10 +44,10 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.485ae1df.min.css' | url }}">
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.45e1311d.min.css' | url }}">
|
||||
{% if config.theme.palette %}
|
||||
{% set palette = config.theme.palette %}
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.356b1318.min.css' | url }}">
|
||||
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.06af60db.min.css' | url }}">
|
||||
{% endif %}
|
||||
{% include "partials/icons.html" %}
|
||||
{% endblock %}
|
||||
@ -96,9 +96,6 @@
|
||||
<body dir="{{ direction }}">
|
||||
{% endif %}
|
||||
{% set features = config.theme.features or [] %}
|
||||
{% if not config.theme.palette is mapping %}
|
||||
{% include "partials/javascripts/palette.html" %}
|
||||
{% endif %}
|
||||
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
|
||||
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
|
||||
<label class="md-overlay" for="__drawer"></label>
|
||||
@ -252,7 +249,7 @@
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ 'assets/javascripts/bundle.cd18aaf1.min.js' | url }}"></script>
|
||||
<script src="{{ 'assets/javascripts/bundle.d7c377c4.min.js' | url }}"></script>
|
||||
{% for script in config.extra_javascript %}
|
||||
{{ script | script_tag }}
|
||||
{% endfor %}
|
||||
|
@ -9,11 +9,6 @@
|
||||
<h1>{{ page.title | d(config.site_name, true)}}</h1>
|
||||
{% endif %}
|
||||
{{ page.content }}
|
||||
{% if page.meta and (
|
||||
page.meta.git_revision_date_localized or
|
||||
page.meta.revision_date
|
||||
) %}
|
||||
{% include "partials/source-file.html" %}
|
||||
{% endif %}
|
||||
{% include "partials/source-file.html" %}
|
||||
{% include "partials/feedback.html" %}
|
||||
{% include "partials/comments.html" %}
|
||||
|
@ -39,6 +39,9 @@
|
||||
{% include "partials/palette.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not config.theme.palette is mapping %}
|
||||
{% include "partials/javascripts/palette.html" %}
|
||||
{% endif %}
|
||||
{% if config.extra.alternate %}
|
||||
{% include "partials/alternate.html" %}
|
||||
{% endif %}
|
||||
|
@ -4,3 +4,4 @@
|
||||
{% if "content.tabs.link" in features %}
|
||||
<script>var tabs=__md_get("__tabs");if(Array.isArray(tabs))e:for(var set of document.querySelectorAll(".tabbed-set")){var tab,labels=set.querySelector(".tabbed-labels");for(tab of tabs)for(var label of labels.getElementsByTagName("label"))if(label.innerText.trim()===tab){var input=document.getElementById(label.htmlFor);input.checked=!0;continue e}}</script>
|
||||
{% endif %}
|
||||
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
|
||||
|
@ -1,4 +1,4 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
<script>var palette=__md_get("__palette");if(palette&&"object"==typeof palette.color)for(var key of Object.keys(palette.color))document.body.setAttribute("data-md-color-"+key,palette.color[key])</script>
|
||||
<script>var media,input,key,value,palette=__md_get("__palette");if(palette&&palette.color){"(prefers-color-scheme)"===palette.color.media&&(media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']"),palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent"));for([key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
|
||||
|
@ -6,9 +6,9 @@
|
||||
{% set scheme = option.scheme | d("default", true) %}
|
||||
{% set primary = option.primary | d("indigo", true) %}
|
||||
{% set accent = option.accent | d("indigo", true) %}
|
||||
<input class="md-option" data-md-color-media="{{ option.media }}" data-md-color-scheme="{{ scheme | replace(' ', '-') }}" data-md-color-primary="{{ primary | replace(' ', '-') }}" data-md-color-accent="{{ accent | replace(' ', '-') }}" {% if option.toggle %} aria-label="{{ option.toggle.name }}" {% else %} aria-hidden="true" {% endif %} type="radio" name="__palette" id="__palette_{{ loop.index }}">
|
||||
<input class="md-option" data-md-color-media="{{ option.media }}" data-md-color-scheme="{{ scheme | replace(' ', '-') }}" data-md-color-primary="{{ primary | replace(' ', '-') }}" data-md-color-accent="{{ accent | replace(' ', '-') }}" {% if option.toggle %} aria-label="{{ option.toggle.name }}" {% else %} aria-hidden="true" {% endif %} type="radio" name="__palette" id="__palette_{{ loop.index0 }}">
|
||||
{% if option.toggle %}
|
||||
<label class="md-header__button md-icon" title="{{ option.toggle.name }}" for="__palette_{{ loop.index0 or loop.length }}" hidden>
|
||||
<label class="md-header__button md-icon" title="{{ option.toggle.name }}" for="__palette_{{ loop.index % loop.length }}" hidden>
|
||||
{% include ".icons/" ~ option.toggle.icon ~ ".svg" %}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
@ -1,20 +1,81 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
<hr>
|
||||
<div class="md-source-file">
|
||||
<small>
|
||||
{% if page.meta.git_revision_date_localized %}
|
||||
{{ lang.t("source.file.date.updated") }}:
|
||||
{{ page.meta.git_revision_date_localized }}
|
||||
{% if page.meta.git_creation_date_localized %}
|
||||
<br>
|
||||
{{ lang.t("source.file.date.created") }}:
|
||||
{{ page.meta.git_creation_date_localized }}
|
||||
{% endif %}
|
||||
{% elif page.meta.revision_date %}
|
||||
{{ lang.t("source.file.date.updated") }}:
|
||||
{{ page.meta.revision_date }}
|
||||
{% if page.meta %}
|
||||
{% if page.meta.git_revision_date_localized %}
|
||||
{% set updated = page.meta.git_revision_date_localized %}
|
||||
{% elif page.meta.revision_date %}
|
||||
{% set updated = page.meta.revision_date %}
|
||||
{% endif %}
|
||||
{% if page.meta.git_creation_date_localized %}
|
||||
{% set created = page.meta.git_creation_date_localized %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if updated or created or git_info or committers %}
|
||||
<aside class="md-source-file">
|
||||
{% if updated %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.date.updated') }}">
|
||||
{% include ".icons/material/clock-edit-outline.svg" %}
|
||||
</span>
|
||||
{{ updated }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% if created %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.date.created') }}">
|
||||
{% include ".icons/material/clock-plus-outline.svg" %}
|
||||
</span>
|
||||
{{ created }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if git_info %}
|
||||
{% set authors = git_info.get("page_authors") %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.contributors') }}">
|
||||
{% if authors | length == 1 %}
|
||||
{% include ".icons/material/account.svg" %}
|
||||
{% else %}
|
||||
{% include ".icons/material/account-group.svg" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<nav>
|
||||
{% for author in authors %}
|
||||
<a href="mailto:{{ author.email }}">
|
||||
{{- author.name -}}
|
||||
</a>
|
||||
{%- if loop.revindex > 1 %}, {% endif -%}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if committers %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.contributors') }}">
|
||||
{% include ".icons/material/github.svg" %}
|
||||
</span>
|
||||
<span>GitHub</span>
|
||||
<nav>
|
||||
{% for author in committers[:4] %}
|
||||
<a href="{{ author.url }}" class="md-author" title="@{{ author.login }}">
|
||||
{% set separator = "&" if "?" in author.avatar else "?" %}
|
||||
<img src="{{ author.avatar }}{{ separator }}size=72" alt="{{ author.name or 'GitHub user' }}">
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% set more = committers[4:] | length %}
|
||||
{% if more > 0 %}
|
||||
{% if page.edit_url %}
|
||||
<a href="{{ page.edit_url | replace('edit', 'blob') }}" class="md-author md-author--more">
|
||||
+{{ more }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="md-author md-author--more">
|
||||
+{{ more }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
</span>
|
||||
{% endif %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
21
mkdocs.yml
21
mkdocs.yml
@ -65,18 +65,24 @@ theme:
|
||||
- toc.follow
|
||||
# - toc.integrate
|
||||
palette:
|
||||
- scheme: default
|
||||
- media: "(prefers-color-scheme)"
|
||||
toggle:
|
||||
icon: material/link
|
||||
name: Switch to light mode
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
icon: material/toggle-switch
|
||||
name: Switch to dark mode
|
||||
- scheme: slate
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: black
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
icon: material/toggle-switch-off
|
||||
name: Switch to system preference
|
||||
font:
|
||||
text: Roboto
|
||||
code: Roboto Mono
|
||||
@ -99,8 +105,9 @@ hooks:
|
||||
|
||||
# Additional configuration
|
||||
extra:
|
||||
annotate:
|
||||
json: [.s2]
|
||||
status:
|
||||
new: Recently added
|
||||
deprecated: Deprecated
|
||||
analytics:
|
||||
provider: google
|
||||
property: !ENV GOOGLE_ANALYTICS_KEY
|
||||
|
@ -112,7 +112,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
root = posixpath.normpath(self.config.blog_dir)
|
||||
site = config.site_dir
|
||||
|
||||
# Compute path to posts directory
|
||||
# Compute and normalize path to posts directory
|
||||
path = self.config.post_dir.format(blog = root)
|
||||
path = posixpath.normpath(path)
|
||||
|
||||
@ -265,10 +265,11 @@ class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
# is not already present, so we can remove footnotes or other content
|
||||
# from the excerpt without affecting the content of the excerpt
|
||||
if separator not in page.markdown:
|
||||
path = page.file.src_path
|
||||
if self.config.post_excerpt == "required":
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(page.file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't find '{separator}' separator in '{path}'"
|
||||
f"Couldn't find '{separator}' in post '{path}' in '{docs}'"
|
||||
)
|
||||
else:
|
||||
page.markdown += f"\n\n{separator}"
|
||||
|
@ -61,7 +61,7 @@ class Post(Page):
|
||||
self.markdown = f.read()
|
||||
|
||||
# Sadly, MkDocs swallows any exceptions that occur during parsing.
|
||||
# As we want to provide the best possible authoring experience, we
|
||||
# Since we want to provide the best possible user experience, we
|
||||
# need to catch errors early and display them nicely. We decided to
|
||||
# drop support for MkDocs' MultiMarkdown syntax, because it is not
|
||||
# correctly implemented anyway. When using MultiMarkdown syntax, all
|
||||
@ -80,7 +80,7 @@ class Post(Page):
|
||||
self.markdown = self.markdown[match.end():].lstrip("\n")
|
||||
|
||||
# The post's metadata could not be parsed because of a syntax error,
|
||||
# which we display to the user with a nice error message
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading metadata of post '{path}' in '{docs}':\n"
|
||||
|
@ -53,10 +53,10 @@ class PostDate(BaseConfigOption[DateDict]):
|
||||
# Normalize the supported types for post dates to datetime
|
||||
def pre_validation(self, config: Config, key_name: str):
|
||||
|
||||
# If the date points to a scalar value, convert it to a dictionary,
|
||||
# since we want to allow the user to specify custom and arbitrary date
|
||||
# values for posts. Currently, only the `created` date is mandatory,
|
||||
# because it's needed to sort posts for views.
|
||||
# If the date points to a scalar value, convert it to a dictionary, as
|
||||
# we want to allow the author to specify custom and arbitrary dates for
|
||||
# posts. Currently, only the `created` date is mandatory, because it's
|
||||
# needed to sort posts for views.
|
||||
if not isinstance(config[key_name], dict):
|
||||
config[key_name] = { "created": config[key_name] }
|
||||
|
||||
|
@ -57,7 +57,7 @@ class InfoPlugin(BasePlugin[InfoConfig]):
|
||||
# Create a self-contained example (run earliest) - determine all files that
|
||||
# are visible to MkDocs and are used to build the site, create an archive
|
||||
# that contains all of them, and print a summary of the archive contents.
|
||||
# The user must attach this archive to the bug report.
|
||||
# The author must attach this archive to the bug report.
|
||||
@event_priority(100)
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
@ -103,10 +103,10 @@ class InfoPlugin(BasePlugin[InfoConfig]):
|
||||
log.error("Please remove 'hooks' setting.")
|
||||
self._help_on_customizations_and_exit()
|
||||
|
||||
# Create in-memory archive and prompt user to enter a short descriptive
|
||||
# Create in-memory archive and prompt author for a short descriptive
|
||||
# name for the archive, which is also used as the directory name. Note
|
||||
# that the name is slugified for better readability and stripped of any
|
||||
# file extension that the user might have entered.
|
||||
# file extension that the author might have entered.
|
||||
archive = BytesIO()
|
||||
example = input("\nPlease name your bug report (2-4 words): ")
|
||||
example, _ = os.path.splitext(example)
|
||||
|
19
src/plugins/privacy/__init__.py
Normal file
19
src/plugins/privacy/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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.
|
43
src/plugins/privacy/config.py
Normal file
43
src/plugins/privacy/config.py
Normal file
@ -0,0 +1,43 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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.
|
||||
|
||||
import os
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import Deprecated, DictOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin configuration
|
||||
class PrivacyConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
|
||||
|
||||
# Settings for caching
|
||||
cache = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/privacy")
|
||||
|
||||
# Settings for external assets
|
||||
assets = Type(bool, default = True)
|
||||
assets_fetch = Type(bool, default = True)
|
||||
assets_fetch_dir = Type(str, default = "assets/external")
|
||||
assets_expr_map = DictOfItems(Type(str), default = {})
|
41
src/plugins/privacy/parser.py
Normal file
41
src/plugins/privacy/parser.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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 html.parser import HTMLParser
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Fragment parser - previously, we used lxml for fault-tolerant HTML5 parsing,
|
||||
# but it blows up the size of the Docker image by 20 MB. We can't just use the
|
||||
# built-in XML parser, as it doesn't handle HTML5 (because, yeah, it's not XML),
|
||||
# so we use a streaming parser and construct the element ourselves.
|
||||
class FragmentParser(HTMLParser):
|
||||
|
||||
# Initialize parser
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs = True)
|
||||
self.result = None
|
||||
|
||||
# Create element
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.result = Element(tag, dict(attrs))
|
550
src/plugins/privacy/plugin.py
Normal file
550
src/plugins/privacy/plugin.py
Normal file
@ -0,0 +1,550 @@
|
||||
# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# 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 errno
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
|
||||
from colorama import Fore, Style
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
||||
from hashlib import sha1
|
||||
from mkdocs.config.config_options import ExtraScriptValue
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure.files import File, Files
|
||||
from mkdocs.utils import is_error_template
|
||||
from re import Match
|
||||
from urllib.parse import ParseResult as URL, urlparse
|
||||
from xml.etree.ElementTree import Element, tostring
|
||||
|
||||
from .config import PrivacyConfig
|
||||
from .parser import FragmentParser
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin
|
||||
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
|
||||
# Initialize thread pools and asset collections
|
||||
def on_config(self, config):
|
||||
self.site = urlparse(config.site_url or "")
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize thread pool
|
||||
self.pool = ThreadPoolExecutor(self.config.concurrency)
|
||||
self.pool_jobs: list[Future] = []
|
||||
|
||||
# Initialize collections of external assets
|
||||
self.assets = Files([])
|
||||
self.assets_expr_map = dict({
|
||||
".css": r"url\((\s*http?[^)]+)\)",
|
||||
".js": r"[\"'](http[^\"']+\.(?:css|js(?:on)?))[\"']",
|
||||
**self.config.assets_expr_map
|
||||
})
|
||||
|
||||
# Process external style sheets and scripts (run latest) - run this after
|
||||
# all other plugins, so they can add additional assets
|
||||
@event_priority(-100)
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return
|
||||
|
||||
# Find all external style sheet and script files that are provided as
|
||||
# part of the build (= already known to MkDocs on startup)
|
||||
for initiator in files.media_files():
|
||||
file = None
|
||||
|
||||
# Check if the file has dependent external assets that must be
|
||||
# downloaded. Create and enqueue a job for each external asset.
|
||||
for url in self._parse_media(initiator):
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config, concurrent = True)
|
||||
|
||||
# If site URL is not given, ensure that Mermaid.js is always
|
||||
# present. This is a special case, as Material for MkDocs
|
||||
# automatically loads Mermaid.js when a Mermaid diagram is
|
||||
# found in the page - https://bit.ly/36tZXsA.
|
||||
if "mermaid.min.js" in url.path and not config.site_url:
|
||||
path = url.geturl()
|
||||
if path not in config.extra_javascript:
|
||||
config.extra_javascript.append(
|
||||
ExtraScriptValue(path)
|
||||
)
|
||||
|
||||
# The local asset references at least one external asset, which
|
||||
# means we must download and replace them later
|
||||
if file:
|
||||
self.assets.append(initiator)
|
||||
files.remove(initiator)
|
||||
|
||||
# Process external style sheet files
|
||||
for path in config.extra_css:
|
||||
url = urlparse(path)
|
||||
if not self._is_excluded(url):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external script files
|
||||
for script in config.extra_javascript:
|
||||
if isinstance(script, str):
|
||||
script = ExtraScriptValue(script)
|
||||
|
||||
# Enqueue a job if the script needs to downloaded
|
||||
url = urlparse(script.path)
|
||||
if not self._is_excluded(url):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external images in page (run latest) - this stage is the earliest
|
||||
# we can start processing external images, since images are the most common
|
||||
# type of external asset when writing. Thus, we create and enqueue a job for
|
||||
# each image we find that checks if the image needs to be downloaded.
|
||||
@event_priority(-100)
|
||||
def on_page_content(self, html, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return
|
||||
|
||||
# Find all external images and download them if not excluded
|
||||
for match in re.findall(
|
||||
r"<img[^>]+src=['\"]?http[^>]+>",
|
||||
html, flags = re.I | re.M
|
||||
):
|
||||
el = self._parse_fragment(match)
|
||||
|
||||
# Create and enqueue job to fetch external image
|
||||
url = urlparse(el.get("src"))
|
||||
if not self._is_excluded(url, page.file):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external assets in template (run later)
|
||||
@event_priority(-50)
|
||||
def on_post_template(self, output_content, *, template_name, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip sitemap.xml and other non-HTML files
|
||||
if not template_name.endswith(".html"):
|
||||
return
|
||||
|
||||
# Parse and replace links to external assets in template
|
||||
initiator = File(template_name, config.docs_dir, config.site_dir, False)
|
||||
return self._parse_html(output_content, initiator, config)
|
||||
|
||||
# Process external assets in page (run later)
|
||||
@event_priority(-50)
|
||||
def on_post_page(self, output, *, page, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Parse and replace links to external assets
|
||||
return self._parse_html(output, page.file, config)
|
||||
|
||||
# Reconcile jobs (run earlier)
|
||||
@event_priority(50)
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
|
||||
# same thread pool for patching all links to external assets
|
||||
wait(self.pool_jobs)
|
||||
self.pool_jobs.clear()
|
||||
|
||||
# Spawn concurrent job to patch all links to dependent external asset
|
||||
# in all style sheet and script files
|
||||
for file in self.assets:
|
||||
_, extension = posixpath.splitext(file.dest_uri)
|
||||
if extension in [".css", ".js"]:
|
||||
self.pool_jobs.append(self.pool.submit(
|
||||
self._patch, file
|
||||
))
|
||||
|
||||
# Otherwise just copy external asset to output directory
|
||||
else:
|
||||
file.copy_file()
|
||||
|
||||
# Reconcile concurrent jobs for the last time, so the plugins following
|
||||
# in the build process always have a consistent state to work with
|
||||
wait(self.pool_jobs)
|
||||
self.pool.shutdown()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Check if the given URL is external
|
||||
def _is_external(self, url: URL):
|
||||
hostname = url.hostname or self.site.hostname
|
||||
return hostname != self.site.hostname
|
||||
|
||||
# Check if the given URL is excluded
|
||||
def _is_excluded(self, url: URL, initiator: File | None = None):
|
||||
if not self._is_external(url):
|
||||
return True
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return True
|
||||
|
||||
# If initiator is given, format for printing
|
||||
via = ""
|
||||
if initiator:
|
||||
via = "".join([
|
||||
Fore.WHITE, Style.DIM,
|
||||
f"in '{initiator.src_uri}' ",
|
||||
Style.RESET_ALL
|
||||
])
|
||||
|
||||
# Print warning if fetching is not enabled
|
||||
if not self.config.assets_fetch:
|
||||
log.warning(f"External file: {url.geturl()} {via}")
|
||||
return True
|
||||
|
||||
# File is not excluded
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Parse a fragment
|
||||
def _parse_fragment(self, fragment: str):
|
||||
parser = FragmentParser()
|
||||
parser.feed(fragment)
|
||||
parser.close()
|
||||
|
||||
# Return element
|
||||
assert isinstance(parser.result, Element)
|
||||
return parser.result
|
||||
|
||||
# Parse and extract all external assets from a media file using a preset
|
||||
# regular expression, and return all URLs found.
|
||||
def _parse_media(self, initiator: File) -> "list[URL]":
|
||||
_, extension = posixpath.splitext(initiator.dest_uri)
|
||||
if extension not in self.assets_expr_map:
|
||||
return []
|
||||
|
||||
# Find and extract all external asset URLs
|
||||
expr = re.compile(self.assets_expr_map[extension], flags = re.I | re.M)
|
||||
with open(initiator.abs_src_path, encoding = "utf-8") as f:
|
||||
return [urlparse(url) for url in re.findall(expr, f.read())]
|
||||
|
||||
# Parse template or page HTML and find all external links that need to be
|
||||
# replaced. Many of the assets should already be downloaded earlier, i.e.,
|
||||
# everything that was directly referenced in the document, but there may
|
||||
# still exist external assets that were added by third-party plugins.
|
||||
def _parse_html(self, output: str, initiator: File, config: MkDocsConfig):
|
||||
|
||||
# Resolve callback
|
||||
def resolve(file: File):
|
||||
if is_error_template(initiator.src_uri):
|
||||
base = urlparse(config.site_url or "/")
|
||||
return posixpath.join(base.path, file.url)
|
||||
else:
|
||||
return file.url_relative_to(initiator)
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
el = self._parse_fragment(match.group())
|
||||
|
||||
# Handle external style sheet or preconnect hint
|
||||
if el.tag == "link":
|
||||
url = urlparse(el.get("href"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
rel = el.get("rel", "")
|
||||
|
||||
# Replace external preconnect hint
|
||||
if rel == "preconnect":
|
||||
return ""
|
||||
|
||||
# Replace external style sheet or favicon
|
||||
if rel == "stylesheet" or rel == "icon":
|
||||
file = self._queue(url, config)
|
||||
el.set("href", resolve(file))
|
||||
|
||||
# Handle external script or image
|
||||
if el.tag == "script" or el.tag == "img":
|
||||
url = urlparse(el.get("src"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config)
|
||||
el.set("src", resolve(file))
|
||||
|
||||
# Return element as string
|
||||
return self._print(el)
|
||||
|
||||
# Find and replace all external asset URLs in current page
|
||||
return re.sub(
|
||||
r"<(?:(?:a|link)[^>]+href|(?:script|img)[^>]+src)=['\"]?http[^>]+>",
|
||||
replace, output, flags = re.I | re.M
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Print element as string - what could possibly go wrong? We're parsing
|
||||
# HTML5 with an XML parser, and XML doesn't allow for boolean attributes,
|
||||
# which is why we must add a dummy value to all attributes that are not
|
||||
# strings before printing the element as string.
|
||||
def _print(self, el: Element):
|
||||
temp = "__temp__"
|
||||
for name in el.attrib:
|
||||
if not isinstance(el.attrib[name], str):
|
||||
el.attrib[name] = temp
|
||||
|
||||
# Return void or opening tag as string, strip closing tag
|
||||
data = tostring(el, encoding = "unicode")
|
||||
return data.replace(" />", ">").replace(f"\"{temp}\"", "")
|
||||
|
||||
# Enqueue external asset for download, if not already done
|
||||
def _queue(self, url: URL, config: MkDocsConfig, concurrent = False):
|
||||
path = self._path_from_url(url)
|
||||
full = posixpath.join(self.config.assets_fetch_dir, path)
|
||||
|
||||
# Try to retrieve existing file
|
||||
file = self.assets.get_file_from_path(full)
|
||||
if not file:
|
||||
|
||||
# Compute path to external asset, which is sourced from the cache
|
||||
# directory, and generate file to register it with MkDocs as soon
|
||||
# as it was downloaded. This allows other plugins to apply
|
||||
# additional processing.
|
||||
file = self._path_to_file(path, config)
|
||||
file.url = url.geturl()
|
||||
|
||||
# Spawn concurrent job to fetch external asset if the extension is
|
||||
# known and the concurrent flag is set. In that case, this function
|
||||
# is called in a context where no replacements are carried out, so
|
||||
# the caller must only ensure to reconcile the concurrent jobs.
|
||||
_, extension = posixpath.splitext(url.path)
|
||||
if extension and concurrent:
|
||||
self.pool_jobs.append(self.pool.submit(
|
||||
self._fetch, file, config
|
||||
))
|
||||
|
||||
# Fetch external asset synchronously, as it either has no extension
|
||||
# or is fetched from a context in which replacements are done
|
||||
else:
|
||||
self._fetch(file, config)
|
||||
|
||||
# Register external asset as file
|
||||
self.assets.append(file)
|
||||
|
||||
# If the URL of the external asset includes a hash fragment, add it to
|
||||
# the returned file, e.g. for dark/light images - see https://t.ly/7b16Y
|
||||
if url.fragment:
|
||||
file.url += f"#{url.fragment}"
|
||||
|
||||
# Return file associated with external asset
|
||||
return file
|
||||
|
||||
# Fetch external asset referenced through the given file
|
||||
def _fetch(self, file: File, config: MkDocsConfig):
|
||||
|
||||
# Check if external asset needs to be downloaded
|
||||
if not os.path.isfile(file.abs_src_path) or not self.config.cache:
|
||||
path = file.abs_src_path
|
||||
|
||||
# Download external asset
|
||||
log.info(f"Downloading external file: {file.url}")
|
||||
res = requests.get(file.url, headers = {
|
||||
|
||||
# Set user agent explicitly, so Google Fonts gives us *.woff2
|
||||
# files, which according to caniuse.com is the only format we
|
||||
# need to download as it covers the entire range of browsers
|
||||
# we're officially supporting.
|
||||
"User-Agent": " ".join([
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko)",
|
||||
"Chrome/98.0.4758.102 Safari/537.36"
|
||||
])
|
||||
})
|
||||
|
||||
# Compute expected file extension and append if missing
|
||||
mime = res.headers["content-type"].split(";")[0]
|
||||
extension = extensions.get(mime)
|
||||
if extension and not path.endswith(extension):
|
||||
path += extension
|
||||
|
||||
# Save to file and create symlink if no extension was present
|
||||
self._save_to_file(path, res.content)
|
||||
if path != file.abs_src_path:
|
||||
|
||||
# Creating symlinks might fail on Windows. Thus, we just print
|
||||
# a warning and continue - see https://bit.ly/3xYFzcZ
|
||||
try:
|
||||
os.symlink(os.path.basename(path), file.abs_src_path)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EEXIST:
|
||||
log.warning(
|
||||
f"Couldn't create symbolic link: {file.src_uri}"
|
||||
)
|
||||
|
||||
# Fall back for when the symlink could not be created. This
|
||||
# means that the plugin will download the original file on
|
||||
# every build, as the content type cannot be resolved from
|
||||
# the file extension.
|
||||
file.abs_src_path = path
|
||||
|
||||
# Resolve destination if file points to a symlink
|
||||
_, extension = os.path.splitext(file.abs_src_path)
|
||||
if os.path.isfile(file.abs_src_path):
|
||||
file.abs_src_path = os.path.realpath(file.abs_src_path)
|
||||
_, extension = os.path.splitext(file.abs_src_path)
|
||||
|
||||
# If the symlink could not be created, we already set the correct
|
||||
# extension, so we need to make sure not to append it again
|
||||
if not file.abs_dest_path.endswith(extension):
|
||||
file.src_uri += extension
|
||||
|
||||
# Compute destination file system path
|
||||
file.dest_uri += extension
|
||||
file.abs_dest_path += extension
|
||||
|
||||
# Compute destination URL
|
||||
file.url = file.dest_uri
|
||||
|
||||
# Parse and enqueue dependent external assets
|
||||
for url in self._parse_media(file):
|
||||
if not self._is_excluded(url, file):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Patch all links to external assets in the given file
|
||||
def _patch(self, initiator: File):
|
||||
with open(initiator.abs_src_path, encoding = "utf-8") as f:
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
value = match.group(1)
|
||||
|
||||
# Map URL to canonical path
|
||||
path = self._path_from_url(urlparse(value))
|
||||
full = posixpath.join(self.config.assets_fetch_dir, path)
|
||||
|
||||
# Try to retrieve existing file
|
||||
file = self.assets.get_file_from_path(full)
|
||||
if not file:
|
||||
name = os.readlink(os.path.join(self.config.cache_dir, full))
|
||||
full = posixpath.join(posixpath.dirname(full), name)
|
||||
|
||||
# Try again after resolving symlink
|
||||
file = self.assets.get_file_from_path(full)
|
||||
|
||||
# This can theoretically never happen, as we're sure that we
|
||||
# only replace files that we successfully extracted. However,
|
||||
# we might have missed several cases, so it's better to throw
|
||||
# here than to swallow the error.
|
||||
if not file:
|
||||
log.error(
|
||||
"File not found. This is likely a bug in the built-in "
|
||||
"privacy plugin. Please create an issue with a minimal "
|
||||
"reproduction."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Create absolute URL for asset in script
|
||||
if file.url.endswith(".js"):
|
||||
url = posixpath.join(self.site.geturl(), file.url)
|
||||
|
||||
# Create relative URL for everything else
|
||||
else:
|
||||
url = file.url_relative_to(initiator)
|
||||
|
||||
# Switch external asset URL to local path
|
||||
return match.group().replace(value, url)
|
||||
|
||||
# Resolve replacement expression according to asset type
|
||||
_, extension = posixpath.splitext(initiator.dest_uri)
|
||||
expr = re.compile(self.assets_expr_map[extension], re.I | re.M)
|
||||
|
||||
# Resolve links to external assets in file
|
||||
self._save_to_file(
|
||||
initiator.abs_dest_path,
|
||||
expr.sub(replace, f.read())
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Normalize (= canonicalize) path by removing trailing slashes, and ensure
|
||||
# that hidden folders (`.` after `/`) are unhidden. Otherwise MkDocs will
|
||||
# not consider them being part of the build and refuse to copy them.
|
||||
def _path_from_url(self, url: URL):
|
||||
path = posixpath.normpath(url.path)
|
||||
path = re.sub(r"/\.", "/_", path)
|
||||
|
||||
# Compute digest of query string, as some URLs yield different results
|
||||
# for different query strings, e.g. https://unsplash.com/random?Coffee
|
||||
if url.query:
|
||||
name, extension = posixpath.splitext(path)
|
||||
|
||||
# Inject digest after file name and before file extension, as
|
||||
# done for style sheet and script files as well
|
||||
digest = sha1(url.query.encode("utf-8")).hexdigest()[:8]
|
||||
path = f"{name}.{digest}{extension}"
|
||||
|
||||
# Create and return URL without leading double slashes
|
||||
url = url._replace(scheme = "", query = "", fragment = "", path = path)
|
||||
return url.geturl()[2:]
|
||||
|
||||
# Create a file for the given path
|
||||
def _path_to_file(self, path: str, config: MkDocsConfig):
|
||||
return File(
|
||||
posixpath.join(self.config.assets_fetch_dir, path),
|
||||
os.path.abspath(self.config.cache_dir),
|
||||
config.site_dir,
|
||||
False
|
||||
)
|
||||
|
||||
# Create a file on the system with the given content
|
||||
def _save_to_file(self, path: str, content: str | bytes):
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
if isinstance(content, str):
|
||||
content = bytes(content, "utf-8")
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.privacy")
|
||||
|
||||
# Expected file extensions
|
||||
extensions = dict({
|
||||
"application/javascript": ".js",
|
||||
"image/avif": ".avif",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"text/javascript": ".js",
|
||||
"text/css": ".css"
|
||||
})
|
@ -35,6 +35,7 @@ export type Flag =
|
||||
| "content.code.copy" /* Code copy button */
|
||||
| "content.lazy" /* Lazy content elements */
|
||||
| "content.tabs.link" /* Link content tabs */
|
||||
| "content.tooltips" /* Tooltips */
|
||||
| "header.autohide" /* Hide header */
|
||||
| "navigation.expand" /* Automatic expansion */
|
||||
| "navigation.indexes" /* Section pages */
|
||||
|
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
Observable,
|
||||
debounceTime,
|
||||
fromEvent,
|
||||
identity,
|
||||
map,
|
||||
merge,
|
||||
startWith
|
||||
} from "rxjs"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Watch element hover
|
||||
*
|
||||
* @param el - Element
|
||||
* @param duration - Debounce duration
|
||||
*
|
||||
* @returns Element hover observable
|
||||
*/
|
||||
export function watchElementHover(
|
||||
el: HTMLElement, duration?: number
|
||||
): Observable<boolean> {
|
||||
return merge(
|
||||
fromEvent(el, "mouseenter").pipe(map(() => true)),
|
||||
fromEvent(el, "mouseleave").pipe(map(() => false))
|
||||
)
|
||||
.pipe(
|
||||
duration ? debounceTime(duration) : identity,
|
||||
startWith(false)
|
||||
)
|
||||
}
|
@ -22,6 +22,7 @@
|
||||
|
||||
export * from "./_"
|
||||
export * from "./focus"
|
||||
export * from "./hover"
|
||||
export * from "./offset"
|
||||
export * from "./size"
|
||||
export * from "./visibility"
|
||||
|
@ -82,6 +82,7 @@ import {
|
||||
setupVersionSelector
|
||||
} from "./integrations"
|
||||
import {
|
||||
patchEllipsis,
|
||||
patchIndeterminate,
|
||||
patchScrollfix,
|
||||
patchScrolllock
|
||||
@ -199,6 +200,7 @@ keyboard$
|
||||
})
|
||||
|
||||
/* Set up patches */
|
||||
patchEllipsis({ document$ })
|
||||
patchIndeterminate({ document$, tablet$ })
|
||||
patchScrollfix({ document$ })
|
||||
patchScrolllock({ viewport$, tablet$ })
|
||||
|
@ -22,9 +22,14 @@
|
||||
|
||||
import { Observable, merge } from "rxjs"
|
||||
|
||||
import { feature } from "~/_"
|
||||
import { Viewport, getElements } from "~/browser"
|
||||
|
||||
import { Component } from "../../_"
|
||||
import {
|
||||
Tooltip,
|
||||
mountTooltip
|
||||
} from "../../tooltip"
|
||||
import {
|
||||
Annotation,
|
||||
mountAnnotationBlock
|
||||
@ -64,6 +69,7 @@ export type Content =
|
||||
| DataTable
|
||||
| Details
|
||||
| Mermaid
|
||||
| Tooltip
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Helper types
|
||||
@ -120,6 +126,11 @@ export function mountContent(
|
||||
|
||||
/* Content tabs */
|
||||
...getElements("[data-tabs]", el)
|
||||
.map(child => mountContentTabs(child, { viewport$ }))
|
||||
.map(child => mountContentTabs(child, { viewport$, target$ })),
|
||||
|
||||
/* Tooltips */
|
||||
...getElements("[title]", el)
|
||||
.filter(() => feature("content.tooltips"))
|
||||
.map(child => mountTooltip(child))
|
||||
)
|
||||
}
|
||||
|
@ -34,6 +34,8 @@ import {
|
||||
mergeWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeLast,
|
||||
takeUntil,
|
||||
tap
|
||||
} from "rxjs"
|
||||
|
||||
@ -46,6 +48,10 @@ import {
|
||||
import { renderClipboardButton } from "~/templates"
|
||||
|
||||
import { Component } from "../../../_"
|
||||
import {
|
||||
Tooltip,
|
||||
mountTooltip
|
||||
} from "../../../tooltip"
|
||||
import {
|
||||
Annotation,
|
||||
mountAnnotationList
|
||||
@ -56,12 +62,20 @@ import {
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Code block
|
||||
* Code block overflow
|
||||
*/
|
||||
export interface CodeBlock {
|
||||
export interface Overflow {
|
||||
scrollable: boolean /* Code block overflows */
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block
|
||||
*/
|
||||
export type CodeBlock =
|
||||
| Overflow
|
||||
| Annotation
|
||||
| Tooltip
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Helper types
|
||||
* ------------------------------------------------------------------------- */
|
||||
@ -125,7 +139,7 @@ function findCandidateList(el: HTMLElement): HTMLElement | undefined {
|
||||
*/
|
||||
export function watchCodeBlock(
|
||||
el: HTMLElement
|
||||
): Observable<CodeBlock> {
|
||||
): Observable<Overflow> {
|
||||
return watchElementSize(el)
|
||||
.pipe(
|
||||
map(({ width }) => {
|
||||
@ -158,12 +172,13 @@ export function watchCodeBlock(
|
||||
*/
|
||||
export function mountCodeBlock(
|
||||
el: HTMLElement, options: MountOptions
|
||||
): Observable<Component<CodeBlock | Annotation>> {
|
||||
): Observable<Component<CodeBlock>> {
|
||||
const { matches: hover } = matchMedia("(hover)")
|
||||
|
||||
/* Defer mounting of code block - see https://bit.ly/3vHVoVD */
|
||||
const factory$ = defer(() => {
|
||||
const push$ = new Subject<CodeBlock>()
|
||||
const push$ = new Subject<Overflow>()
|
||||
const done$ = push$.pipe(takeLast(1))
|
||||
push$.subscribe(({ scrollable }) => {
|
||||
if (scrollable && hover)
|
||||
el.setAttribute("tabindex", "0")
|
||||
@ -172,16 +187,19 @@ export function mountCodeBlock(
|
||||
})
|
||||
|
||||
/* Render button for Clipboard.js integration */
|
||||
const content$: Array<Observable<Component<CodeBlock>>> = []
|
||||
if (ClipboardJS.isSupported()) {
|
||||
if (el.closest(".copy") || (
|
||||
feature("content.code.copy") && !el.closest(".no-copy")
|
||||
)) {
|
||||
const parent = el.closest("pre")!
|
||||
parent.id = `__code_${sequence++}`
|
||||
parent.insertBefore(
|
||||
renderClipboardButton(parent.id),
|
||||
el
|
||||
)
|
||||
|
||||
/* Mount tooltip, if enabled */
|
||||
const button = renderClipboardButton(parent.id)
|
||||
parent.insertBefore(button, el)
|
||||
if (feature("content.tooltips"))
|
||||
content$.push(mountTooltip(button))
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,22 +214,15 @@ export function mountCodeBlock(
|
||||
feature("content.code.annotate")
|
||||
)) {
|
||||
const annotations$ = mountAnnotationList(list, el, options)
|
||||
|
||||
/* Create and return component */
|
||||
return watchCodeBlock(el)
|
||||
.pipe(
|
||||
tap(state => push$.next(state)),
|
||||
finalize(() => push$.complete()),
|
||||
map(state => ({ ref: el, ...state })),
|
||||
mergeWith(
|
||||
watchElementSize(container)
|
||||
.pipe(
|
||||
map(({ width, height }) => width && height),
|
||||
distinctUntilChanged(),
|
||||
switchMap(active => active ? annotations$ : EMPTY)
|
||||
)
|
||||
content$.push(
|
||||
watchElementSize(container)
|
||||
.pipe(
|
||||
takeUntil(done$),
|
||||
map(({ width, height }) => width && height),
|
||||
distinctUntilChanged(),
|
||||
switchMap(active => active ? annotations$ : EMPTY)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,7 +231,8 @@ export function mountCodeBlock(
|
||||
.pipe(
|
||||
tap(state => push$.next(state)),
|
||||
finalize(() => push$.complete()),
|
||||
map(state => ({ ref: el, ...state }))
|
||||
map(state => ({ ref: el, ...state })),
|
||||
mergeWith(...content$)
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -24,5 +24,6 @@ export * from "./_"
|
||||
export * from "./annotation"
|
||||
export * from "./code"
|
||||
export * from "./details"
|
||||
export * from "./mermaid"
|
||||
export * from "./table"
|
||||
export * from "./tabs"
|
||||
|
@ -29,6 +29,7 @@ import {
|
||||
combineLatest,
|
||||
defer,
|
||||
endWith,
|
||||
filter,
|
||||
finalize,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
@ -55,6 +56,7 @@ import {
|
||||
watchElementSize
|
||||
} from "~/browser"
|
||||
import { renderTabbedControl } from "~/templates"
|
||||
import { h } from "~/utilities"
|
||||
|
||||
import { Component } from "../../_"
|
||||
|
||||
@ -78,6 +80,7 @@ export interface ContentTabs {
|
||||
*/
|
||||
interface MountOptions {
|
||||
viewport$: Observable<Viewport> /* Viewport observable */
|
||||
target$: Observable<HTMLElement> /* Location target observable */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@ -87,14 +90,13 @@ interface MountOptions {
|
||||
/**
|
||||
* Watch content tabs
|
||||
*
|
||||
* @param el - Content tabs element
|
||||
* @param inputs - Content tabs input elements
|
||||
*
|
||||
* @returns Content tabs observable
|
||||
*/
|
||||
export function watchContentTabs(
|
||||
el: HTMLElement
|
||||
inputs: HTMLInputElement[]
|
||||
): Observable<ContentTabs> {
|
||||
const inputs = getElements<HTMLInputElement>(":scope > input", el)
|
||||
const initial = inputs.find(input => input.checked) || inputs[0]
|
||||
return merge(...inputs.map(input => fromEvent(input, "change")
|
||||
.pipe(
|
||||
@ -110,19 +112,16 @@ export function watchContentTabs(
|
||||
/**
|
||||
* Mount content tabs
|
||||
*
|
||||
* This function scrolls the active tab into view. While this functionality is
|
||||
* provided by browsers as part of `scrollInfoView`, browsers will always also
|
||||
* scroll the vertical axis, which we do not want. Thus, we decided to provide
|
||||
* this functionality ourselves.
|
||||
*
|
||||
* @param el - Content tabs element
|
||||
* @param options - Options
|
||||
*
|
||||
* @returns Content tabs component observable
|
||||
*/
|
||||
export function mountContentTabs(
|
||||
el: HTMLElement, { viewport$ }: MountOptions
|
||||
el: HTMLElement, { viewport$, target$ }: MountOptions
|
||||
): Observable<Component<ContentTabs>> {
|
||||
const container = getElement(".tabbed-labels", el)
|
||||
const inputs = getElements<HTMLInputElement>(":scope > input", el)
|
||||
|
||||
/* Render content tab previous button for pagination */
|
||||
const prev = renderTabbedControl("prev")
|
||||
@ -133,14 +132,13 @@ export function mountContentTabs(
|
||||
el.append(next)
|
||||
|
||||
/* Mount component on subscription */
|
||||
const container = getElement(".tabbed-labels", el)
|
||||
return defer(() => {
|
||||
const push$ = new Subject<ContentTabs>()
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
combineLatest([push$, watchElementSize(el)])
|
||||
.pipe(
|
||||
auditTime(1, animationFrameScheduler),
|
||||
takeUntil(done$)
|
||||
takeUntil(done$),
|
||||
auditTime(1, animationFrameScheduler)
|
||||
)
|
||||
.subscribe({
|
||||
|
||||
@ -202,6 +200,40 @@ export function mountContentTabs(
|
||||
})
|
||||
})
|
||||
|
||||
/* Switch to content tab target */
|
||||
target$
|
||||
.pipe(
|
||||
takeUntil(done$),
|
||||
filter(input => inputs.includes(input as HTMLInputElement))
|
||||
)
|
||||
.subscribe(input => input.click())
|
||||
|
||||
/* Add link to each content tab label */
|
||||
container.classList.add("tabbed-labels--linked")
|
||||
for (const input of inputs) {
|
||||
const label = getElement<HTMLLabelElement>(`label[for="${input.id}"]`)
|
||||
label.replaceChildren(h("a", {
|
||||
href: `#${label.htmlFor}`,
|
||||
tabIndex: -1
|
||||
}, ...Array.from(label.childNodes)))
|
||||
|
||||
/* Allow to copy link without scrolling to anchor */
|
||||
fromEvent<MouseEvent>(label.firstElementChild!, "click")
|
||||
.pipe(
|
||||
takeUntil(done$),
|
||||
filter(ev => !(ev.metaKey || ev.ctrlKey)),
|
||||
tap(ev => {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
})
|
||||
)
|
||||
// @todo we might need to remove the anchor link on complete
|
||||
.subscribe(() => {
|
||||
history.replaceState({}, "", `#${label.htmlFor}`)
|
||||
label.click()
|
||||
})
|
||||
}
|
||||
|
||||
/* Set up linking of content tabs, if enabled */
|
||||
if (feature("content.tabs.link"))
|
||||
push$.pipe(
|
||||
@ -252,7 +284,7 @@ export function mountContentTabs(
|
||||
})
|
||||
|
||||
/* Create and return component */
|
||||
return watchContentTabs(el)
|
||||
return watchContentTabs(inputs)
|
||||
.pipe(
|
||||
tap(state => push$.next(state)),
|
||||
finalize(() => push$.complete()),
|
||||
|
@ -31,8 +31,11 @@ import {
|
||||
distinctUntilKeyChanged,
|
||||
endWith,
|
||||
filter,
|
||||
from,
|
||||
ignoreElements,
|
||||
map,
|
||||
mergeMap,
|
||||
mergeWith,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
@ -43,12 +46,17 @@ import {
|
||||
import { feature } from "~/_"
|
||||
import {
|
||||
Viewport,
|
||||
getElements,
|
||||
watchElementSize,
|
||||
watchToggle
|
||||
} from "~/browser"
|
||||
|
||||
import { Component } from "../../_"
|
||||
import { Main } from "../../main"
|
||||
import {
|
||||
Tooltip,
|
||||
mountTooltip
|
||||
} from "../../tooltip"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
@ -173,7 +181,7 @@ export function watchHeader(
|
||||
*/
|
||||
export function mountHeader(
|
||||
el: HTMLElement, { header$, main$ }: MountOptions
|
||||
): Observable<Component<Header>> {
|
||||
): Observable<Component<Header | Tooltip>> {
|
||||
return defer(() => {
|
||||
const push$ = new Subject<Main>()
|
||||
const done$ = push$.pipe(ignoreElements(), endWith(true))
|
||||
@ -187,6 +195,13 @@ export function mountHeader(
|
||||
el.hidden = hidden
|
||||
})
|
||||
|
||||
/* Mount tooltips, if enabled */
|
||||
const tooltips = from(getElements("[title]", el))
|
||||
.pipe(
|
||||
filter(() => feature("content.tooltips")),
|
||||
mergeMap(child => mountTooltip(child))
|
||||
)
|
||||
|
||||
/* Link to main area */
|
||||
main$.subscribe(push$)
|
||||
|
||||
@ -194,7 +209,8 @@ export function mountHeader(
|
||||
return header$
|
||||
.pipe(
|
||||
takeUntil(done$),
|
||||
map(state => ({ ref: el, ...state }))
|
||||
map(state => ({ ref: el, ...state })),
|
||||
mergeWith(tooltips.pipe(takeUntil(done$)))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
@ -34,4 +34,5 @@ export * from "./sidebar"
|
||||
export * from "./source"
|
||||
export * from "./tabs"
|
||||
export * from "./toc"
|
||||
export * from "./tooltip"
|
||||
export * from "./top"
|
||||
|
@ -31,12 +31,15 @@ import {
|
||||
mergeMap,
|
||||
observeOn,
|
||||
of,
|
||||
repeat,
|
||||
shareReplay,
|
||||
skip,
|
||||
startWith,
|
||||
takeUntil,
|
||||
tap
|
||||
} from "rxjs"
|
||||
|
||||
import { getElements } from "~/browser"
|
||||
import { getElements, watchMedia } from "~/browser"
|
||||
import { h } from "~/utilities"
|
||||
|
||||
import {
|
||||
@ -52,6 +55,7 @@ import {
|
||||
* Palette colors
|
||||
*/
|
||||
export interface PaletteColor {
|
||||
media?: string /* Media query */
|
||||
scheme?: string /* Color scheme */
|
||||
primary?: string /* Primary color */
|
||||
accent?: string /* Accent color */
|
||||
@ -88,15 +92,12 @@ export function watchPalette(
|
||||
/* Emit changes in color palette */
|
||||
return of(...inputs)
|
||||
.pipe(
|
||||
mergeMap(input => fromEvent(input, "change")
|
||||
.pipe(
|
||||
map(() => input)
|
||||
)
|
||||
),
|
||||
mergeMap(input => fromEvent(input, "change").pipe(map(() => input))),
|
||||
startWith(inputs[Math.max(0, current.index)]),
|
||||
map(input => ({
|
||||
index: inputs.indexOf(input),
|
||||
color: {
|
||||
media: input.getAttribute("data-md-color-media"),
|
||||
scheme: input.getAttribute("data-md-color-scheme"),
|
||||
primary: input.getAttribute("data-md-color-primary"),
|
||||
accent: input.getAttribute("data-md-color-accent")
|
||||
@ -116,6 +117,7 @@ export function watchPalette(
|
||||
export function mountPalette(
|
||||
el: HTMLElement
|
||||
): Observable<Component<Palette>> {
|
||||
const inputs = getElements<HTMLInputElement>("input", el)
|
||||
const meta = h("meta", { name: "theme-color" })
|
||||
document.head.appendChild(meta)
|
||||
|
||||
@ -124,16 +126,31 @@ export function mountPalette(
|
||||
document.head.appendChild(scheme)
|
||||
|
||||
/* Mount component on subscription */
|
||||
const media$ = watchMedia("(prefers-color-scheme: light)")
|
||||
return defer(() => {
|
||||
const push$ = new Subject<Palette>()
|
||||
push$.subscribe(palette => {
|
||||
document.body.setAttribute("data-md-color-switching", "")
|
||||
|
||||
/* Retrieve color palette for system preference */
|
||||
if (palette.color.media === "(prefers-color-scheme)") {
|
||||
const media = matchMedia("(prefers-color-scheme: light)")
|
||||
const input = document.querySelector(media.matches
|
||||
? "[data-md-color-media='(prefers-color-scheme: light)']"
|
||||
: "[data-md-color-media='(prefers-color-scheme: dark)']"
|
||||
)!
|
||||
|
||||
/* Retrieve colors for system preference */
|
||||
palette.color.scheme = input.getAttribute("data-md-color-scheme")!
|
||||
palette.color.primary = input.getAttribute("data-md-color-primary")!
|
||||
palette.color.accent = input.getAttribute("data-md-color-accent")!
|
||||
}
|
||||
|
||||
/* Set color palette */
|
||||
for (const [key, value] of Object.entries(palette.color))
|
||||
document.body.setAttribute(`data-md-color-${key}`, value)
|
||||
|
||||
/* Toggle visibility */
|
||||
/* Set toggle visibility */
|
||||
for (let index = 0; index < inputs.length; index++) {
|
||||
const label = inputs[index].nextElementSibling
|
||||
if (label instanceof HTMLElement)
|
||||
@ -169,9 +186,10 @@ export function mountPalette(
|
||||
})
|
||||
|
||||
/* Create and return component */
|
||||
const inputs = getElements<HTMLInputElement>("input", el)
|
||||
return watchPalette(inputs)
|
||||
.pipe(
|
||||
takeUntil(media$.pipe(skip(1))),
|
||||
repeat(),
|
||||
tap(state => push$.next(state)),
|
||||
finalize(() => push$.complete()),
|
||||
map(state => ({ ref: el, ...state }))
|
||||
|
264
src/templates/assets/javascripts/components/tooltip/index.ts
Normal file
264
src/templates/assets/javascripts/components/tooltip/index.ts
Normal file
@ -0,0 +1,264 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
Subject,
|
||||
animationFrameScheduler,
|
||||
asyncScheduler,
|
||||
auditTime,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
finalize,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
subscribeOn,
|
||||
tap,
|
||||
throttleTime
|
||||
} from "rxjs"
|
||||
|
||||
import {
|
||||
ElementOffset,
|
||||
getElement,
|
||||
getElementContainer,
|
||||
getElementOffset,
|
||||
getElementSize,
|
||||
watchElementContentOffset,
|
||||
watchElementFocus,
|
||||
watchElementHover
|
||||
} from "~/browser"
|
||||
import { renderTooltip } from "~/templates"
|
||||
|
||||
import { Component } from "../_"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Tooltip
|
||||
*/
|
||||
export interface Tooltip {
|
||||
active: boolean /* Tooltip is active */
|
||||
offset: ElementOffset /* Tooltip offset */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Data
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Global sequence number for tooltips
|
||||
*/
|
||||
let sequence = 0
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Watch tooltip
|
||||
*
|
||||
* This function will append the tooltip temporarily to compute its width,
|
||||
* which is necessary for correct centering, and then removing it again.
|
||||
*
|
||||
* @param el - Tooltip element
|
||||
* @param host - Host element
|
||||
*
|
||||
* @returns Tooltip observable
|
||||
*/
|
||||
export function watchTooltip(
|
||||
el: HTMLElement, host: HTMLElement
|
||||
): Observable<Tooltip> {
|
||||
document.body.append(el)
|
||||
|
||||
/* Compute width and remove tooltip immediately */
|
||||
const { width } = getElementSize(el)
|
||||
el.style.setProperty("--md-tooltip-width", `${width}px`)
|
||||
el.remove()
|
||||
|
||||
/* Retrieve and watch containing element */
|
||||
const container = getElementContainer(host)
|
||||
const scroll$ =
|
||||
typeof container !== "undefined"
|
||||
? watchElementContentOffset(container)
|
||||
: of({ x: 0, y: 0 })
|
||||
|
||||
/* Compute tooltip visibility */
|
||||
const active$ = merge(
|
||||
watchElementFocus(host),
|
||||
watchElementHover(host)
|
||||
)
|
||||
.pipe(
|
||||
distinctUntilChanged()
|
||||
)
|
||||
|
||||
/* Compute tooltip offset */
|
||||
return combineLatest([active$, scroll$])
|
||||
.pipe(
|
||||
map(([active, scroll]) => {
|
||||
let { x, y } = getElementOffset(host)
|
||||
const size = getElementSize(host)
|
||||
|
||||
/**
|
||||
* Experimental: fix handling of tables - see https://bit.ly/3TQEj5O
|
||||
*
|
||||
* If this proves to be a viable fix, we should refactor tooltip
|
||||
* positioning and somehow streamline the current process. This might
|
||||
* also fix positioning for annotations inside tables, which is another
|
||||
* limitation.
|
||||
*/
|
||||
const table = host.closest("table")
|
||||
if (table && host.parentElement) {
|
||||
x += table.offsetLeft + host.parentElement.offsetLeft
|
||||
y += table.offsetTop + host.parentElement.offsetTop
|
||||
}
|
||||
return {
|
||||
active,
|
||||
offset: {
|
||||
x: x - scroll.x + size.width / 2 - width / 2,
|
||||
y: y - scroll.y + size.height + 8
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount tooltip
|
||||
*
|
||||
* @param el - Host element
|
||||
*
|
||||
* @returns Tooltip component observable
|
||||
*/
|
||||
export function mountTooltip(
|
||||
el: HTMLElement
|
||||
): Observable<Component<Tooltip>> {
|
||||
const title = el.title
|
||||
if (!title.length)
|
||||
return EMPTY
|
||||
|
||||
/* Render tooltip and set title from host element */
|
||||
const id = `__tooltip_${sequence++}`
|
||||
const tooltip = renderTooltip(id, "inline")
|
||||
const typeset = getElement(".md-typeset", tooltip)
|
||||
typeset.innerHTML = title
|
||||
|
||||
/* Mount component on subscription */
|
||||
return defer(() => {
|
||||
const push$ = new Subject<Tooltip>()
|
||||
push$.subscribe({
|
||||
|
||||
/* Handle emission */
|
||||
next({ offset }) {
|
||||
tooltip.style.setProperty("--md-tooltip-x", `${offset.x}px`)
|
||||
tooltip.style.setProperty("--md-tooltip-y", `${offset.y}px`)
|
||||
},
|
||||
|
||||
/* Handle complete */
|
||||
complete() {
|
||||
tooltip.style.removeProperty("--md-tooltip-x")
|
||||
tooltip.style.removeProperty("--md-tooltip-y")
|
||||
}
|
||||
})
|
||||
|
||||
/* Toggle tooltip presence to mitigate empty lines when copying */
|
||||
merge(
|
||||
push$.pipe(filter(({ active }) => active)),
|
||||
push$.pipe(debounceTime(250), filter(({ active }) => !active))
|
||||
)
|
||||
.subscribe({
|
||||
|
||||
/* Handle emission */
|
||||
next({ active }) {
|
||||
if (active) {
|
||||
el.insertAdjacentElement("afterend", tooltip)
|
||||
el.setAttribute("aria-describedby", id)
|
||||
el.removeAttribute("title")
|
||||
} else {
|
||||
tooltip.remove()
|
||||
el.removeAttribute("aria-describedby")
|
||||
el.setAttribute("title", title)
|
||||
}
|
||||
},
|
||||
|
||||
/* Handle complete */
|
||||
complete() {
|
||||
tooltip.remove()
|
||||
el.removeAttribute("aria-describedby")
|
||||
el.setAttribute("title", title)
|
||||
}
|
||||
})
|
||||
|
||||
/* Toggle tooltip visibility */
|
||||
push$
|
||||
.pipe(
|
||||
auditTime(16, animationFrameScheduler)
|
||||
)
|
||||
.subscribe(({ active }) => {
|
||||
tooltip.classList.toggle("md-tooltip--active", active)
|
||||
})
|
||||
|
||||
// @todo - refactor positioning together with annotations – there are
|
||||
// several things that overlap and are identical in handling
|
||||
|
||||
/* Track relative origin of tooltip */
|
||||
push$
|
||||
.pipe(
|
||||
throttleTime(125, animationFrameScheduler),
|
||||
filter(() => !!el.offsetParent),
|
||||
map(() => el.offsetParent!.getBoundingClientRect()),
|
||||
map(({ x }) => x)
|
||||
)
|
||||
.subscribe({
|
||||
|
||||
/* Handle emission */
|
||||
next(origin) {
|
||||
if (origin)
|
||||
tooltip.style.setProperty("--md-tooltip-0", `${-origin}px`)
|
||||
else
|
||||
tooltip.style.removeProperty("--md-tooltip-0")
|
||||
},
|
||||
|
||||
/* Handle complete */
|
||||
complete() {
|
||||
tooltip.style.removeProperty("--md-tooltip-0")
|
||||
}
|
||||
})
|
||||
|
||||
/* Create and return component */
|
||||
return watchTooltip(tooltip, el)
|
||||
.pipe(
|
||||
tap(state => push$.next(state)),
|
||||
finalize(() => push$.complete()),
|
||||
map(state => ({ ref: el, ...state }))
|
||||
)
|
||||
})
|
||||
.pipe(
|
||||
subscribeOn(asyncScheduler)
|
||||
)
|
||||
}
|
103
src/templates/assets/javascripts/patches/ellipsis/index.ts
Normal file
103
src/templates/assets/javascripts/patches/ellipsis/index.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
Observable,
|
||||
filter,
|
||||
finalize,
|
||||
map,
|
||||
mergeMap,
|
||||
skip,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil
|
||||
} from "rxjs"
|
||||
|
||||
import {
|
||||
getElements,
|
||||
watchElementVisibility
|
||||
} from "~/browser"
|
||||
import { mountTooltip } from "~/components"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Helper types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Patch options
|
||||
*/
|
||||
interface PatchOptions {
|
||||
document$: Observable<Document> /* Document observable */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Patch ellipsis
|
||||
*
|
||||
* This function will fetch all elements that are shortened with ellipsis, and
|
||||
* filter those which are visible. Once they become visible, they stay in that
|
||||
* state, even though they may be hidden again. This optimization is necessary
|
||||
* to reduce pressure on the browser, with elements fading in and out of view.
|
||||
*
|
||||
* @param options - Options
|
||||
*/
|
||||
export function patchEllipsis(
|
||||
{ document$ }: PatchOptions
|
||||
): void {
|
||||
document$
|
||||
.pipe(
|
||||
switchMap(() => getElements(".md-ellipsis")),
|
||||
mergeMap(el => watchElementVisibility(el)
|
||||
.pipe(
|
||||
takeUntil(document$.pipe(skip(1))),
|
||||
filter(visible => visible),
|
||||
map(() => el),
|
||||
take(1)
|
||||
)
|
||||
),
|
||||
filter(el => el.offsetWidth < el.scrollWidth),
|
||||
mergeMap(el => {
|
||||
const text = el.innerText
|
||||
const host = el.closest("a") || el
|
||||
host.title = text
|
||||
|
||||
/* Mount tooltip */
|
||||
return mountTooltip(host)
|
||||
.pipe(
|
||||
takeUntil(document$.pipe(skip(1))),
|
||||
finalize(() => host.removeAttribute("title"))
|
||||
)
|
||||
})
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
// @todo move this outside of here and fix memleaks
|
||||
document$
|
||||
.pipe(
|
||||
switchMap(() => getElements(".md-status")),
|
||||
mergeMap(el => mountTooltip(el))
|
||||
)
|
||||
.subscribe()
|
||||
}
|
@ -20,6 +20,7 @@
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
export * from "./ellipsis"
|
||||
export * from "./indeterminate"
|
||||
export * from "./scrollfix"
|
||||
export * from "./scrolllock"
|
||||
|
@ -26,4 +26,5 @@ export * from "./search"
|
||||
export * from "./source"
|
||||
export * from "./tabbed"
|
||||
export * from "./table"
|
||||
export * from "./tooltip"
|
||||
export * from "./version"
|
||||
|
@ -22,6 +22,16 @@
|
||||
|
||||
import { h } from "~/utilities"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Tooltip style
|
||||
*/
|
||||
export type TooltipStyle =
|
||||
| "inline"
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
* Functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
@ -30,13 +40,24 @@ import { h } from "~/utilities"
|
||||
* Render a tooltip
|
||||
*
|
||||
* @param id - Tooltip identifier
|
||||
* @param style - Tooltip style
|
||||
*
|
||||
* @returns Element
|
||||
*/
|
||||
export function renderTooltip(id?: string): HTMLElement {
|
||||
return (
|
||||
<div class="md-tooltip" id={id}>
|
||||
<div class="md-tooltip__inner md-typeset"></div>
|
||||
</div>
|
||||
)
|
||||
export function renderTooltip(
|
||||
id?: string, style?: TooltipStyle
|
||||
): HTMLElement {
|
||||
if (style === "inline") { // @todo refactor control flow
|
||||
return (
|
||||
<div class="md-tooltip md-tooltip--inline" id={id} role="tooltip">
|
||||
<div class="md-tooltip__inner md-typeset"></div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div class="md-tooltip" id={id} role="tooltip">
|
||||
<div class="md-tooltip__inner md-typeset"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -83,4 +83,5 @@
|
||||
|
||||
@import "main/integrations/mermaid";
|
||||
|
||||
@import "main/modifiers";
|
||||
@import "main/modifiers/grid";
|
||||
@import "main/modifiers/inline";
|
||||
|
@ -86,6 +86,8 @@
|
||||
// Code highlighting color shades
|
||||
--md-code-hl-color: hsla(#{hex2hsl($clr-blue-a200)}, 1);
|
||||
--md-code-hl-color--light: hsla(#{hex2hsl($clr-blue-a200)}, 0.1);
|
||||
|
||||
// Code highlighting syntax color shades
|
||||
--md-code-hl-number-color: hsla(0, 67%, 50%, 1);
|
||||
--md-code-hl-special-color: hsla(340, 83%, 47%, 1);
|
||||
--md-code-hl-function-color: hsla(291, 45%, 50%, 1);
|
||||
|
@ -291,24 +291,6 @@ kbd {
|
||||
text-decoration: none;
|
||||
cursor: help;
|
||||
border-bottom: px2rem(1px) dotted var(--md-default-fg-color--light);
|
||||
|
||||
// Show tooltip for touch devices
|
||||
@media (hover: none) {
|
||||
|
||||
// Tooltip
|
||||
&[title]:is(:focus, :hover)::after {
|
||||
position: absolute;
|
||||
inset-inline: px2rem(16px);
|
||||
padding: px2rem(4px) px2rem(6px);
|
||||
margin-top: 2em;
|
||||
font-size: px2rem(14px);
|
||||
color: var(--md-default-bg-color);
|
||||
content: attr(title);
|
||||
background-color: var(--md-default-fg-color);
|
||||
border-radius: px2rem(2px);
|
||||
box-shadow: var(--md-shadow-z3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small text
|
||||
|
@ -35,6 +35,7 @@
|
||||
width: px2rem(32px);
|
||||
height: px2rem(32px);
|
||||
overflow: hidden;
|
||||
border-radius: 100%;
|
||||
transition:
|
||||
color 125ms,
|
||||
transform 125ms;
|
||||
@ -42,7 +43,6 @@
|
||||
// Author image
|
||||
img {
|
||||
display: block;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
// More authors
|
||||
|
@ -180,3 +180,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source file information
|
||||
.md-source-file {
|
||||
margin: 1em 0;
|
||||
|
||||
// Source file information fact
|
||||
&__fact {
|
||||
display: inline-flex;
|
||||
gap: px2rem(6px);
|
||||
align-items: center;
|
||||
margin-inline-end: px2rem(12px);
|
||||
font-size: px2rem(13.6px);
|
||||
color: var(--md-default-fg-color--light);
|
||||
|
||||
// Adjust vertical spacing
|
||||
.md-icon {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: px2rem(1px);
|
||||
}
|
||||
|
||||
// Author
|
||||
.md-author {
|
||||
float: inline-start;
|
||||
margin-right: px2rem(4px);
|
||||
}
|
||||
|
||||
// Adjust size of icon
|
||||
svg {
|
||||
width: px2rem(18px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -95,6 +95,30 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
// Inline tooltip
|
||||
&--inline {
|
||||
width: auto;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
|
||||
// Tooltip is not active
|
||||
&:not(.md-tooltip--active) {
|
||||
transform: translateY(px2rem(4px)) scale(0.9);
|
||||
}
|
||||
|
||||
// Tooltip wrapper
|
||||
.md-tooltip__inner {
|
||||
padding: px2rem(4px) px2rem(8px);
|
||||
font-size: px2rem(10px);
|
||||
}
|
||||
|
||||
// Hack: When the host element is hidden, the context for the tooltip is
|
||||
// lost immediately, resulting in invalid and sometimes jumpy positioning.
|
||||
[hidden] + & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Show outline on target and for keyboard devices
|
||||
:is(.focus-visible > &, &:target) {
|
||||
outline: var(--md-accent-fg-color) auto;
|
||||
|
@ -29,15 +29,47 @@
|
||||
|
||||
// Emoji and icon container
|
||||
:is(.emojione, .twemoji, .gemoji) {
|
||||
--md-icon-size: #{px2em(18px)};
|
||||
|
||||
display: inline-flex;
|
||||
height: px2em(18px);
|
||||
height: var(--md-icon-size);
|
||||
vertical-align: text-top;
|
||||
|
||||
// Icon - inlined via mkdocs-material-extensions
|
||||
svg {
|
||||
width: px2em(18px);
|
||||
width: var(--md-icon-size);
|
||||
max-height: 100%;
|
||||
fill: currentcolor;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon with size modifier
|
||||
:is(.lg, .xl, .xxl, .xxxl) {
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
// Adjust icon alignment
|
||||
.middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Adjust icon size to 1.5x
|
||||
.lg {
|
||||
--md-icon-size: #{px2em(24px)};
|
||||
}
|
||||
|
||||
// Adjust icon size to 2x
|
||||
.xl {
|
||||
--md-icon-size: #{px2em(36px)};
|
||||
}
|
||||
|
||||
// Adjust icon size to 3x
|
||||
.xxl {
|
||||
--md-icon-size: #{px2em(48px)};
|
||||
}
|
||||
|
||||
// Adjust icon size to 4x
|
||||
.xxxl {
|
||||
--md-icon-size: #{px2em(64px)};
|
||||
}
|
||||
}
|
||||
|
@ -168,6 +168,23 @@
|
||||
&:hover {
|
||||
color: var(--md-default-fg-color);
|
||||
}
|
||||
|
||||
// Tab label anchor link
|
||||
> [href]:first-child {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab label with anchor link
|
||||
&--linked > label {
|
||||
padding: 0;
|
||||
|
||||
// Move padding one level down to anchor link, so the whole tab area
|
||||
// becomes clickable, not only the text.
|
||||
> a {
|
||||
display: block;
|
||||
padding: px2em(10px, 12.8px) 1.25em px2em(8px, 12.8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
129
src/templates/assets/stylesheets/main/modifiers/_grid.scss
Normal file
129
src/templates/assets/stylesheets/main/modifiers/_grid.scss
Normal file
@ -0,0 +1,129 @@
|
||||
////
|
||||
/// Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com>
|
||||
///
|
||||
/// 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
|
||||
////
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rules
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Scoped in typesetted content to match specificity of regular content
|
||||
.md-typeset {
|
||||
|
||||
// Grid container
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
repeat(
|
||||
auto-fit,
|
||||
minmax(
|
||||
min(100%, #{px2rem(320px)}),
|
||||
1fr
|
||||
)
|
||||
);
|
||||
grid-gap: px2rem(8px);
|
||||
margin: 1em 0;
|
||||
|
||||
// Grid card container - if all grid items should render as cards, the
|
||||
// `.cards` class can be added, which moves list items up one level.
|
||||
&.cards > :is(ul, ol) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// Grid card - a card is either a list item of a grid container with the
|
||||
// `.cards` class or a single element with the `.card` class, which allows
|
||||
// to align cards with other components (admonitions, tabs, ...) in grids.
|
||||
&.cards > :is(ul, ol) > li,
|
||||
> .card {
|
||||
display: block;
|
||||
padding: px2rem(16px);
|
||||
margin: 0;
|
||||
border: px2rem(1px) solid var(--md-default-fg-color--lightest);
|
||||
border-radius: px2rem(2px);
|
||||
transition:
|
||||
border 250ms,
|
||||
box-shadow 250ms;
|
||||
|
||||
// Grid list item on focus/hover
|
||||
&:is(:focus-within, :hover) {
|
||||
border-color: transparent;
|
||||
box-shadow: var(--md-shadow-z2);
|
||||
}
|
||||
|
||||
// Adjust spacing for horizontal separators
|
||||
> hr {
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
// Adjust spacing on first child
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// Adjust spacing on last child
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Grid item
|
||||
> * {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
// Grid item: admonition
|
||||
> :is(.admonition, details) {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
// Grid item: code block
|
||||
> pre,
|
||||
> .highlight > *,
|
||||
> .highlighttable {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
// Grid item: code block without line numbers - stretch to match height
|
||||
// of containing grid item, which must be done explicitly.
|
||||
> .highlight > pre:only-child,
|
||||
> .highlight > pre > code {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Grid item: code block with line numbers - stretch to match height of
|
||||
// containing grid item, which is even uglier than the rule before. However,
|
||||
// it's not possible to achieve this behavior without explicitly setting the
|
||||
// height on each and every element as we do here.
|
||||
> .highlighttable,
|
||||
> .highlighttable > tbody,
|
||||
> .highlighttable > tbody > tr,
|
||||
> .highlighttable > tbody > tr > .code,
|
||||
> .highlighttable > tbody > tr > .code > .highlight,
|
||||
> .highlighttable > tbody > tr > .code > .highlight > pre,
|
||||
> .highlighttable > tbody > tr > .code > .highlight > pre > code {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Grid item: tabbed container
|
||||
> .tabbed-set {
|
||||
margin-block: 0;
|
||||
}
|
||||
}
|
||||
}
|
@ -48,7 +48,10 @@
|
||||
--md-code-bg-color: hsla(var(--md-hue), 15%, 18%, 1);
|
||||
|
||||
// Code highlighting color shades
|
||||
--md-code-hl-color--light: hsla(#{hex2hsl($clr-blue-a200)}, 0.15);
|
||||
--md-code-hl-color: hsla(#{hex2hsl($clr-blue-a400)}, 1);
|
||||
--md-code-hl-color--light: hsla(#{hex2hsl($clr-blue-a400)}, 0.1);
|
||||
|
||||
// Code highlighting syntax color shades
|
||||
--md-code-hl-number-color: hsla(6, 74%, 63%, 1);
|
||||
--md-code-hl-special-color: hsla(340, 83%, 66%, 1);
|
||||
--md-code-hl-function-color: hsla(291, 57%, 65%, 1);
|
||||
|
@ -194,11 +194,6 @@
|
||||
{% endif %}
|
||||
{% set features = config.theme.features or [] %}
|
||||
|
||||
<!-- User preference: color palette -->
|
||||
{% if not config.theme.palette is mapping %}
|
||||
{% include "partials/javascripts/palette.html" %}
|
||||
{% endif %}
|
||||
|
||||
<!--
|
||||
State toggles - we need to set autocomplete="off" in order to reset the
|
||||
drawer on back button invocation in some browsers
|
||||
|
@ -40,12 +40,7 @@
|
||||
{{ page.content }}
|
||||
|
||||
<!-- Source file information -->
|
||||
{% if page.meta and (
|
||||
page.meta.git_revision_date_localized or
|
||||
page.meta.revision_date
|
||||
) %}
|
||||
{% include "partials/source-file.html" %}
|
||||
{% endif %}
|
||||
{% include "partials/source-file.html" %}
|
||||
|
||||
<!-- Was this page helpful? -->
|
||||
{% include "partials/feedback.html" %}
|
||||
|
@ -79,6 +79,11 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- User preference: color palette -->
|
||||
{% if not config.theme.palette is mapping %}
|
||||
{% include "partials/javascripts/palette.html" %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Site language selector -->
|
||||
{% if config.extra.alternate %}
|
||||
{% include "partials/alternate.html" %}
|
||||
|
@ -37,3 +37,10 @@
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Switch to content tab target -->
|
||||
<script>
|
||||
var target = document.getElementById(location.hash.slice(1))
|
||||
if (target && target.name)
|
||||
target.checked = target.name.startsWith("__tabbed_")
|
||||
</script>
|
||||
|
@ -23,7 +23,25 @@
|
||||
<!-- User preference: color palette -->
|
||||
<script>
|
||||
var palette = __md_get("__palette")
|
||||
if (palette && typeof palette.color === "object")
|
||||
for (var key of Object.keys(palette.color))
|
||||
document.body.setAttribute("data-md-color-" + key, palette.color[key])
|
||||
if (palette && palette.color) {
|
||||
|
||||
/* Retrieve color palette for system preference */
|
||||
if (palette.color.media === "(prefers-color-scheme)") {
|
||||
var media = matchMedia("(prefers-color-scheme: light)")
|
||||
var input = document.querySelector(media.matches
|
||||
? "[data-md-color-media='(prefers-color-scheme: light)']"
|
||||
: "[data-md-color-media='(prefers-color-scheme: dark)']"
|
||||
)
|
||||
|
||||
/* Retrieve colors for system preference */
|
||||
palette.color.media = input.getAttribute("data-md-color-media"),
|
||||
palette.color.scheme = input.getAttribute("data-md-color-scheme"),
|
||||
palette.color.primary = input.getAttribute("data-md-color-primary"),
|
||||
palette.color.accent = input.getAttribute("data-md-color-accent")
|
||||
}
|
||||
|
||||
/* Set color palette */
|
||||
for (var [key, value] of Object.entries(palette.color))
|
||||
document.body.setAttribute("data-md-color-" + key, value)
|
||||
}
|
||||
</script>
|
||||
|
@ -39,13 +39,13 @@
|
||||
{% endif %}
|
||||
type="radio"
|
||||
name="__palette"
|
||||
id="__palette_{{ loop.index }}"
|
||||
id="__palette_{{ loop.index0 }}"
|
||||
/>
|
||||
{% if option.toggle %}
|
||||
<label
|
||||
class="md-header__button md-icon"
|
||||
title="{{ option.toggle.name }}"
|
||||
for="__palette_{{ loop.index0 or loop.length }}"
|
||||
for="__palette_{{ loop.index % loop.length }}"
|
||||
hidden
|
||||
>
|
||||
{% include ".icons/" ~ option.toggle.icon ~ ".svg" %}
|
||||
|
@ -20,25 +20,106 @@
|
||||
IN THE SOFTWARE.
|
||||
-->
|
||||
|
||||
<!-- Determine date of last update -->
|
||||
{% if page.meta %}
|
||||
{% if page.meta.git_revision_date_localized %}
|
||||
{% set updated = page.meta.git_revision_date_localized %}
|
||||
{% elif page.meta.revision_date %}
|
||||
{% set updated = page.meta.revision_date %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Determine date of creation -->
|
||||
{% if page.meta.git_creation_date_localized %}
|
||||
{% set created = page.meta.git_creation_date_localized %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Source file information -->
|
||||
<hr />
|
||||
<div class="md-source-file">
|
||||
<small>
|
||||
{% if updated or created or git_info or committers %}
|
||||
<aside class="md-source-file">
|
||||
|
||||
<!-- mkdocs-git-revision-date-localized-plugin -->
|
||||
{% if page.meta.git_revision_date_localized %}
|
||||
{{ lang.t("source.file.date.updated") }}:
|
||||
{{ page.meta.git_revision_date_localized }}
|
||||
{% if page.meta.git_creation_date_localized %}
|
||||
<br />
|
||||
{{ lang.t("source.file.date.created") }}:
|
||||
{{ page.meta.git_creation_date_localized }}
|
||||
{% endif %}
|
||||
|
||||
<!-- mkdocs-git-revision-date-plugin -->
|
||||
{% elif page.meta.revision_date %}
|
||||
{{ lang.t("source.file.date.updated") }}:
|
||||
{{ page.meta.revision_date }}
|
||||
<!-- Date of last update -->
|
||||
{% if updated %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.date.updated') }}">
|
||||
{% include ".icons/material/clock-edit-outline.svg" %}
|
||||
</span>
|
||||
{{ updated }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Date of creation -->
|
||||
{% if created %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.date.created') }}">
|
||||
{% include ".icons/material/clock-plus-outline.svg" %}
|
||||
</span>
|
||||
{{ created }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Authors (git-authors plugin) -->
|
||||
{% if git_info %}
|
||||
{% set authors = git_info.get("page_authors") %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.contributors') }}">
|
||||
{% if authors | length == 1 %}
|
||||
{% include ".icons/material/account.svg" %}
|
||||
{% else %}
|
||||
{% include ".icons/material/account-group.svg" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
<nav>
|
||||
{% for author in authors %}
|
||||
<a href="mailto:{{ author.email }}">
|
||||
{{- author.name -}}
|
||||
</a>
|
||||
{%- if loop.revindex > 1 %}, {% endif -%}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Authors (git-committers plugin) -->
|
||||
{% if committers %}
|
||||
<span class="md-source-file__fact">
|
||||
<span class="md-icon" title="{{ lang.t('source.file.contributors') }}">
|
||||
{% include ".icons/material/github.svg" %}
|
||||
</span>
|
||||
<span>GitHub</span>
|
||||
<nav>
|
||||
{% for author in committers[:4] %}
|
||||
<a
|
||||
href="{{ author.url }}"
|
||||
class="md-author"
|
||||
title="@{{ author.login }}"
|
||||
>
|
||||
{% set separator = "&" if "?" in author.avatar else "?" %}
|
||||
<img
|
||||
src="{{ author.avatar }}{{ separator }}size=72"
|
||||
alt="{{ author.name or 'GitHub user' }}"
|
||||
/>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
<!-- More authors -->
|
||||
{% set more = committers[4:] | length %}
|
||||
{% if more > 0 %}
|
||||
{% if page.edit_url %}
|
||||
<a
|
||||
href="{{ page.edit_url | replace('edit', 'blob') }}"
|
||||
class="md-author md-author--more"
|
||||
>
|
||||
+{{ more }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="md-author md-author--more">
|
||||
+{{ more }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
</span>
|
||||
{% endif %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
Loading…
Reference in New Issue
Block a user