test bkp
This commit is contained in:
1 parent
6ccab0347b
commit
954a2fd6d6
18684 files changed
+601012
-190
No files matched your search
@@ -0,0 +1,21 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
|
||||
__version__ = "9.7.3"
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) 2016-2025 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 functools
|
||||
import material
|
||||
import os
|
||||
|
||||
from glob import iglob
|
||||
from inspect import getfile
|
||||
from markdown import Markdown
|
||||
from pymdownx import emoji, twemoji_db
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Create twemoji index
|
||||
def twemoji(options: object, md: Markdown):
|
||||
paths = options.get("custom_icons", [])[:]
|
||||
return _load_twemoji_index(tuple(paths))
|
||||
|
||||
# Create emoji or icon
|
||||
def to_svg(
|
||||
index: str, shortname: str, alias: str, uc: str | None, alt: str,
|
||||
title: str, category: str, options: object, md: Markdown
|
||||
):
|
||||
if not uc:
|
||||
icons = md.inlinePatterns["emoji"].emoji_index["emoji"]
|
||||
|
||||
# Create and return element to host icon
|
||||
el = Element("span", { "class": options.get("classes", index) })
|
||||
el.text = md.htmlStash.store(_load(icons[shortname]["path"]))
|
||||
return el
|
||||
|
||||
# Delegate to `pymdownx.emoji` extension
|
||||
return emoji.to_svg(
|
||||
index, shortname, alias, uc, alt, title, category, options, md
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Load icon
|
||||
@functools.lru_cache(maxsize = None)
|
||||
def _load(file: str):
|
||||
with open(file, encoding = "utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
# Load twemoji index and add icons
|
||||
@functools.lru_cache(maxsize = None)
|
||||
def _load_twemoji_index(paths):
|
||||
index = {
|
||||
"name": "twemoji",
|
||||
"emoji": twemoji_db.emoji,
|
||||
"aliases": twemoji_db.aliases
|
||||
}
|
||||
|
||||
# Compute path to theme root and traverse all icon directories
|
||||
root = os.path.dirname(getfile(material))
|
||||
root = os.path.join(root, "templates", ".icons")
|
||||
for path in [*paths, root]:
|
||||
base = os.path.normpath(path)
|
||||
|
||||
# Index icons provided by the theme and via custom icons
|
||||
glob = os.path.join(base, "**", "*.svg")
|
||||
glob = iglob(os.path.normpath(glob), recursive = True)
|
||||
for file in glob:
|
||||
icon = file[len(base) + 1:-4].replace(os.path.sep, "-")
|
||||
|
||||
# Add icon to index
|
||||
name = f":{icon}:"
|
||||
if not any(name in index[key] for key in ["emoji", "aliases"]):
|
||||
index["emoji"][name] = { "name": name, "path": file }
|
||||
|
||||
# Return index
|
||||
return index
|
||||
@@ -0,0 +1,223 @@
|
||||
# Copyright (c) 2016-2025 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 logging
|
||||
|
||||
from material.utilities.filter import FileFilter, FilterConfig
|
||||
from mkdocs.structure.pages import _RelativePathTreeprocessor
|
||||
from markdown import Extension, Markdown
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
from mkdocs.exceptions import ConfigurationError
|
||||
from urllib.parse import urlparse
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class PreviewProcessor(Treeprocessor):
|
||||
"""
|
||||
A Markdown treeprocessor to enable instant previews on links.
|
||||
|
||||
Note that this treeprocessor is dependent on the `relpath` treeprocessor
|
||||
registered programmatically by MkDocs before rendering a page.
|
||||
"""
|
||||
|
||||
def __init__(self, md: Markdown, config: dict):
|
||||
"""
|
||||
Initialize the treeprocessor.
|
||||
|
||||
Arguments:
|
||||
md: The Markdown instance.
|
||||
config: The configuration.
|
||||
"""
|
||||
super().__init__(md)
|
||||
self.config = config
|
||||
|
||||
def run(self, root: Element):
|
||||
"""
|
||||
Run the treeprocessor.
|
||||
|
||||
Arguments:
|
||||
root: The root element of the parsed Markdown document.
|
||||
"""
|
||||
at = self.md.treeprocessors.get_index_for_name("relpath")
|
||||
|
||||
# Hack: Python Markdown has no notion of where it is, i.e., which file
|
||||
# is being processed. This seems to be a deliberate design decision, as
|
||||
# it is not possible to access the file path of the current page, but
|
||||
# it might also be an oversight that is now impossible to fix. However,
|
||||
# since this extension is only useful in the context of Material for
|
||||
# MkDocs, we can assume that the _RelativePathTreeprocessor is always
|
||||
# present, telling us the file path of the current page. If that ever
|
||||
# changes, we would need to wrap this extension in a plugin, but for
|
||||
# the time being we are sneaky and will probably get away with it.
|
||||
processor = self.md.treeprocessors[at]
|
||||
if not isinstance(processor, _RelativePathTreeprocessor):
|
||||
raise TypeError("Relative path processor not registered")
|
||||
|
||||
# Normalize configurations
|
||||
configurations = self.config["configurations"]
|
||||
configurations.append({
|
||||
"sources": self.config.get("sources"),
|
||||
"targets": self.config.get("targets")
|
||||
})
|
||||
|
||||
# Walk through all configurations - @todo refactor so that we don't
|
||||
# iterate multiple times over the same elements
|
||||
for configuration in configurations:
|
||||
|
||||
# Skip, if the configuration defines nothing – we could also fix
|
||||
# this in the file filter, but we first fix it here and check if
|
||||
# it generalizes well enough to other inclusion/exclusion sites,
|
||||
# because here, it would hinder the ability to automaticaly
|
||||
# include all sources, while excluding specific targets.
|
||||
if (
|
||||
not configuration.get("sources") and
|
||||
not configuration.get("targets")
|
||||
):
|
||||
continue
|
||||
|
||||
# Skip if page should not be considered
|
||||
filter = get_filter(configuration, "sources")
|
||||
if not filter(processor.file):
|
||||
continue
|
||||
|
||||
# Walk through all links and add preview attributes
|
||||
filter = get_filter(configuration, "targets")
|
||||
for el in root.iter("a"):
|
||||
href = el.get("href")
|
||||
if not href:
|
||||
continue
|
||||
|
||||
# Skip footnotes
|
||||
if "footnote-ref" in el.get("class", ""):
|
||||
continue
|
||||
|
||||
# Skip external links
|
||||
url = urlparse(href)
|
||||
if url.scheme or url.netloc:
|
||||
continue
|
||||
|
||||
# Add preview attribute to internal links
|
||||
for path in processor._possible_target_uris(
|
||||
processor.file, url.path,
|
||||
processor.config.use_directory_urls
|
||||
):
|
||||
target = processor.files.get_file_from_path(path)
|
||||
if not target:
|
||||
continue
|
||||
|
||||
# Include, if filter matches
|
||||
if filter(target):
|
||||
el.set("data-preview", "")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class PreviewExtension(Extension):
|
||||
"""
|
||||
A Markdown extension to enable instant previews on links.
|
||||
|
||||
This extensions allows to automatically add the `data-preview` attribute to
|
||||
internal links matching specific criteria, so Material for MkDocs renders a
|
||||
nice preview on hover as part of a tooltip. It is the recommended way to
|
||||
add previews to links in a programmatic way.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
"""
|
||||
self.config = {
|
||||
"configurations": [[], "Filter configurations"],
|
||||
"sources": [{}, "Link sources"],
|
||||
"targets": [{}, "Link targets"]
|
||||
}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def extendMarkdown(self, md: Markdown):
|
||||
"""
|
||||
Register Markdown extension.
|
||||
|
||||
Arguments:
|
||||
md: The Markdown instance.
|
||||
"""
|
||||
md.registerExtension(self)
|
||||
|
||||
# Create and register treeprocessor - we use the same priority as the
|
||||
# `relpath` treeprocessor, the latter of which is guaranteed to run
|
||||
# after our treeprocessor, so we can check the original Markdown URIs
|
||||
# before they are resolved to URLs.
|
||||
processor = PreviewProcessor(md, self.getConfigs())
|
||||
md.treeprocessors.register(processor, "preview", 0)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def get_filter(settings: dict, key: str):
|
||||
"""
|
||||
Get file filter from settings.
|
||||
|
||||
Arguments:
|
||||
settings: The settings.
|
||||
key: The key in the settings.
|
||||
|
||||
Returns:
|
||||
The file filter.
|
||||
"""
|
||||
config = FilterConfig()
|
||||
config.load_dict(settings.get(key) or {})
|
||||
|
||||
# Validate filter configuration
|
||||
errors, warnings = config.validate()
|
||||
for _, w in warnings:
|
||||
log.warning(
|
||||
f"Error reading filter configuration in '{key}':\n"
|
||||
f"{w}"
|
||||
)
|
||||
for _, e in errors:
|
||||
raise ConfigurationError(
|
||||
f"Error reading filter configuration in '{key}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Return file filter
|
||||
return FileFilter(config = config) # type: ignore
|
||||
|
||||
def makeExtension(**kwargs):
|
||||
"""
|
||||
Register Markdown extension.
|
||||
|
||||
Arguments:
|
||||
**kwargs: Configuration options.
|
||||
|
||||
Returns:
|
||||
The Markdown extension.
|
||||
"""
|
||||
return PreviewExtension(**kwargs)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.extensions.preview")
|
||||
@@ -0,0 +1,38 @@
|
||||
# Copyright (c) 2016-2025 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 colorama import Fore, Style
|
||||
from os import getenv
|
||||
from sys import stderr
|
||||
|
||||
# Silence this warning with NO_MKDOCS_2_WARNING=1
|
||||
if not getenv("NO_MKDOCS_2_WARNING"):
|
||||
print(
|
||||
"\n"
|
||||
f"{Fore.RED} │ ⚠ WARNING – MkDocs 2.0 is incompatible with Material for MkDocs{Style.RESET_ALL}\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL}\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} MkDocs 1.x is unmaintained. We recommend switching to Zensical, our\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} new static site generator, as soon as possible. We're providing an\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} analysis of the situation in this article:\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} \n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} \033[4mhttps://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/{Style.RESET_ALL}\n"
|
||||
f"{Style.RESET_ALL}",
|
||||
file=stderr
|
||||
)
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2016-2025 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 .structure import View
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Sort views by name
|
||||
def view_name(view: View):
|
||||
return view.name
|
||||
|
||||
# Sort views by post count
|
||||
def view_post_count(view: View):
|
||||
return len(view.posts)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
# Copyright (c) 2016-2025 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 mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import DictOfItems, Optional, SubConfig, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Author
|
||||
class Author(Config):
|
||||
name = Type(str)
|
||||
description = Type(str)
|
||||
avatar = Type(str)
|
||||
slug = Optional(Type(str))
|
||||
url = Optional(Type(str))
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Authors
|
||||
class Authors(Config):
|
||||
authors = DictOfItems(SubConfig(Author), default = {})
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2016-2025 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 collections.abc import Callable
|
||||
from mkdocs.config.config_options import Choice, Deprecated, Optional, Type
|
||||
from mkdocs.config.base import Config
|
||||
from pymdownx.slugs import slugify
|
||||
|
||||
from . import view_name
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Blog plugin configuration
|
||||
class BlogConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for blog
|
||||
blog_dir = Type(str, default = "blog")
|
||||
blog_toc = Type(bool, default = False)
|
||||
|
||||
# Settings for posts
|
||||
post_dir = Type(str, default = "{blog}/posts")
|
||||
post_date_format = Type(str, default = "long")
|
||||
post_url_date_format = Type(str, default = "yyyy/MM/dd")
|
||||
post_url_format = Type(str, default = "{date}/{slug}")
|
||||
post_url_max_categories = Type(int, default = 1)
|
||||
post_slugify = Type(Callable, default = slugify(case = "lower"))
|
||||
post_slugify_separator = Type(str, default = "-")
|
||||
post_excerpt = Choice(["optional", "required"], default = "optional")
|
||||
post_excerpt_max_authors = Type(int, default = 1)
|
||||
post_excerpt_max_categories = Type(int, default = 5)
|
||||
post_excerpt_separator = Type(str, default = "<!-- more -->")
|
||||
post_readtime = Type(bool, default = True)
|
||||
post_readtime_words_per_minute = Type(int, default = 265)
|
||||
|
||||
# Settings for archive
|
||||
archive = Type(bool, default = True)
|
||||
archive_name = Type(str, default = "blog.archive")
|
||||
archive_date_format = Type(str, default = "yyyy")
|
||||
archive_url_date_format = Type(str, default = "yyyy")
|
||||
archive_url_format = Type(str, default = "archive/{date}")
|
||||
archive_pagination = Optional(Type(bool))
|
||||
archive_pagination_per_page = Optional(Type(int))
|
||||
archive_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for categories
|
||||
categories = Type(bool, default = True)
|
||||
categories_name = Type(str, default = "blog.categories")
|
||||
categories_url_format = Type(str, default = "category/{slug}")
|
||||
categories_slugify = Type(Callable, default = slugify(case = "lower"))
|
||||
categories_slugify_separator = Type(str, default = "-")
|
||||
categories_sort_by = Type(Callable, default = view_name)
|
||||
categories_sort_reverse = Type(bool, default = False)
|
||||
categories_allowed = Type(list, default = [])
|
||||
categories_pagination = Optional(Type(bool))
|
||||
categories_pagination_per_page = Optional(Type(int))
|
||||
categories_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for authors
|
||||
authors = Type(bool, default = True)
|
||||
authors_file = Type(str, default = "{blog}/.authors.yml")
|
||||
authors_profiles = Type(bool, default = False)
|
||||
authors_profiles_name = Type(str, default = "blog.authors")
|
||||
authors_profiles_url_format = Type(str, default = "author/{slug}")
|
||||
authors_profiles_pagination = Optional(Type(bool))
|
||||
authors_profiles_pagination_per_page = Optional(Type(int))
|
||||
authors_profiles_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for pagination
|
||||
pagination = Type(bool, default = True)
|
||||
pagination_per_page = Type(int, default = 10)
|
||||
pagination_url_format = Type(str, default = "page/{page}")
|
||||
pagination_format = Type(str, default = "~2~")
|
||||
pagination_if_single_page = Type(bool, default = False)
|
||||
pagination_keep_content = Type(bool, default = False)
|
||||
|
||||
# Settings for drafts
|
||||
draft = Type(bool, default = False)
|
||||
draft_on_serve = Type(bool, default = True)
|
||||
draft_if_future_date = Type(bool, default = False)
|
||||
|
||||
# Deprecated settings
|
||||
pagination_template = Deprecated(moved_to = "pagination_format")
|
||||
File diff suppressed because it is too large.
Load diff
+51
@@ -0,0 +1,51 @@
|
||||
# Copyright (c) 2016-2025 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 re
|
||||
|
||||
from math import ceil
|
||||
|
||||
from .parser import ReadtimeParser
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Compute readtime - we first used the original readtime library, but the list
|
||||
# of dependencies it brings with it increased the size of the Docker image by
|
||||
# 20 MB (packed), which is an increase of 50%. For this reason, we adapt the
|
||||
# original readtime algorithm to our needs - see https://t.ly/fPZ7L
|
||||
def readtime(html: str, words_per_minute: int):
|
||||
parser = ReadtimeParser()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
|
||||
# Extract words from text and compute readtime in seconds
|
||||
words = len(re.split(r"\W+", "".join(parser.text)))
|
||||
seconds = ceil(words / words_per_minute * 60)
|
||||
|
||||
# Account for additional images
|
||||
delta = 12
|
||||
for _ in range(parser.images):
|
||||
seconds += delta
|
||||
if delta > 3: delta -= 1
|
||||
|
||||
# Return readtime in minutes
|
||||
return ceil(seconds / 60)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
# Copyright (c) 2016-2025 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
|
||||
|
||||
# TODO: Refactor the `void` set into a common module and import it from there
|
||||
# and not from the search plugin.
|
||||
from material.plugins.search.plugin import void
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Readtime parser
|
||||
class ReadtimeParser(HTMLParser):
|
||||
|
||||
# Initialize parser
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs = True)
|
||||
|
||||
# Tags to skip
|
||||
self.skip = set([
|
||||
"object", # Objects
|
||||
"script", # Scripts
|
||||
"style", # Styles
|
||||
"svg" # SVGs
|
||||
])
|
||||
|
||||
# Current context
|
||||
self.context = []
|
||||
|
||||
# Keep track of text and images
|
||||
self.text = []
|
||||
self.images = 0
|
||||
|
||||
# Called at the start of every HTML tag
|
||||
def handle_starttag(self, tag, attrs):
|
||||
# Collect images
|
||||
if tag == "img":
|
||||
self.images += 1
|
||||
|
||||
# Ignore self-closing tags
|
||||
if tag not in void:
|
||||
# Add tag to context
|
||||
self.context.append(tag)
|
||||
|
||||
# Called for the text contents of each tag
|
||||
def handle_data(self, data):
|
||||
# Collect text if not inside skip context
|
||||
if not self.skip.intersection(self.context):
|
||||
self.text.append(data)
|
||||
|
||||
# Called at the end of every HTML tag
|
||||
def handle_endtag(self, tag):
|
||||
if self.context and self.context[-1] == tag:
|
||||
# Remove tag from context
|
||||
self.context.pop()
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# Copyright (c) 2016-2025 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 logging
|
||||
import os
|
||||
import yaml
|
||||
|
||||
from copy import copy
|
||||
from markdown import Markdown
|
||||
from material.plugins.blog.author import Author
|
||||
from material.plugins.meta.plugin import MetaPlugin
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.files import File, Files
|
||||
from mkdocs.structure.nav import Link, Section
|
||||
from mkdocs.structure.pages import Page, _RelativePathTreeprocessor
|
||||
from mkdocs.structure.toc import get_toc
|
||||
from mkdocs.utils.meta import YAML_RE
|
||||
from re import Match
|
||||
from yaml import SafeLoader
|
||||
|
||||
from .config import PostConfig
|
||||
from .markdown import ExcerptTreeprocessor
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post
|
||||
class Post(Page):
|
||||
|
||||
# Initialize post - posts are never listed in the navigation, which is why
|
||||
# they will never include a title that was manually set, so we can omit it
|
||||
def __init__(self, file: File, config: MkDocsConfig):
|
||||
super().__init__(None, file, config)
|
||||
|
||||
# Resolve path relative to docs directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
|
||||
# Read contents and metadata immediately
|
||||
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
self.markdown = f.read()
|
||||
|
||||
# Sadly, MkDocs swallows any exceptions that occur during parsing.
|
||||
# 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
|
||||
# date formats are returned as strings and list are not properly
|
||||
# supported. Thus, we just use the relevants parts of `get_data`.
|
||||
match: Match = YAML_RE.match(self.markdown)
|
||||
if not match:
|
||||
raise PluginError(
|
||||
f"Error reading metadata of post '{path}' in '{docs}':\n"
|
||||
f"Expected metadata to be defined but found nothing"
|
||||
)
|
||||
|
||||
# Extract metadata and parse as YAML
|
||||
try:
|
||||
self.meta = yaml.load(match.group(1), SafeLoader) or {}
|
||||
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 author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading metadata of post '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Hack: if the meta plugin is registered, we need to move the call
|
||||
# to `on_page_markdown` here, because we need to merge the metadata
|
||||
# of the post with the metadata of any meta files prior to creating
|
||||
# the post configuration. To our current knowledge, it's the only
|
||||
# way to allow posts to receive metadata from meta files, because
|
||||
# posts must be loaded prior to constructing the navigation in
|
||||
# `on_files` but the meta plugin first runs in `on_page_markdown`.
|
||||
plugin: MetaPlugin = config.plugins.get("material/meta")
|
||||
if plugin:
|
||||
plugin.on_page_markdown(
|
||||
self.markdown, page = self, config = config, files = None
|
||||
)
|
||||
|
||||
# Initialize post configuration, but remove all keys that this plugin
|
||||
# doesn't care about, or they will be reported as invalid configuration
|
||||
self.config: PostConfig = PostConfig(file.abs_src_path)
|
||||
self.config.load_dict({
|
||||
key: self.meta[key] for key in (
|
||||
set(self.meta.keys()) &
|
||||
set(self.config.keys())
|
||||
)
|
||||
})
|
||||
|
||||
# Validate configuration and throw if errors occurred
|
||||
errors, warnings = self.config.validate()
|
||||
for _, w in warnings:
|
||||
log.warning(w)
|
||||
for k, e in errors:
|
||||
raise PluginError(
|
||||
f"Error reading metadata '{k}' of post '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Excerpts are subsets of posts that are used in pages like archive and
|
||||
# category views. They are not rendered as standalone pages, but are
|
||||
# rendered in the context of a view. Each post has a dedicated excerpt
|
||||
# instance which is reused when rendering views.
|
||||
self.excerpt: Excerpt = None
|
||||
|
||||
# Initialize authors and actegories
|
||||
self.authors: list[Author] = []
|
||||
self.categories: list[Category] = []
|
||||
|
||||
# Ensure template is set or use default
|
||||
self.meta.setdefault("template", "blog-post.html")
|
||||
|
||||
# Ensure template hides navigation
|
||||
self.meta["hide"] = self.meta.get("hide", [])
|
||||
if "navigation" not in self.meta["hide"]:
|
||||
self.meta["hide"].append("navigation")
|
||||
|
||||
# The contents and metadata were already read in the constructor (and not
|
||||
# in `read_source` as for pages), so this function must be set to a no-op
|
||||
def read_source(self, config: MkDocsConfig):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Excerpt
|
||||
class Excerpt(Page):
|
||||
|
||||
# Initialize an excerpt for the given post - we create the Markdown parser
|
||||
# when intitializing the excerpt in order to improve rendering performance
|
||||
# for excerpts, as they are reused across several different views, because
|
||||
# posts might be referenced from multiple different locations
|
||||
def __init__(self, post: Post, config: MkDocsConfig, files: Files):
|
||||
self.file = copy(post.file)
|
||||
self.post = post
|
||||
|
||||
# Set canonical URL, or we can't print excerpts when debugging the
|
||||
# blog plugin, as the `abs_url` property would be missing
|
||||
self._set_canonical_url(config.site_url)
|
||||
|
||||
# Initialize configuration and metadata
|
||||
self.config = post.config
|
||||
self.meta = post.meta
|
||||
|
||||
# Initialize authors and categories - note that views usually contain
|
||||
# subsets of those lists, which is why we need to manage them here
|
||||
self.authors: list[Author] = []
|
||||
self.categories: list[Category] = []
|
||||
|
||||
# Initialize content after separator - allow template authors to render
|
||||
# posts inline or to provide a link to the post's page
|
||||
self.more = None
|
||||
|
||||
# Initialize parser - note that we need to patch the configuration,
|
||||
# more specifically the table of contents extension
|
||||
config = _patch(config)
|
||||
self.md = Markdown(
|
||||
extensions = config.markdown_extensions,
|
||||
extension_configs = config.mdx_configs,
|
||||
)
|
||||
|
||||
# Register excerpt tree processor - this processor resolves anchors to
|
||||
# posts from within views, so they point to the correct location
|
||||
self.md.treeprocessors.register(
|
||||
ExcerptTreeprocessor(post),
|
||||
"excerpt",
|
||||
0
|
||||
)
|
||||
|
||||
# Register relative path tree processor - this processor resolves links
|
||||
# to other pages and assets, and is used by MkDocs itself
|
||||
self.md.treeprocessors.register(
|
||||
_RelativePathTreeprocessor(self.file, files, config),
|
||||
"relpath",
|
||||
1
|
||||
)
|
||||
|
||||
# Render an excerpt of the post on the given page - note that this is not
|
||||
# thread-safe because excerpts are shared across views, as it cuts down on
|
||||
# the cost of initialization. However, if in the future, we decide to render
|
||||
# posts and views concurrently, we must change this behavior.
|
||||
def render(self, page: Page, separator: str):
|
||||
self.file.url = page.url
|
||||
|
||||
# Retrieve excerpt tree processor and set page as base
|
||||
at = self.md.treeprocessors.get_index_for_name("excerpt")
|
||||
processor: ExcerptTreeprocessor = self.md.treeprocessors[at]
|
||||
processor.base = page
|
||||
|
||||
# Ensure that the excerpt includes a title in its content, since the
|
||||
# title is linked to the post when rendering - see https://t.ly/5Gg2F
|
||||
self.markdown = self.post.markdown
|
||||
if not self.post._title_from_render:
|
||||
self.markdown = "\n\n".join([f"# {self.post.title}", self.markdown])
|
||||
|
||||
# Convert Markdown to HTML and extract excerpt
|
||||
self.content = self.md.convert(self.markdown)
|
||||
self.content, *more = self.content.split(separator, 1)
|
||||
if more:
|
||||
self.more = more[0]
|
||||
|
||||
# Extract table of contents and reset post URL - if we wouldn't reset
|
||||
# the excerpt URL, linking to the excerpt from the view would not work
|
||||
self.toc = get_toc(getattr(self.md, "toc_tokens", []))
|
||||
self.file.url = self.post.url
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# View
|
||||
class View(Page):
|
||||
|
||||
# Parent view
|
||||
parent: View | Section
|
||||
|
||||
# Initialize view
|
||||
def __init__(self, name: str | None, file: File, config: MkDocsConfig):
|
||||
super().__init__(None, file, config)
|
||||
|
||||
# Initialize name of the view - note that views never pass a title to
|
||||
# the parent constructor, so the author can always override the title
|
||||
# that is used for rendering. However, for some purposes, like for
|
||||
# example sorting, we need something to compare.
|
||||
self.name = name
|
||||
|
||||
# Initialize posts and views
|
||||
self.posts: list[Post] = []
|
||||
self.views: list[View] = []
|
||||
|
||||
# Initialize pages for pagination
|
||||
self.pages: list[View] = []
|
||||
|
||||
# Set necessary metadata
|
||||
def read_source(self, config: MkDocsConfig):
|
||||
super().read_source(config)
|
||||
|
||||
# Ensure template is set or use default
|
||||
self.meta.setdefault("template", "blog.html")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Archive view
|
||||
class Archive(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Category view
|
||||
class Category(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Profile view
|
||||
class Profile(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Reference
|
||||
class Reference(Link):
|
||||
|
||||
# Initialize reference - this is essentially a crossover of pages and links,
|
||||
# as it inherits the metadata of the page and allows for anchors
|
||||
def __init__(self, title: str, url: str):
|
||||
super().__init__(title, url)
|
||||
self.meta = {}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Patch configuration
|
||||
def _patch(config: MkDocsConfig):
|
||||
config = copy(config)
|
||||
|
||||
# Copy parts of configuration that needs to be patched
|
||||
config.validation = copy(config.validation)
|
||||
config.validation.links = copy(config.validation.links)
|
||||
config.markdown_extensions = copy(config.markdown_extensions)
|
||||
config.mdx_configs = copy(config.mdx_configs)
|
||||
|
||||
# Make sure that the author did not add another instance of the table of
|
||||
# contents extension to the configuration, as this leads to weird behavior
|
||||
if "markdown.extensions.toc" in config.markdown_extensions:
|
||||
config.markdown_extensions.remove("markdown.extensions.toc")
|
||||
|
||||
# In order to render excerpts for posts, we need to make sure that the
|
||||
# table of contents extension is appropriately configured
|
||||
config.mdx_configs["toc"] = {
|
||||
**config.mdx_configs.get("toc", {}),
|
||||
**{
|
||||
"anchorlink": True, # Render headline as clickable
|
||||
"baselevel": 2, # Render h1 as h2 and so forth
|
||||
"permalink": False, # Remove permalinks
|
||||
"toc_depth": 2 # Remove everything below h2
|
||||
}
|
||||
}
|
||||
|
||||
# Additionally, we disable link validation when rendering excerpts, because
|
||||
# invalid links have already been reported when rendering the page
|
||||
links = config.validation.links
|
||||
links.not_found = logging.DEBUG
|
||||
links.absolute_links = logging.DEBUG
|
||||
links.unrecognized_links = logging.DEBUG
|
||||
|
||||
# Return patched configuration
|
||||
return config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.blog")
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
# Copyright (c) 2016-2025 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 mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import Optional, Type
|
||||
|
||||
from .options import PostDate, PostLinks, UniqueListOfItems
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post configuration
|
||||
class PostConfig(Config):
|
||||
authors = UniqueListOfItems(Type(str), default = [])
|
||||
categories = UniqueListOfItems(Type(str), default = [])
|
||||
date = PostDate()
|
||||
draft = Optional(Type(bool))
|
||||
pin = Type(bool, default = False)
|
||||
links = Optional(PostLinks())
|
||||
readtime = Optional(Type(int))
|
||||
slug = Optional(Type(str))
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Copyright (c) 2016-2025 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
|
||||
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.utils import get_relative_url
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Excerpt tree processor
|
||||
class ExcerptTreeprocessor(Treeprocessor):
|
||||
|
||||
# Initialize excerpt tree processor
|
||||
def __init__(self, page: Page, base: Page | None = None):
|
||||
self.page = page
|
||||
self.base = base
|
||||
|
||||
# Transform HTML after Markdown processing
|
||||
def run(self, root: Element):
|
||||
assert self.base
|
||||
main = True
|
||||
|
||||
# We're only interested in anchors, which is why we continue when the
|
||||
# link does not start with an anchor tag
|
||||
for el in root.iter("a"):
|
||||
anchor = el.get("href")
|
||||
if not anchor.startswith("#"):
|
||||
continue
|
||||
|
||||
# The main headline should link to the post page, not to a specific
|
||||
# anchor, which is why we remove the anchor in that case
|
||||
path = get_relative_url(self.page.url, self.base.url)
|
||||
if main:
|
||||
el.set("href", path)
|
||||
else:
|
||||
el.set("href", path + anchor)
|
||||
|
||||
# Main headline has been seen
|
||||
main = False
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# Copyright (c) 2016-2025 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 datetime import date, datetime, time, timezone
|
||||
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
|
||||
from mkdocs.config.config_options import ListOfItems, T
|
||||
from mkdocs.structure.files import Files
|
||||
from mkdocs.structure.nav import (
|
||||
Navigation, _add_parent_links, _data_to_navigation
|
||||
)
|
||||
from typing import Dict
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Date dictionary
|
||||
class DateDict(Dict[str, datetime]):
|
||||
|
||||
# Initialize date dictionary
|
||||
def __init__(self, data: dict):
|
||||
super().__init__(data)
|
||||
|
||||
# Ensure presence of `date.created`
|
||||
self.created: datetime = data["created"]
|
||||
|
||||
# Allow attribute access
|
||||
def __getattr__(self, name: str):
|
||||
if name in self:
|
||||
return self[name]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post date option
|
||||
class PostDate(BaseConfigOption[DateDict]):
|
||||
|
||||
# Initialize post dates
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# 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, 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] }
|
||||
|
||||
# Convert all date values to datetime
|
||||
for key, value in config[key_name].items():
|
||||
|
||||
# Handle datetime - since datetime is a subclass of date, we need
|
||||
# to check it first, or we lose the time - see https://t.ly/-KG9N
|
||||
if isinstance(value, datetime):
|
||||
# Set timezone to UTC if not set
|
||||
if value.tzinfo is None:
|
||||
config[key_name][key] = value.replace(tzinfo=timezone.utc)
|
||||
continue;
|
||||
|
||||
|
||||
# Handle date - we set 00:00:00 as the default time, if the author
|
||||
# only supplied a date, and convert it to datetime in UTC
|
||||
if isinstance(value, date):
|
||||
config[key_name][key] = datetime.combine(value, time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Initialize date dictionary
|
||||
config[key_name] = DateDict(config[key_name])
|
||||
|
||||
# Ensure each date value is of type datetime
|
||||
def run_validation(self, value: DateDict):
|
||||
for key in value:
|
||||
if not isinstance(value[key], datetime):
|
||||
raise ValidationError(
|
||||
f"Expected type: {date} or {datetime} "
|
||||
f"but received: {type(value[key])}"
|
||||
)
|
||||
|
||||
# Ensure presence of `date.created`
|
||||
if not value.created:
|
||||
raise ValidationError(
|
||||
"Expected 'created' date when using dictionary syntax"
|
||||
)
|
||||
|
||||
# Return date dictionary
|
||||
return value
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post links option
|
||||
class PostLinks(BaseConfigOption[Navigation]):
|
||||
|
||||
# Create navigation from structured items - we don't need to provide a
|
||||
# configuration object to the function, because it will not be used
|
||||
def run_validation(self, value: object):
|
||||
items = _data_to_navigation(value, Files([]), None)
|
||||
_add_parent_links(items)
|
||||
|
||||
# Return navigation
|
||||
return Navigation(items, [])
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Unique list of items
|
||||
class UniqueListOfItems(ListOfItems[T]):
|
||||
|
||||
# Ensure that each item is unique
|
||||
def run_validation(self, value: object):
|
||||
data = super().run_validation(value)
|
||||
return list(dict.fromkeys(data))
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) 2016-2025 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 mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Group plugin configuration
|
||||
class GroupConfig(Config):
|
||||
enabled = Type(bool, default = False)
|
||||
plugins = Type((list, dict))
|
||||
@@ -0,0 +1,161 @@
|
||||
# Copyright (c) 2016-2025 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 logging
|
||||
|
||||
from collections.abc import Callable
|
||||
from mkdocs.config.config_options import Plugins
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
|
||||
from .config import GroupConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Group plugin
|
||||
class GroupPlugin(BasePlugin[GroupConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize object attributes
|
||||
self.is_serve = False
|
||||
self.is_dirty = False
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
self.is_dirty = dirty
|
||||
|
||||
# If the group is enabled, conditionally load plugins - at first, this might
|
||||
# sound easier than it actually is, as we need to jump through some hoops to
|
||||
# ensure correct ordering among plugins. We're effectively initializing the
|
||||
# plugins that are part of the group after all MkDocs finished initializing
|
||||
# all other plugins, so we need to patch the order of the methods. Moreover,
|
||||
# we must use MkDocs existing plugin collection, or we might have collisions
|
||||
# with other plugins that are not part of the group. As so often, this is a
|
||||
# little hacky, but has huge potential making plugin configuration easier.
|
||||
# There's one little caveat: the `__init__` and `on_startup` methods of the
|
||||
# plugins that are part of the group are called after all other plugins, so
|
||||
# the `event_priority` decorator for `on_startup` methods is effectively
|
||||
# useless. However, the `on_startup` method is only intended to set up the
|
||||
# plugin and doesn't receive anything else than the invoked command and
|
||||
# whether we're running a dirty build, so there should be no problems.
|
||||
@event_priority(150)
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Retrieve plugin collection from configuration
|
||||
option: Plugins = dict(config._schema)["plugins"]
|
||||
assert isinstance(option, Plugins)
|
||||
|
||||
# Load all plugins in group
|
||||
self.plugins: dict[str, BasePlugin] = {}
|
||||
try:
|
||||
for name, plugin in self._load(option):
|
||||
self.plugins[name] = plugin
|
||||
|
||||
# The plugin could not be loaded, likely because it's not installed or
|
||||
# misconfigured, so we raise a plugin error for a nicer error message
|
||||
except Exception as e:
|
||||
raise PluginError(str(e))
|
||||
|
||||
# Patch order of plugin methods
|
||||
for events in option.plugins.events.values():
|
||||
self._patch(events, config)
|
||||
|
||||
# Invoke `on_startup` event for plugins in group
|
||||
command = "serve" if self.is_serve else "build"
|
||||
for method in option.plugins.events["startup"]:
|
||||
plugin = self._get_plugin(method)
|
||||
|
||||
# Ensure that we have a method bound to a plugin (and not a hook)
|
||||
if plugin and plugin in self.plugins.values():
|
||||
method(command = command, dirty = self.is_dirty)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Retrieve plugin instance for bound method or nothing
|
||||
def _get_plugin(self, method: Callable):
|
||||
return getattr(method, "__self__", None)
|
||||
|
||||
# Retrieve priority of plugin method
|
||||
def _get_priority(self, method: Callable):
|
||||
return getattr(method, "mkdocs_priority", 0)
|
||||
|
||||
# Retrieve position of plugin
|
||||
def _get_position(self, plugin: BasePlugin, config: MkDocsConfig) -> int:
|
||||
for at, (_, candidate) in enumerate(config.plugins.items()):
|
||||
if plugin == candidate:
|
||||
return at
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Load plugins that are part of the group
|
||||
def _load(self, option: Plugins):
|
||||
for name, data in option._parse_configs(self.config.plugins):
|
||||
yield option.load_plugin_with_namespace(name, data)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Patch order of plugin methods - all other plugin methods are already in
|
||||
# the right order, so we only need to check those that are part of the group
|
||||
# and bubble them up into the right location. Some plugin methods may define
|
||||
# priorities, so we need to make sure to order correctly within those.
|
||||
def _patch(self, methods: list[Callable], config: MkDocsConfig):
|
||||
position = self._get_position(self, config)
|
||||
for at in reversed(range(1, len(methods))):
|
||||
tail = methods[at - 1]
|
||||
head = methods[at]
|
||||
|
||||
# Skip if the plugin is not part of the group
|
||||
plugin = self._get_plugin(head)
|
||||
if not plugin or plugin not in self.plugins.values():
|
||||
continue
|
||||
|
||||
# Skip if the previous method has a higher priority than the current
|
||||
# one, because we know we can't swap them anyway
|
||||
if self._get_priority(tail) > self._get_priority(head):
|
||||
continue
|
||||
|
||||
# Ensure that we have a method bound to a plugin (and not a hook)
|
||||
plugin = self._get_plugin(tail)
|
||||
if not plugin:
|
||||
continue
|
||||
|
||||
# Both methods have the same priority, so we check if the ordering
|
||||
# of both methods is violated, and if it is, swap them
|
||||
if (position < self._get_position(plugin, config)):
|
||||
methods[at], methods[at - 1] = tail, head
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.group")
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) 2016-2025 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 mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Info plugin configuration
|
||||
class InfoConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
enabled_on_serve = Type(bool, default = False)
|
||||
|
||||
# Settings for archive
|
||||
archive = Type(bool, default = True)
|
||||
archive_stop_on_violation = Type(bool, default = True)
|
||||
@@ -0,0 +1,27 @@
|
||||
def get_exclusion_patterns():
|
||||
"""
|
||||
Regex patterns, which will be compared against directory and file names
|
||||
case-sensitively. https://docs.python.org/3/library/re.html#re.search is the
|
||||
matching function and scans the whole string to find any pattern match. Used
|
||||
with the https://pypi.org/project/regex/ module.
|
||||
|
||||
Additional remarks for pattern creation:
|
||||
- The compared paths will be always in POSIX format.
|
||||
- Each directory path will have a / at the end to allow to distinguish them
|
||||
from files.
|
||||
- Patterns for dynamic or custom paths like Virtual Environments (venv) or
|
||||
build site directories are created during plugin runtime.
|
||||
"""
|
||||
return [
|
||||
r"/__pycache__/", # Python cache directory
|
||||
|
||||
r"/\.DS_Store$", # macOS
|
||||
|
||||
r"/[^/]+\.zip$", # Generated files and folders
|
||||
|
||||
r"/[^/]*\.cache($|/)", # .cache files and folders
|
||||
|
||||
r"/\.vscode/", # Common autogenerated IDE directories
|
||||
r"/\.vs/",
|
||||
r"/\.idea/",
|
||||
]
|
||||
@@ -0,0 +1,563 @@
|
||||
# Copyright (c) 2016-2025 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 getpass
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import requests
|
||||
import site
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
from colorama import Fore, Style
|
||||
from importlib.metadata import distributions, version
|
||||
from io import BytesIO
|
||||
from markdown.extensions.toc import slugify
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.utils.yaml import get_yaml_loader
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
|
||||
from .config import InfoConfig
|
||||
from .patterns import get_exclusion_patterns
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Info plugin
|
||||
class InfoPlugin(BasePlugin[InfoConfig]):
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
|
||||
# Initialize empty members
|
||||
self.exclusion_patterns = []
|
||||
self.excluded_entries = []
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
|
||||
# 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 author must attach this archive to the bug report.
|
||||
@event_priority(100)
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# By default, the plugin is disabled when the documentation is served,
|
||||
# but not when it is built. This should nicely align with the expected
|
||||
# user experience when creating reproductions.
|
||||
if not self.config.enabled_on_serve and self.is_serve:
|
||||
return
|
||||
|
||||
# Resolve latest version
|
||||
url = "https://github.com/squidfunk/mkdocs-material/releases/latest"
|
||||
res = requests.get(url, allow_redirects = False)
|
||||
|
||||
# Check if we're running the latest version
|
||||
_, current = res.headers.get("location").rsplit("/", 1)
|
||||
present = version("mkdocs-material")
|
||||
if not present.startswith(current):
|
||||
log.error("Please upgrade to the latest version.")
|
||||
self._help_on_versions_and_exit(present, current)
|
||||
|
||||
# Exit if archive creation is disabled
|
||||
if not self.config.archive:
|
||||
sys.exit(1)
|
||||
|
||||
# Print message that we're creating a bug report
|
||||
log.info("Started archive creation for bug report")
|
||||
|
||||
# Check that there are no overrides in place - we need to use a little
|
||||
# hack to detect whether the custom_dir setting was used without parsing
|
||||
# mkdocs.yml again - we check at which position the directory provided
|
||||
# by the theme resides, and if it's not the first one, abort.
|
||||
if config.theme.custom_dir:
|
||||
log.error("Please remove 'custom_dir' setting.")
|
||||
self._help_on_customizations_and_exit()
|
||||
|
||||
# Check that there are no hooks in place - hooks can alter the behavior
|
||||
# of MkDocs in unpredictable ways, which is why they must be considered
|
||||
# being customizations. Thus, we can't offer support for debugging and
|
||||
# must abort here.
|
||||
if config.hooks:
|
||||
log.error("Please remove 'hooks' setting.")
|
||||
self._help_on_customizations_and_exit()
|
||||
|
||||
# Assure all paths that will be validated are absolute. Convert possible
|
||||
# relative config_file_path to absolute. Its absolute directory path is
|
||||
# being later used to resolve other paths.
|
||||
config.config_file_path = _convert_to_abs(config.config_file_path)
|
||||
config_file_parent = os.path.dirname(config.config_file_path)
|
||||
|
||||
# Convert relative custom_dir path to absolute. The Theme.custom_dir
|
||||
# property cannot be set, therefore a helper variable is used.
|
||||
if config.theme.custom_dir:
|
||||
abs_custom_dir = _convert_to_abs(
|
||||
config.theme.custom_dir,
|
||||
abs_prefix = config_file_parent
|
||||
)
|
||||
else:
|
||||
abs_custom_dir = ""
|
||||
|
||||
# Extract the absolute path to projects plugin's directory to explicitly
|
||||
# support path validation and dynamic exclusion for the plugin
|
||||
projects_plugin = config.plugins.get("material/projects")
|
||||
if projects_plugin:
|
||||
abs_projects_dir = _convert_to_abs(
|
||||
projects_plugin.config.projects_dir,
|
||||
abs_prefix = config_file_parent
|
||||
)
|
||||
else:
|
||||
abs_projects_dir = ""
|
||||
|
||||
# MkDocs removes the INHERIT configuration key during load, and doesn't
|
||||
# expose the information in any way, as the parent configuration is
|
||||
# merged into one. To validate that the INHERIT config file will be
|
||||
# included in the ZIP file the current config file must be loaded again
|
||||
# without parsing. Each file can have their own INHERIT key, so a list
|
||||
# of configurations is supported. The INHERIT path is converted during
|
||||
# load to absolute.
|
||||
loaded_configs = _load_yaml(config.config_file_path)
|
||||
if not isinstance(loaded_configs, list):
|
||||
loaded_configs = [loaded_configs]
|
||||
|
||||
# It can happen that the drive letter case is inconsistent on Windows.
|
||||
# Therefore, assure first character to be uppercase for the following
|
||||
# checks. See: https://t.ly/9t1SU
|
||||
site_prefixes = list(map(capitalize, site.PREFIXES))
|
||||
cwd = capitalize(os.getcwd())
|
||||
|
||||
# We need to make sure the user put every file in the current working
|
||||
# directory. To assure the reproduction inside the ZIP file can be run,
|
||||
# validate that the MkDocs paths are children of the current root.
|
||||
paths_to_validate = list(map(capitalize, [
|
||||
config.config_file_path,
|
||||
config.docs_dir,
|
||||
abs_custom_dir,
|
||||
abs_projects_dir,
|
||||
*[cfg.get("INHERIT", "") for cfg in loaded_configs]
|
||||
]))
|
||||
|
||||
# Convert relative hook paths to absolute path
|
||||
for hook in config.hooks:
|
||||
path = _convert_to_abs(hook, abs_prefix = config_file_parent)
|
||||
paths_to_validate.append(path)
|
||||
|
||||
# Remove valid paths from the list
|
||||
for path in list(paths_to_validate):
|
||||
if not path or path.startswith(cwd):
|
||||
paths_to_validate.remove(path)
|
||||
|
||||
# Report the invalid paths to the user
|
||||
if paths_to_validate:
|
||||
log.error("One or more paths aren't children of root")
|
||||
self._help_on_not_in_cwd(paths_to_validate)
|
||||
|
||||
# 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 author might have entered.
|
||||
archive = BytesIO()
|
||||
example = input("\nPlease name your bug report (2-4 words): ")
|
||||
example, _ = os.path.splitext(example)
|
||||
example = "-".join([present, slugify(example, "-")])
|
||||
|
||||
# Get local copy of the exclusion patterns
|
||||
self.exclusion_patterns = get_exclusion_patterns()
|
||||
self.excluded_entries = []
|
||||
|
||||
# Exclude the site_dir at project root
|
||||
if capitalize(config.site_dir).startswith(cwd):
|
||||
self.exclusion_patterns.append(_resolve_pattern(config.site_dir))
|
||||
|
||||
# Exclude the Virtual Environment directory. site.getsitepackages() has
|
||||
# inconsistent results across operating systems, and relies on the
|
||||
# PREFIXES that will contain the absolute path to the activated venv.
|
||||
for path in site_prefixes:
|
||||
if path.startswith(cwd):
|
||||
self.exclusion_patterns.append(_resolve_pattern(path))
|
||||
|
||||
# Guess other Virtual Environment paths in case we forget to activate
|
||||
# them or in case we have multiple. Making sure which venv is activated
|
||||
# is not necessary, as it is an optional step in the guidelines.
|
||||
for abs_root, dirnames, filenames in os.walk(os.getcwd()):
|
||||
for filename in filenames:
|
||||
if filename.lower() != "pyvenv.cfg":
|
||||
continue
|
||||
|
||||
path = capitalize(abs_root)
|
||||
|
||||
if path not in site_prefixes:
|
||||
print(f"Possible inactive venv: {path}")
|
||||
self.exclusion_patterns.append(_resolve_pattern(path))
|
||||
|
||||
# Exclude site_dir for projects
|
||||
if projects_plugin:
|
||||
for path in glob.iglob(
|
||||
pathname = projects_plugin.config.projects_config_files,
|
||||
root_dir = abs_projects_dir,
|
||||
recursive = True
|
||||
):
|
||||
current_config_file = os.path.join(abs_projects_dir, path)
|
||||
project_config = _get_project_config(current_config_file)
|
||||
pattern = _resolve_pattern(project_config.site_dir)
|
||||
self.exclusion_patterns.append(pattern)
|
||||
|
||||
# Track dotpath inclusion to inform about it later
|
||||
contains_dotpath: bool = False
|
||||
|
||||
# Create self-contained example from project
|
||||
files: list[str] = []
|
||||
with ZipFile(archive, "a", ZIP_DEFLATED, False) as f:
|
||||
for abs_root, dirnames, filenames in os.walk(os.getcwd()):
|
||||
# Set and print progress indicator
|
||||
indicator = f"Processing: {abs_root}"
|
||||
print(indicator, end="\r", flush=True)
|
||||
|
||||
# Prune the folders in-place to prevent their processing
|
||||
for name in list(dirnames):
|
||||
# Resolve the absolute directory path
|
||||
path = os.path.join(abs_root, name)
|
||||
|
||||
# Exclude the directory and all subdirectories
|
||||
if self._is_excluded(path):
|
||||
dirnames.remove(name)
|
||||
continue
|
||||
|
||||
# Warn about .dotdirectories
|
||||
if _is_dotpath(path, log_warning = True):
|
||||
contains_dotpath = True
|
||||
|
||||
# Write files to the in-memory archive
|
||||
for name in filenames:
|
||||
# Resolve the absolute file path
|
||||
path = os.path.join(abs_root, name)
|
||||
|
||||
# Exclude the file
|
||||
if self._is_excluded(path):
|
||||
continue
|
||||
|
||||
# Warn about .dotfiles
|
||||
if _is_dotpath(path, log_warning = True):
|
||||
contains_dotpath = True
|
||||
|
||||
# Resolve the relative path to create a matching structure
|
||||
path = os.path.relpath(path, os.path.curdir)
|
||||
f.write(path, os.path.join(example, path))
|
||||
|
||||
# Clear the line for the next indicator
|
||||
print(" " * len(indicator), end="\r", flush=True)
|
||||
|
||||
# Add information on installed packages
|
||||
f.writestr(
|
||||
os.path.join(example, "requirements.lock.txt"),
|
||||
"\n".join(sorted([
|
||||
"==".join([package.name, package.version])
|
||||
for package in distributions()
|
||||
]))
|
||||
)
|
||||
|
||||
# Try to get login to replace it with USERNAME placeholder
|
||||
try:
|
||||
username = getpass.getuser()
|
||||
except Exception:
|
||||
username = "USERNAME"
|
||||
|
||||
# Add information on platform
|
||||
f.writestr(
|
||||
os.path.join(example, "platform.json"),
|
||||
json.dumps(
|
||||
{
|
||||
"system": platform.platform(),
|
||||
"architecture": platform.architecture(),
|
||||
"python": platform.python_version(),
|
||||
"cwd": os.getcwd(),
|
||||
"command": " ".join([
|
||||
sys.argv[0].rsplit(os.sep, 1)[-1],
|
||||
*sys.argv[1:]
|
||||
]),
|
||||
"env:$PYTHONPATH": os.getenv("PYTHONPATH", ""),
|
||||
"env:$VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", ""),
|
||||
"sys.path": sys.path,
|
||||
"excluded_entries": self.excluded_entries
|
||||
},
|
||||
default = str,
|
||||
indent = 2
|
||||
).replace(username, "USERNAME")
|
||||
)
|
||||
|
||||
# Retrieve list of processed files
|
||||
for a in f.filelist:
|
||||
# Highlight .dotpaths in a more explicit manner
|
||||
color = (Fore.LIGHTYELLOW_EX if "/." in a.filename
|
||||
else Fore.LIGHTBLACK_EX)
|
||||
files.append("".join([
|
||||
color, a.filename, " ",
|
||||
_size(a.compress_size)
|
||||
]))
|
||||
|
||||
# Finally, write archive to disk
|
||||
buffer = archive.getbuffer()
|
||||
with open(f"{example}.zip", "wb") as f:
|
||||
f.write(archive.getvalue())
|
||||
|
||||
# Print summary
|
||||
log.info("Archive successfully created:")
|
||||
print(Style.NORMAL)
|
||||
|
||||
# Print archive file names
|
||||
files.sort()
|
||||
for file in files:
|
||||
print(f" {file}")
|
||||
|
||||
# Print archive name
|
||||
print(Style.RESET_ALL)
|
||||
print("".join([
|
||||
" ", f.name, " ",
|
||||
_size(buffer.nbytes, 10)
|
||||
]))
|
||||
|
||||
# Print warning when file size is excessively large
|
||||
print(Style.RESET_ALL)
|
||||
if buffer.nbytes > 1000000:
|
||||
log.warning("Archive exceeds recommended maximum size of 1 MB")
|
||||
|
||||
# Print warning when file contains hidden .dotpaths
|
||||
if contains_dotpath:
|
||||
log.warning(
|
||||
"Archive contains dotpaths, which could contain sensitive "
|
||||
"information.\nPlease review them at the bottom of the list "
|
||||
"and share only necessary data to reproduce the issue."
|
||||
)
|
||||
|
||||
# Aaaaaand done
|
||||
sys.exit(1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Print help on versions and exit
|
||||
def _help_on_versions_and_exit(self, have, need):
|
||||
print(Fore.RED)
|
||||
print(" When reporting issues, please first upgrade to the latest")
|
||||
print(" version of Material for MkDocs, as the problem might already")
|
||||
print(" be fixed in the latest version. This helps reduce duplicate")
|
||||
print(" efforts and saves us maintainers time.")
|
||||
print(Style.NORMAL)
|
||||
print(f" Please update from {have} to {need}.")
|
||||
print(Style.RESET_ALL)
|
||||
print(f" pip install --upgrade --force-reinstall mkdocs-material")
|
||||
print(Style.NORMAL)
|
||||
|
||||
# Exit, unless explicitly told not to
|
||||
if self.config.archive_stop_on_violation:
|
||||
sys.exit(1)
|
||||
|
||||
# Print help on customizations and exit
|
||||
def _help_on_customizations_and_exit(self):
|
||||
print(Fore.RED)
|
||||
print(" When reporting issues, you must remove all customizations")
|
||||
print(" and check if the problem persists. If not, the problem is")
|
||||
print(" caused by your overrides. Please understand that we can't")
|
||||
print(" help you debug your customizations. Please remove:")
|
||||
print(Style.NORMAL)
|
||||
print(" - theme.custom_dir")
|
||||
print(" - hooks")
|
||||
print(Fore.YELLOW)
|
||||
print(" Additionally, please remove all third-party JavaScript or")
|
||||
print(" CSS not explicitly mentioned in our documentation:")
|
||||
print(Style.NORMAL)
|
||||
print(" - extra_css")
|
||||
print(" - extra_javascript")
|
||||
print(Fore.YELLOW)
|
||||
print(" If you're using customizations from the theme's documentation")
|
||||
print(" and you want to report a bug specific to those customizations")
|
||||
print(" then set the 'archive_stop_on_violation: false' option in the")
|
||||
print(" info plugin config.")
|
||||
print(Style.RESET_ALL)
|
||||
|
||||
# Exit, unless explicitly told not to
|
||||
if self.config.archive_stop_on_violation:
|
||||
sys.exit(1)
|
||||
|
||||
# Print help on not in current working directory and exit
|
||||
def _help_on_not_in_cwd(self, outside_root):
|
||||
print(Fore.RED)
|
||||
print(" The current working (root) directory:\n")
|
||||
print(f" {os.getcwd()}\n")
|
||||
print(" is not a parent of the following paths:")
|
||||
print(Style.NORMAL)
|
||||
for path in outside_root:
|
||||
print(f" {path}")
|
||||
print("\n To assure that all project files are found please adjust")
|
||||
print(" your config or file structure and put everything within the")
|
||||
print(" root directory of the project.")
|
||||
print("\n Please also make sure `mkdocs build` is run in the actual")
|
||||
print(" root directory of the project.")
|
||||
print(Style.RESET_ALL)
|
||||
|
||||
# Exit, unless explicitly told not to
|
||||
if self.config.archive_stop_on_violation:
|
||||
sys.exit(1)
|
||||
|
||||
# Check if path is excluded and should be omitted from the zip. Use pattern
|
||||
# matching for files and folders, and lookahead specific files in folders to
|
||||
# skip them. Side effect: Save excluded paths to save them in the zip file.
|
||||
def _is_excluded(self, abspath: str) -> bool:
|
||||
|
||||
# Resolve the path into POSIX format to match the patterns
|
||||
pattern_path = _resolve_pattern(abspath, return_path = True)
|
||||
for pattern in self.exclusion_patterns:
|
||||
if re.search(pattern, pattern_path):
|
||||
log.debug(f"Excluded pattern '{pattern}': {abspath}")
|
||||
self.excluded_entries.append(f"{pattern} - {pattern_path}")
|
||||
return True
|
||||
|
||||
# File exclusion should be limited to pattern matching
|
||||
if os.path.isfile(abspath):
|
||||
return False
|
||||
|
||||
# Projects, which don't use the projects plugin for multi-language
|
||||
# support could have separate build folders for each config file or
|
||||
# language. Therefore, we exclude them with the assumption a site_dir
|
||||
# contains the sitemap file. Example of such a setup: https://t.ly/DLQcy
|
||||
sitemap_gz = os.path.join(abspath, "sitemap.xml.gz")
|
||||
if os.path.exists(sitemap_gz):
|
||||
log.debug(f"Excluded site_dir: {abspath}")
|
||||
self.excluded_entries.append(f"sitemap.xml.gz - {pattern_path}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Print human-readable size
|
||||
def _size(value, factor = 1):
|
||||
color = Fore.GREEN
|
||||
if value > 100000 * factor: color = Fore.RED
|
||||
elif value > 25000 * factor: color = Fore.YELLOW
|
||||
for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
|
||||
if abs(value) < 1000.0:
|
||||
return f"{color}{value:3.1f} {unit}"
|
||||
value /= 1000.0
|
||||
|
||||
# Get the absolute path with set prefix. To validate if a file is inside the
|
||||
# current working directory it needs to be absolute, so that it is possible to
|
||||
# check the prefix.
|
||||
def _convert_to_abs(path: str, abs_prefix: str = None) -> str:
|
||||
if os.path.isabs(path): return path
|
||||
if abs_prefix is None: abs_prefix = os.getcwd()
|
||||
return os.path.normpath(os.path.join(abs_prefix, path))
|
||||
|
||||
# Get the loaded config, or a list with all loaded configs. MkDocs removes the
|
||||
# INHERIT configuration key during load, and doesn't expose the information in
|
||||
# any way, as the parent configuration is merged into one. The INHERIT path is
|
||||
# needed for validation. This custom YAML loader replicates MkDocs' loading
|
||||
# logic. Side effect: It converts the INHERIT path to absolute.
|
||||
def _load_yaml(abs_src_path: str):
|
||||
|
||||
with open(abs_src_path, encoding ="utf-8-sig") as file:
|
||||
source = file.read()
|
||||
|
||||
try:
|
||||
result = yaml.load(source, Loader = get_yaml_loader()) or {}
|
||||
except yaml.YAMLError:
|
||||
result = {}
|
||||
|
||||
if "INHERIT" in result:
|
||||
relpath = result.get('INHERIT')
|
||||
parent_path = os.path.dirname(abs_src_path)
|
||||
abspath = _convert_to_abs(relpath, abs_prefix = parent_path)
|
||||
if os.path.exists(abspath):
|
||||
result["INHERIT"] = abspath
|
||||
log.debug(f"Loading inherited configuration file: {abspath}")
|
||||
parent = _load_yaml(abspath)
|
||||
if isinstance(parent, list):
|
||||
result = [result, *parent]
|
||||
elif isinstance(parent, dict):
|
||||
result = [result, parent]
|
||||
|
||||
return result
|
||||
|
||||
# Get a normalized POSIX path for the pattern matching with removed current
|
||||
# working directory prefix. Directory paths end with a '/' to allow more control
|
||||
# in the pattern creation for files and directories. The patterns are matched
|
||||
# using the search function, so they are prefixed with ^ for specificity.
|
||||
def _resolve_pattern(abspath: str, return_path: bool = False):
|
||||
path = capitalize(abspath).replace(capitalize(os.getcwd()), "", 1)
|
||||
path = path.replace(os.sep, "/").rstrip("/")
|
||||
|
||||
if not path:
|
||||
return "/"
|
||||
|
||||
# Check abspath, as the file needs to exist
|
||||
if not os.path.isfile(abspath):
|
||||
path = path + "/"
|
||||
|
||||
return path if return_path else f"^{path}"
|
||||
|
||||
# Get project configuration with resolved absolute paths for validation
|
||||
def _get_project_config(project_config_file: str):
|
||||
with open(project_config_file, encoding="utf-8-sig") as file:
|
||||
config = MkDocsConfig(config_file_path = project_config_file)
|
||||
config.load_file(file)
|
||||
|
||||
# MkDocs transforms site_dir to absolute path during validation
|
||||
config.validate()
|
||||
|
||||
return config
|
||||
|
||||
# Check if the path is a .dotpath. A warning can also be issued when the param
|
||||
# is set. The function also returns a boolean to track results outside it.
|
||||
def _is_dotpath(path: str, log_warning: bool = False) -> bool:
|
||||
posix_path = _resolve_pattern(path, return_path = True)
|
||||
name = posix_path.rstrip("/").rsplit("/", 1)[-1]
|
||||
if name.startswith("."):
|
||||
if log_warning:
|
||||
log.warning(f"The following .dotpath will be included: {path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
# It can happen that the drive letter case is inconsistent on Windows.
|
||||
# Capitalize the first character keeping the rest the same for comparison.
|
||||
# See: https://t.ly/9t1SU
|
||||
def capitalize(path: str):
|
||||
return path[0].upper() + path[1:] if path else path
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.info")
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2016-2025 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 mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin configuration
|
||||
class MetaConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for meta files
|
||||
meta_file = Type(str, default = ".meta.yml")
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2016-2025 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 logging
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from mergedeep import Strategy, merge
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.files import InclusionLevel
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from yaml import SafeLoader, load
|
||||
|
||||
from .config import MetaConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin
|
||||
class MetaPlugin(BasePlugin[MetaConfig]):
|
||||
|
||||
# Construct metadata mapping
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize mapping
|
||||
self.meta = {}
|
||||
|
||||
# Resolve and load meta files in docs directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
for file in files:
|
||||
name = posixpath.basename(file.src_uri)
|
||||
if not name == self.config.meta_file:
|
||||
continue
|
||||
|
||||
# Exclude meta file from site directory - explicitly excluding the
|
||||
# meta file allows the author to use a file name without '.' prefix
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Open file and parse as YAML
|
||||
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
path = file.src_path
|
||||
try:
|
||||
self.meta[path] = load(f, SafeLoader)
|
||||
|
||||
# The meta file could not be loaded because of a syntax error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Set metadata for page, if applicable (run earlier)
|
||||
@event_priority(50)
|
||||
def on_page_markdown(self, markdown, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Start with a clean state, as we first need to apply all meta files
|
||||
# that are relevant to the current page, and then merge the page meta
|
||||
# on top of that to ensure that the page meta always takes precedence
|
||||
# over meta files - see https://t.ly/kvCRn
|
||||
meta = {}
|
||||
|
||||
# Merge matching meta files in level-order
|
||||
strategy = Strategy.TYPESAFE_ADDITIVE
|
||||
for path, defaults in self.meta.items():
|
||||
if not page.file.src_path.startswith(os.path.dirname(path)):
|
||||
continue
|
||||
|
||||
# Skip if meta file was already merged - this happens in case of
|
||||
# blog posts, as they need to be merged when posts are constructed,
|
||||
# which is why we need to keep track of which meta files are applied
|
||||
# to what pages using the `__extends` key.
|
||||
page.meta.setdefault("__extends", [])
|
||||
if path in page.meta["__extends"]:
|
||||
continue
|
||||
|
||||
# Try to merge metadata
|
||||
try:
|
||||
merge(meta, defaults, strategy = strategy)
|
||||
page.meta["__extends"].append(path)
|
||||
|
||||
# Merging the metadata with the given strategy resulted in an error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
raise PluginError(
|
||||
f"Error merging meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Ensure page metadata is merged last, so the author can override any
|
||||
# defaults from the meta files, or even remove them entirely
|
||||
page.meta = merge(meta, page.meta, strategy = strategy)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.meta")
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2016-2025 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 mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Offline plugin configuration
|
||||
class OfflineConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) 2016-2025 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.plugins import BasePlugin, event_priority
|
||||
|
||||
from .config import OfflineConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Offline plugin
|
||||
class OfflinePlugin(BasePlugin[OfflineConfig]):
|
||||
|
||||
# Set configuration for offline build
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Ensure correct resolution of links when viewing the site from the
|
||||
# file system by disabling directory URLs
|
||||
config.use_directory_urls = False
|
||||
|
||||
# Append iframe-worker to polyfills/shims
|
||||
config.extra["polyfills"] = config.extra.get("polyfills", [])
|
||||
if not any("iframe-worker" in url for url in config.extra["polyfills"]):
|
||||
script = "https://unpkg.com/iframe-worker/shim"
|
||||
config.extra["polyfills"].append(script)
|
||||
|
||||
# Add support for offline search (run latest) - the search index is copied
|
||||
# and inlined into a script, so that it can be used without a server
|
||||
@event_priority(-100)
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Ensure presence of search index
|
||||
path = os.path.join(config.site_dir, "search")
|
||||
file = os.path.join(path, "search_index.json")
|
||||
if not os.path.isfile(file):
|
||||
return
|
||||
|
||||
# Obtain search index contents
|
||||
with open(file, encoding = "utf-8") as f:
|
||||
data = f.read()
|
||||
|
||||
# Inline search index contents into script
|
||||
file = os.path.join(path, "search_index.js")
|
||||
with open(file, "w", encoding = "utf-8") as f:
|
||||
f.write(f"var __index = {data}")
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2016-2025 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 ListOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Optimize plugin configuration
|
||||
class OptimizeConfig(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/optimize")
|
||||
|
||||
# Settings for optimization
|
||||
optimize = Type(bool, default = True)
|
||||
optimize_png = Type(bool, default = True)
|
||||
optimize_png_speed = Type(int, default = 3)
|
||||
optimize_png_strip = Type(bool, default = True)
|
||||
optimize_jpg = Type(bool, default = True)
|
||||
optimize_jpg_quality = Type(int, default = 60)
|
||||
optimize_jpg_progressive = Type(bool, default = True)
|
||||
optimize_include = ListOfItems(Type(str), default = [])
|
||||
optimize_exclude = ListOfItems(Type(str), default = [])
|
||||
|
||||
# Settings for reporting
|
||||
print_gain = Type(bool, default = True)
|
||||
print_gain_summary = Type(bool, default = True)
|
||||
@@ -0,0 +1,388 @@
|
||||
# Copyright (c) 2016-2025 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 functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fnmatch import fnmatch
|
||||
from colorama import Fore, Style
|
||||
from concurrent.futures import Future
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from hashlib import sha1
|
||||
from mkdocs import utils
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin
|
||||
from mkdocs.structure.files import File
|
||||
from shutil import which
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from .config import OptimizeConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Optimize plugin
|
||||
class OptimizePlugin(BasePlugin[OptimizeConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Manifest
|
||||
manifest: dict[str, str] = {}
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
|
||||
# Initialize thread pool
|
||||
self.pool = ThreadPoolExecutor(self.config.concurrency)
|
||||
self.pool_jobs: dict[str, Future] = {}
|
||||
|
||||
# Resolve and load manifest
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Resolve cache directory (once) - this is necessary, so the cache is
|
||||
# always relative to the configuration file, and thus project, and not
|
||||
# relative to the current working directory, or it would not work with
|
||||
# the projects plugin.
|
||||
path = os.path.abspath(self.config.cache_dir)
|
||||
if path != self.config.cache_dir:
|
||||
self.config.cache_dir = os.path.join(
|
||||
os.path.dirname(config.config_file_path),
|
||||
os.path.normpath(self.config.cache_dir)
|
||||
)
|
||||
|
||||
# Ensure cache directory exists
|
||||
os.makedirs(self.config.cache_dir, exist_ok = True)
|
||||
|
||||
# Initialize manifest
|
||||
self.manifest_file = os.path.join(
|
||||
self.config.cache_dir, "manifest.json"
|
||||
)
|
||||
|
||||
# Load manifest if it exists and the cache should be used
|
||||
if os.path.isfile(self.manifest_file) and self.config.cache:
|
||||
try:
|
||||
with open(self.manifest_file) as f:
|
||||
self.manifest = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Initialize optimization pipeline
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if media files should not be optimized
|
||||
if not self.config.optimize:
|
||||
return
|
||||
|
||||
# Filter all optimizable media files and steal reponsibility from MkDocs
|
||||
# by removing them from the files collection. Then, start a concurrent
|
||||
# job that checks if an image was already optimized and can be returned
|
||||
# from the cache, or optimize it accordingly.
|
||||
for file in files.media_files():
|
||||
if self._is_excluded(file):
|
||||
continue
|
||||
|
||||
# Spawn concurrent job to optimize the given image and add future
|
||||
# to job dictionary, as it returns the file we need to copy later
|
||||
path = os.path.join(self.config.cache_dir, file.src_path)
|
||||
self.pool_jobs[file.abs_src_path] = self.pool.submit(
|
||||
self._optimize_image, file, path, config
|
||||
)
|
||||
|
||||
# Steal responsibility from MkDocs
|
||||
files.remove(file)
|
||||
|
||||
# Finish optimization pipeline
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if media files should not be optimized
|
||||
if not self.config.optimize:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs - we need to wait for all jobs to finish
|
||||
# before we can copy the optimized files to the output directory. If an
|
||||
# exception occurred in one of the jobs, we raise it here, so the build
|
||||
# fails and the author can fix the issue.
|
||||
for path, future in self.pool_jobs.items():
|
||||
if future.exception():
|
||||
raise future.exception()
|
||||
else:
|
||||
file: File = future.result()
|
||||
file.copy_file()
|
||||
|
||||
# Save manifest if cache should be used
|
||||
if self.config.cache:
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# Compute and print gains through optimization
|
||||
if self.config.print_gain_summary:
|
||||
print(Style.NORMAL)
|
||||
print(f" Optimizations:")
|
||||
|
||||
# Print summary for file extension
|
||||
for seek in [".png", ".jpg"]:
|
||||
size = size_opt = 0
|
||||
for path, future in self.pool_jobs.items():
|
||||
file: File = future.result()
|
||||
|
||||
# Skip files that are not of the given type
|
||||
_, extension = os.path.splitext(path)
|
||||
extension = ".jpg" if extension == ".jpeg" else extension
|
||||
if extension != seek:
|
||||
continue
|
||||
|
||||
# Compute size before and after optimization
|
||||
size += os.path.getsize(path)
|
||||
size_opt += os.path.getsize(file.abs_dest_path)
|
||||
|
||||
# Compute absolute and relative gain
|
||||
if size and size_opt:
|
||||
gain_abs = size - size_opt
|
||||
gain_rel = (1 - size_opt / size) * 100
|
||||
|
||||
# Print summary for files
|
||||
print(
|
||||
f" *{seek} {Fore.GREEN}{_size(size_opt)}"
|
||||
f"{Fore.WHITE}{Style.DIM} ↓ "
|
||||
f"{_size(gain_abs)} [{gain_rel:3.1f}%]"
|
||||
f"{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
# Reset all styles
|
||||
print(Style.RESET_ALL)
|
||||
|
||||
# Save manifest on shutdown
|
||||
def on_shutdown(self):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Shutdown thread pool - if we're on Python 3.9 and above, cancel all
|
||||
# pending futures that have not yet been scheduled
|
||||
if sys.version_info >= (3, 9):
|
||||
self.pool.shutdown(cancel_futures = True)
|
||||
else:
|
||||
self.pool.shutdown()
|
||||
|
||||
# Save manifest if cache should be used
|
||||
if self.manifest and self.config.cache:
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Check if a file can be optimized
|
||||
def _is_optimizable(self, file: File):
|
||||
|
||||
# Check if PNG images should be optimized
|
||||
if file.url.endswith((".png")):
|
||||
return self.config.optimize_png
|
||||
|
||||
# Check if JPG images should be optimized
|
||||
if file.url.endswith((".jpg", ".jpeg")):
|
||||
return self.config.optimize_jpg
|
||||
|
||||
# File can not be optimized by the plugin
|
||||
return False
|
||||
|
||||
# Check if the given file is excluded
|
||||
def _is_excluded(self, file: File):
|
||||
if not self._is_optimizable(file):
|
||||
return True
|
||||
|
||||
# Check if file matches one of the inclusion patterns
|
||||
path = file.src_path
|
||||
if self.config.optimize_include:
|
||||
for pattern in self.config.optimize_include:
|
||||
if fnmatch(file.src_uri, pattern):
|
||||
return False
|
||||
|
||||
# File is not included
|
||||
log.debug(f"Excluding file '{path}' due to inclusion patterns")
|
||||
return True
|
||||
|
||||
# Check if file matches one of the exclusion patterns
|
||||
for pattern in self.config.optimize_exclude:
|
||||
if fnmatch(file.src_uri, pattern):
|
||||
log.debug(f"Excluding file '{path}' due to exclusion patterns")
|
||||
return True
|
||||
|
||||
# File is not excluded
|
||||
return False
|
||||
|
||||
# Optimize image and write to cache
|
||||
def _optimize_image(self, file: File, path: str, config: MkDocsConfig):
|
||||
with open(file.abs_src_path, "rb") as f:
|
||||
data = f.read()
|
||||
hash = sha1(data).hexdigest()
|
||||
|
||||
# Check if file hash changed, so we need to optimize again
|
||||
prev = self.manifest.get(file.url, "")
|
||||
if hash != prev or not os.path.isfile(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
|
||||
# Optimize PNG image using pngquant
|
||||
if file.url.endswith((".png")):
|
||||
self._optimize_image_png(file, path, config)
|
||||
|
||||
# Optimize JPG image using pillow
|
||||
if file.url.endswith((".jpg", ".jpeg")):
|
||||
self._optimize_image_jpg(file, path, config)
|
||||
|
||||
# Compute size before and after optimization
|
||||
size = len(data)
|
||||
size_opt = os.path.getsize(path)
|
||||
|
||||
# Compute absolute and relative gain
|
||||
gain_abs = size - size_opt
|
||||
gain_rel = (1 - size_opt / size) * 100
|
||||
|
||||
# Print how much we gained, if we did and desired
|
||||
gain = ""
|
||||
if gain_abs and self.config.print_gain:
|
||||
gain += " ↓ "
|
||||
gain += " ".join([_size(gain_abs), f"[{gain_rel:3.1f}%]"])
|
||||
|
||||
# Print summary for file
|
||||
log.info(
|
||||
f"Optimized media file: {file.src_uri} "
|
||||
f"{Fore.GREEN}{_size(size_opt)}"
|
||||
f"{Fore.WHITE}{Style.DIM}{gain}"
|
||||
f"{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
# Update manifest by associating file with hash
|
||||
self.manifest[file.url] = hash
|
||||
|
||||
# Compute project root
|
||||
root = os.path.dirname(config.config_file_path)
|
||||
|
||||
# Compute source file system path
|
||||
file.abs_src_path = path
|
||||
file.src_path = os.path.relpath(path, root)
|
||||
|
||||
# Return file to be copied from cache
|
||||
return file
|
||||
|
||||
# Optimize PNG image - we first tried to use libimagequant, but encountered
|
||||
# the occassional segmentation fault, which means it's probably not a good
|
||||
# choice. Instead, we just rely on pngquant which seems much more stable.
|
||||
def _optimize_image_png(self, file: File, path: str, config: MkDocsConfig):
|
||||
|
||||
# Check if the required dependencies for optimizing are available, which
|
||||
# is, at the absolute minimum, the 'pngquant' binary, and raise an error
|
||||
# to the caller, so he can decide what to do with the error. The caller
|
||||
# can treat this as a warning or an error to abort the build.
|
||||
if not which("pngquant"):
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't optimize image '{path}' in '{docs}': 'pngquant' "
|
||||
f"not found. Make sure 'pngquant' is installed and in your path"
|
||||
)
|
||||
|
||||
# Build command line arguments
|
||||
args = ["pngquant",
|
||||
"--force", "--skip-if-larger",
|
||||
"--output", path,
|
||||
"--speed", f"{self.config.optimize_png_speed}"
|
||||
]
|
||||
|
||||
# Add flag to remove optional metadata
|
||||
if self.config.optimize_png_strip:
|
||||
args.append("--strip")
|
||||
|
||||
# Set input file and run, then check if pngquant actually wrote a file,
|
||||
# as we instruct it not to if the size of the optimized file is larger.
|
||||
# This can happen if files are already compressed and optimized by
|
||||
# the author. In that case, just copy the original file.
|
||||
subprocess.run([*args, file.abs_src_path])
|
||||
if not os.path.isfile(path):
|
||||
utils.copy_file(file.abs_src_path, path)
|
||||
|
||||
# Optimize JPG image
|
||||
def _optimize_image_jpg(self, file: File, path: str, config: MkDocsConfig):
|
||||
|
||||
# Check if the required dependencies for optimizing are available, which
|
||||
# is, at the absolute minimum, the 'pillow' package, and raise an error
|
||||
# to the caller, so he can decide what to do with the error. The caller
|
||||
# can treat this as a warning or an error to abort the build.
|
||||
if not _supports("Image"):
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't optimize image '{path}' in '{docs}': install "
|
||||
f"required dependencies – pip install 'mkdocs-material[imaging]'"
|
||||
)
|
||||
|
||||
# Open and save optimized image
|
||||
image = Image.open(file.abs_src_path)
|
||||
image.save(path, "jpeg",
|
||||
quality = self.config.optimize_jpg_quality,
|
||||
progressive = self.config.optimize_jpg_progressive
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check for presence of optional imports
|
||||
@functools.lru_cache(maxsize = None)
|
||||
def _supports(name: str):
|
||||
return name in globals()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Print human-readable size
|
||||
def _size(value):
|
||||
for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
|
||||
if abs(value) < 1000.0:
|
||||
return f"{value:3.1f} {unit}"
|
||||
value /= 1000.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.optimize")
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) 2016-2025 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 (
|
||||
Choice, Deprecated, DictOfItems, ListOfItems, Type
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for log level
|
||||
LogLevel = (
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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 logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = Choice(LogLevel, default = "info")
|
||||
|
||||
# Settings for external assets
|
||||
assets = Type(bool, default = True)
|
||||
assets_fetch = Type(bool, default = True)
|
||||
assets_fetch_dir = Type(str, default = "assets/external")
|
||||
assets_include = ListOfItems(Type(str), default = [])
|
||||
assets_exclude = ListOfItems(Type(str), default = [])
|
||||
assets_expr_map = DictOfItems(Type(str), default = {})
|
||||
|
||||
# Settings for external links
|
||||
links = Type(bool, default = True)
|
||||
links_attr_map = DictOfItems(Type(str), default = {})
|
||||
links_noopener = Type(bool, default = True)
|
||||
|
||||
# Deprecated settings
|
||||
external_assets = Deprecated(message = "Deprecated, use 'assets_fetch'")
|
||||
external_assets_dir = Deprecated(moved_to = "assets_fetch_dir")
|
||||
external_assets_include = Deprecated(moved_to = "assets_include")
|
||||
external_assets_exclude = Deprecated(moved_to = "assets_exclude")
|
||||
external_assets_expr = Deprecated(moved_to = "assets_expr_map")
|
||||
external_links = Deprecated(moved_to = "links")
|
||||
external_links_attr_map = Deprecated(moved_to = "links_attr_map")
|
||||
external_links_noopener = Deprecated(moved_to = "links_noopener")
|
||||
@@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2016-2025 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))
|
||||
@@ -0,0 +1,680 @@
|
||||
# Copyright (c) 2016-2025 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, wait
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from fnmatch import fnmatch
|
||||
from hashlib import sha1
|
||||
from mkdocs.config.config_options import ExtraScriptValue
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
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, unquote
|
||||
from xml.etree.ElementTree import Element, tostring
|
||||
|
||||
from .config import PrivacyConfig
|
||||
from .parser import FragmentParser
|
||||
|
||||
DEFAULT_TIMEOUT_IN_SECS = 5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin
|
||||
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# 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_done: list[File] = []
|
||||
self.assets_expr_map = {
|
||||
".css": r"url\(\s*([\"']?)(?P<url>(?:https?:)?//[^)'\"]+)\1\s*\)",
|
||||
".js": r"[\"'](?P<url>(?:https?:)?//[^\"']+\.(?:css|js(?:on)?))[\"']",
|
||||
**self.config.assets_expr_map
|
||||
}
|
||||
|
||||
# Set log level or disable logging altogether - @todo when refactoring
|
||||
# this plugin for the next time, we should put this into a factory
|
||||
if not self.config.log:
|
||||
log.disabled = True
|
||||
else:
|
||||
log.setLevel(self.config.log_level.upper())
|
||||
|
||||
# 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:
|
||||
script = ExtraScriptValue(url.geturl())
|
||||
if script not in config.extra_javascript:
|
||||
config.extra_javascript.append(script)
|
||||
|
||||
# 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. Also,
|
||||
# downloading all external images at this stage, we reconcile all concurrent
|
||||
# jobs in `on_env`, which is the stage in which the optimize plugin will
|
||||
# evaluate what images can and need to be optimized. This means we can pass
|
||||
# external images through the optimization pipeline. Additionally, we run
|
||||
# this after all other plugins, so we allow them to add additional images
|
||||
# to the content of the page. How cool is that?
|
||||
@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=['\"]?(?:https?:)?//[^>]+>",
|
||||
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)
|
||||
|
||||
# Reconcile jobs and pass external assets to MkDocs (run earlier) - allow
|
||||
# other plugins (e.g. optimize plugin) to post-process external assets
|
||||
@event_priority(50)
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
|
||||
# same thread pool for fetching all remaining external assets
|
||||
wait(self.pool_jobs)
|
||||
self.pool_jobs.clear()
|
||||
|
||||
# Append all downloaded assets that are not style sheets or scripts to
|
||||
# MkDocs's collection of files, making them available to other plugins
|
||||
# for further processing. The remaining exteral assets are patched
|
||||
# before copying, which is done at the end of the build process.
|
||||
for file in self.assets:
|
||||
_, extension = posixpath.splitext(file.dest_uri)
|
||||
if extension not in [".css", ".js"]:
|
||||
self.assets_done.append(file)
|
||||
files.append(file)
|
||||
|
||||
# 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) - allow other plugins (e.g. optimize plugin)
|
||||
# to process all downloaded assets, which is why we must reconcile here
|
||||
@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, if we
|
||||
# haven't handed control to MkDocs in `on_env` before
|
||||
elif file not in self.assets_done:
|
||||
if os.path.exists(str(file.abs_src_path)):
|
||||
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
|
||||
])
|
||||
|
||||
# Check if URL matches one of the inclusion patterns
|
||||
if self.config.assets_include:
|
||||
for pattern in self.config.assets_include:
|
||||
if fnmatch(self._path_from_url(url), pattern):
|
||||
return False
|
||||
|
||||
# File is not included
|
||||
log.debug(
|
||||
f"Excluding external file '{url.geturl()}' {via}due to "
|
||||
f"inclusion patterns"
|
||||
)
|
||||
return True
|
||||
|
||||
# Check if URL matches one of the exclusion patterns
|
||||
for pattern in self.config.assets_exclude:
|
||||
if fnmatch(self._path_from_url(url), pattern):
|
||||
log.debug(
|
||||
f"Excluding external file '{url.geturl()}' {via}due to "
|
||||
f"exclusion patterns"
|
||||
)
|
||||
return True
|
||||
|
||||
# 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()
|
||||
|
||||
# Check parse result and return element
|
||||
if isinstance(parser.result, Element):
|
||||
return parser.result
|
||||
|
||||
# Otherwise, raise a plugin error - if the author accidentally used
|
||||
# invalid HTML inside of the tag, e.g., forget a opening or closing
|
||||
# quote, we need to catch this here, as we're using pretty basic
|
||||
# regular expression based extraction
|
||||
raise PluginError(
|
||||
"Couldn't parse due to possible syntax error in HTML: \n\n"
|
||||
+ fragment
|
||||
)
|
||||
|
||||
# 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 []
|
||||
|
||||
# Skip if source path is not set, which might be true for generated
|
||||
# files or for files that were added programatically in plugins
|
||||
if not initiator.abs_src_path:
|
||||
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-sig") as f:
|
||||
results = re.finditer(expr, f.read())
|
||||
return [urlparse(result.group("url")) for result in results]
|
||||
|
||||
# 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 link
|
||||
if self.config.links and el.tag == "a":
|
||||
for key, value in self.config.links_attr_map.items():
|
||||
el.set(key, value)
|
||||
|
||||
# Set `rel=noopener` if link opens in a new window
|
||||
if self.config.links_noopener:
|
||||
if el.get("target") == "_blank":
|
||||
rel = re.findall(r"\S+", el.get("rel", ""))
|
||||
if "noopener" not in rel:
|
||||
rel.append("noopener")
|
||||
|
||||
# Set relationships after adding `noopener`
|
||||
el.set("rel", " ".join(rel))
|
||||
|
||||
# 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 favicon, preload hint or style sheet
|
||||
if rel in ("icon", "preload", "stylesheet"):
|
||||
file = self._queue(url, config)
|
||||
if file:
|
||||
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)
|
||||
if file:
|
||||
el.set("src", resolve(file))
|
||||
|
||||
# Handle external image in SVG
|
||||
if el.tag == "image":
|
||||
url = urlparse(el.get("href"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config)
|
||||
if file:
|
||||
el.set("href", 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|image)[^>]+href|(?:script|img)[^>]+src)=['\"]?(?:https?:)?//[^>]+>",
|
||||
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:
|
||||
if not self._fetch(file, config):
|
||||
return None
|
||||
|
||||
# Register external asset as file - it might have already been
|
||||
# registered, and since MkDocs 1.6, trigger a deprecation warning
|
||||
if not self.assets.get_file_from_path(file.src_uri):
|
||||
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
|
||||
|
||||
# In case the URL is a protocol-relative URL that starts with `//`,
|
||||
# we prepend `http:` as a scheme, assuming that all external assets
|
||||
# are available via HTTP. If we'd require `https:`, some external
|
||||
# assets might not be fetchable.
|
||||
if file.url.startswith("//"):
|
||||
file.url = f"http:{file.url}"
|
||||
|
||||
# Download external asset
|
||||
log.info(f"Downloading external file: {file.url}")
|
||||
try:
|
||||
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 range
|
||||
# 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",
|
||||
]
|
||||
)
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT_IN_SECS,
|
||||
)
|
||||
res.raise_for_status()
|
||||
|
||||
# Intercept errors of type `ConnectionError` and `HTTPError`
|
||||
except Exception as error:
|
||||
log.warning(f"Couldn't retrieve {file.url}: {error}")
|
||||
return False
|
||||
|
||||
# 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)
|
||||
|
||||
# External asset was successfully downloaded
|
||||
return 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-sig") as f:
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
value = match.group("url")
|
||||
|
||||
# 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, unquote(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 = {
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+320
@@ -0,0 +1,320 @@
|
||||
# Copyright (c) 2016-2025 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 logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future, as_completed
|
||||
from concurrent.futures.process import ProcessPoolExecutor
|
||||
from logging import Logger
|
||||
from material.plugins.projects.config import ProjectsConfig
|
||||
from material.plugins.projects.structure import Project, ProjectJob
|
||||
from mkdocs.commands.build import build
|
||||
from mkdocs.config.base import ConfigErrors, ConfigWarnings
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import Abort
|
||||
from mkdocs.livereload import LiveReloadServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .log import (
|
||||
get_log_for,
|
||||
get_log_formatter,
|
||||
get_log_handler,
|
||||
get_log_level_for
|
||||
)
|
||||
from .watcher import ProjectsWatcher
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects builder
|
||||
class ProjectsBuilder:
|
||||
|
||||
# Set of projects
|
||||
projects: set[Project] = set()
|
||||
|
||||
# Projects watcher
|
||||
watcher: ProjectsWatcher | None = None
|
||||
|
||||
# Initialize projects builder - compared to our other concurrent plugins,
|
||||
# this plugin is forced to use a process pool in order to guarantee proper
|
||||
# isolation, as MkDocs itself is not thread-safe. We also need to recreate
|
||||
# the process pool on every build, or CTRL-C is broken
|
||||
def __init__(self, config: MkDocsConfig, plugin: ProjectsConfig):
|
||||
|
||||
# Initialize root project
|
||||
self.root = Project(config.config_file_path, plugin)
|
||||
self.root.config = config
|
||||
|
||||
# Initialize process pool
|
||||
self.pool = ProcessPoolExecutor
|
||||
self.pool_jobs: dict[str, Future] = {}
|
||||
|
||||
# Build projects
|
||||
def build(self, serve: bool = False, dirty: bool = False):
|
||||
self.pool = ProcessPoolExecutor(
|
||||
self.root.plugin.concurrency,
|
||||
mp_context = multiprocessing.get_context("spawn")
|
||||
)
|
||||
|
||||
# Determine projects in topological order and prepare for building
|
||||
built: list[str] = []
|
||||
queue = [*self.root.jobs()]
|
||||
for job in queue:
|
||||
_setup(job.project, self.root, serve)
|
||||
if serve:
|
||||
self._link(job.project)
|
||||
|
||||
# Schedule projects for building
|
||||
for job in reversed(queue):
|
||||
if self._schedule(job, serve, dirty):
|
||||
queue.remove(job)
|
||||
|
||||
# Build loop - iteratively build more projects while there are still
|
||||
# projects to be built, sticking to the topological order.
|
||||
while len(built) < len(self.pool_jobs):
|
||||
for future in as_completed(self.pool_jobs.values()):
|
||||
slug, errors, warnings = future.result()
|
||||
if slug in built:
|
||||
continue
|
||||
|
||||
# Mark project as built
|
||||
built.append(slug)
|
||||
|
||||
# Schedule projects for building
|
||||
for job in reversed(queue):
|
||||
if self._schedule(job, serve, dirty):
|
||||
queue.remove(job)
|
||||
|
||||
# Print errors and warnings
|
||||
for project in self.projects:
|
||||
if project.slug == slug:
|
||||
_print(get_log_for(project), errors, warnings)
|
||||
break
|
||||
|
||||
# Shutdown process pool
|
||||
self.pool.shutdown()
|
||||
if self.watcher:
|
||||
|
||||
# Update watched paths
|
||||
for project in self.projects:
|
||||
if project.slug not in built:
|
||||
self.pool_jobs.pop(project.slug, None)
|
||||
self.watcher.unwatch(project)
|
||||
else:
|
||||
self.watcher.watch(project, self.taint)
|
||||
|
||||
# Taint a project to schedule it for building
|
||||
def taint(self, project: Project):
|
||||
self.pool_jobs.pop(project.slug, None)
|
||||
|
||||
# Watch and serve projects
|
||||
def serve(self, server: LiveReloadServer, is_dirty: bool = False):
|
||||
self.watcher = ProjectsWatcher(server)
|
||||
self.watcher.watch(self.root, self.taint)
|
||||
for project in self.projects:
|
||||
self.watcher.watch(project, self.taint)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Create symlink for project if we're serving the site
|
||||
def _link(self, project: Project):
|
||||
|
||||
# Compute path for slug from current project - normalize path,
|
||||
# as paths computed from slugs or site URLs use forward slashes
|
||||
path = project.path(self.root)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Create symbolic link, if we haven't already
|
||||
path = os.path.join(self.root.config.site_dir, path)
|
||||
if not os.path.islink(path):
|
||||
|
||||
# Ensure link target exists
|
||||
target = os.path.realpath(os.path.dirname(path))
|
||||
if not os.path.exists(target):
|
||||
os.makedirs(target, exist_ok = True)
|
||||
|
||||
# Create symbolic link
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
os.symlink(project.config.site_dir, path)
|
||||
|
||||
# Schedule project for building - spawn concurrent job to build the project
|
||||
# and add a future to the jobs dictionary to link build results to projects
|
||||
def _schedule(self, job: ProjectJob, serve: bool, dirty: bool):
|
||||
self.projects.add(job.project)
|
||||
|
||||
# Exit early, if project doesn't need to be re built
|
||||
if job.project.slug in self.pool_jobs:
|
||||
return True
|
||||
|
||||
# Check if dependencies have been built already, and if so, remove
|
||||
# them from the list of dependencies. If a dependency has failed to
|
||||
# build, we'll raise an exception, which will be caught by the main
|
||||
# process, and the entire build will be aborted.
|
||||
for dependency in [*job.dependencies]:
|
||||
future = self.pool_jobs[dependency.slug]
|
||||
if future.running():
|
||||
continue
|
||||
|
||||
# If the dependency has failed to build, we'll raise an exception
|
||||
# to abort the entire build, as we can't build the project itself
|
||||
# without the dependency. This will be caught by the main process.
|
||||
# Otherwise, we'll remove the dependency from the list.
|
||||
if future.exception():
|
||||
raise future.exception()
|
||||
elif future.done():
|
||||
job.dependencies.remove(dependency)
|
||||
|
||||
# If all dependencies of the project have been built, we can build
|
||||
# the project itself by spawning a concurrent job
|
||||
if not job.dependencies:
|
||||
self.pool_jobs[job.project.slug] = self.pool.submit(
|
||||
_build, job.project, serve, dirty,
|
||||
get_log_level_for(job.project)
|
||||
)
|
||||
|
||||
# Return whether the project has been scheduled
|
||||
return not job.dependencies
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Setup project by preparing it for building
|
||||
def _setup(project: Project, root: Project, serve: bool):
|
||||
assert project.slug != "."
|
||||
|
||||
# Retrieve configuration of top-level project and transform project
|
||||
transform = root.plugin.projects_config_transform
|
||||
if isinstance(transform, Callable):
|
||||
transform(project, root)
|
||||
|
||||
# If the top-level project defines a site URL, we need to make sure that the
|
||||
# site URL of the project is set as well, setting it to the path we derive
|
||||
# from the slug. This allows to define the URL independent of the entire
|
||||
# project's directory structure. If the top-level project doesn't define a
|
||||
# site URL, it might be the case that the author is building a consolidated
|
||||
# project of several nested projects that are independent, but which should
|
||||
# be bundled together for distribution. As this is a case that is quite
|
||||
# common, we're not raising a warning or error.
|
||||
path = project.path(root)
|
||||
if root.config.site_url:
|
||||
|
||||
# If the project doesn't have a site URL, compute it from the site URL
|
||||
# of the top-level project and the path derived from the slug
|
||||
if not project.config.site_url:
|
||||
project.config.site_url = posixpath.join(
|
||||
root.config.site_url,
|
||||
path
|
||||
)
|
||||
|
||||
# If we're serving the site, replace the project's host name with the
|
||||
# dev server address, so we can serve nested projects as well
|
||||
if serve:
|
||||
url = urlparse(project.config.site_url)
|
||||
url = url._replace(
|
||||
scheme = "http",
|
||||
netloc = str(root.config.dev_addr)
|
||||
)
|
||||
|
||||
# Update site URL with dev server address
|
||||
project.config.site_url = url.geturl()
|
||||
|
||||
# If we're building the site, the project's output must be written to the
|
||||
# site directory of the top-level project, so we can serve it from there
|
||||
if not serve:
|
||||
project.config.site_dir = os.path.join(
|
||||
root.config.site_dir,
|
||||
os.path.normpath(path)
|
||||
)
|
||||
|
||||
# If we're serving the site, we must fall back to symbolic links, as MkDocs
|
||||
# will empty the entire site directory every time it performs a build
|
||||
else:
|
||||
project.config.site_dir = os.path.join(
|
||||
os.path.dirname(project.config.config_file_path),
|
||||
project.config.site_dir
|
||||
)
|
||||
|
||||
# Build project - note that regardless of whether MkDocs was started in build
|
||||
# or serve mode, projects must always be built, as they're served by the root
|
||||
def _build(project: Project, serve: bool, dirty: bool, level = logging.WARN):
|
||||
config = project.config
|
||||
|
||||
# Change working directory to project root - this is necessary, or relative
|
||||
# paths used in extensions and plugins will be resolved incorrectly
|
||||
os.chdir(os.path.dirname(config.config_file_path))
|
||||
|
||||
# Validate configuration
|
||||
errors, warnings = config.validate()
|
||||
if not errors:
|
||||
|
||||
# Retrieve and configure MkDocs' logger
|
||||
log = logging.getLogger("mkdocs")
|
||||
log.setLevel(level)
|
||||
|
||||
# Hack: there seems to be an inconsistency between operating systems,
|
||||
# and it's yet unclear where this is coming from - on macOS, the MkDocs
|
||||
# default logger has no designated handler registered, but on Linux it
|
||||
# does. If there's no handler, we need to create one. If there is, we
|
||||
# must only set the formatter, as otherwise we'll end up with the same
|
||||
# message printed on two log handlers - see https://t.ly/q7UEq
|
||||
handler = get_log_handler(project)
|
||||
if not log.hasHandlers():
|
||||
log.addHandler(handler)
|
||||
else:
|
||||
for handler in log.handlers:
|
||||
handler.setFormatter(get_log_formatter(project))
|
||||
|
||||
# Build project and dispatch startup and shutdown plugin events - note
|
||||
# that we must pass the correct command to the event handler, but run
|
||||
# the build command anyway, because otherwise some plugins will not
|
||||
# run in serve mode.
|
||||
command = "serve" if serve else "build"
|
||||
config.plugins.run_event("startup", command = command, dirty = dirty)
|
||||
try:
|
||||
build(config, dirty = dirty)
|
||||
finally:
|
||||
config.plugins.run_event("shutdown")
|
||||
log.removeHandler(handler)
|
||||
|
||||
# Return slug, errors and warnings
|
||||
return project.slug, errors, warnings
|
||||
|
||||
# Print errors and warnings resulting from building a project
|
||||
def _print(log: Logger, errors: ConfigErrors, warnings: ConfigWarnings):
|
||||
|
||||
# Print warnings
|
||||
for value, message in warnings:
|
||||
log.warning(f"Config value '{value}': {message}")
|
||||
|
||||
# Print errors
|
||||
for value, message in errors:
|
||||
log.error(f"Config value '{value}': {message}")
|
||||
|
||||
# Abort if there were errors
|
||||
if errors:
|
||||
raise Abort(f"Aborted with {len(errors)} configuration errors")
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) 2016-2025 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 logging
|
||||
|
||||
from click import style
|
||||
from logging import Filter
|
||||
from material.plugins.projects.structure import Project
|
||||
from mkdocs.__main__ import ColorFormatter
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Dirty build warning filter
|
||||
class ProjectsFilter(Filter):
|
||||
|
||||
# Filter log messages
|
||||
def filter(self, record):
|
||||
message = record.getMessage()
|
||||
return not message.startswith("A 'dirty' build")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Retrieve logger for project
|
||||
def get_log_for(project: Project):
|
||||
log = logging.getLogger("".join(["mkdocs.material.projects", project.slug]))
|
||||
|
||||
# Ensure logger does not propagate messags to parent logger, or messages
|
||||
# will be printed multiple times, and attach handler with color formatter
|
||||
log.propagate = False
|
||||
if not log.hasHandlers():
|
||||
log.addHandler(get_log_handler(project))
|
||||
log.setLevel(get_log_level_for(project))
|
||||
|
||||
# Return logger
|
||||
return log
|
||||
|
||||
# Retrieve log level for project
|
||||
def get_log_level_for(project: Project):
|
||||
level = logging.INFO
|
||||
|
||||
# Determine log level as set in MkDocs - if the build is started with the
|
||||
# `--quiet` flag, the log level is set to `ERROR` to suppress all messages,
|
||||
# except for errors. If it's started with `--verbose`, MkDocs sets the log
|
||||
# level to `DEBUG`, the most verbose of all log levels.
|
||||
log = logging.getLogger("mkdocs")
|
||||
for handler in log.handlers:
|
||||
level = handler.level
|
||||
break
|
||||
|
||||
# Determine if MkDocs was invoked with the `--quiet` flag and the log level
|
||||
# as configured in the plugin configuration. When `--quiet` is set, or the
|
||||
# projects plugin configuration disables logging, ignore the configured log
|
||||
# level and set it to `ERROR` to suppress all messages.
|
||||
quiet = level == logging.ERROR
|
||||
level = project.plugin.log_level.upper()
|
||||
if quiet or not project.plugin.log:
|
||||
level = logging.ERROR
|
||||
|
||||
# Retun log level
|
||||
return level
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Retrieve log handler for project
|
||||
def get_log_handler(project: Project):
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(get_log_formatter(project))
|
||||
|
||||
# Add filter to suppress dirty build warning, or we'll get as many of those
|
||||
# as projects are built - one warning is surely enough, KTHXBYE
|
||||
handler.addFilter(ProjectsFilter())
|
||||
return handler
|
||||
|
||||
# Retrieve log formatter for project
|
||||
def get_log_formatter(project: Project):
|
||||
prefix = style(f"project://{project.slug}", underline = True)
|
||||
return ColorFormatter(f"[{prefix}] %(message)s")
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
# Copyright (c) 2016-2025 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 os
|
||||
|
||||
from collections.abc import Callable
|
||||
from material.plugins.projects.structure import Project
|
||||
from mkdocs.livereload import LiveReloadServer
|
||||
|
||||
from .handler import ProjectChanged, ProjectAddedOrRemoved
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects watcher
|
||||
class ProjectsWatcher:
|
||||
|
||||
# Set of watched paths
|
||||
watched: set[str] = set()
|
||||
|
||||
# Initialize projects watcher
|
||||
def __init__(self, server: LiveReloadServer):
|
||||
self.server = server
|
||||
|
||||
# Watch project and invoke function on change
|
||||
def watch(self, project: Project, fn: Callable):
|
||||
self._on_project_changed(project, fn)
|
||||
self._on_project_added_or_removed(project, fn)
|
||||
|
||||
# Stop watching project
|
||||
def unwatch(self, project: Project):
|
||||
|
||||
# Traverse all watched paths
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
for path in [*self.watched]:
|
||||
|
||||
# Remove path from watched paths
|
||||
if path.startswith(root):
|
||||
self.server.unwatch(path)
|
||||
self.watched.remove(path)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Register event handler for project changes
|
||||
def _on_project_changed(self, project: Project, fn: Callable):
|
||||
|
||||
# Resolve project root and docs directory
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
docs = os.path.join(root, project.config.docs_dir)
|
||||
|
||||
# Collect all paths to watch
|
||||
paths = set([docs, project.config.config_file_path])
|
||||
for path in project.config.watch:
|
||||
paths.add(os.path.join(root, path))
|
||||
|
||||
# Register event handler for unwatched paths
|
||||
handler = ProjectChanged(project, fn)
|
||||
for path in paths - self.watched:
|
||||
self.server.watch(path)
|
||||
|
||||
# Add path and its descendents to watched paths
|
||||
self.server.observer.schedule(handler, path, recursive = True)
|
||||
self.watched.add(path)
|
||||
|
||||
# Register event handler for project additions and removals
|
||||
def _on_project_added_or_removed(self, project: Project, fn: Callable):
|
||||
|
||||
# Resolve project root and path to projects directory
|
||||
root = os.path.dirname(project.config.config_file_path)
|
||||
path = os.path.join(root, project.plugin.projects_dir)
|
||||
|
||||
# Register event handler for unwatched paths
|
||||
handler = ProjectAddedOrRemoved(project, fn)
|
||||
if path not in self.watched and os.path.isdir(path):
|
||||
|
||||
# Add path to watched paths
|
||||
self.server.observer.schedule(handler, path)
|
||||
self.watched.add(path)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+105
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) 2016-2025 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 logging
|
||||
import os
|
||||
|
||||
from collections.abc import Callable
|
||||
from material.plugins.projects.structure import Project
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project changed
|
||||
class ProjectChanged(FileSystemEventHandler):
|
||||
|
||||
# Initialize event handler
|
||||
def __init__(self, project: Project, handler: Callable):
|
||||
self.project = project
|
||||
self.handler = handler
|
||||
|
||||
# Handle file event
|
||||
def on_any_event(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Invoke file event handler
|
||||
def _handle(self, event: FileSystemEvent):
|
||||
config = self.project.config
|
||||
|
||||
# Resolve path to docs directory
|
||||
base = os.path.dirname(config.config_file_path)
|
||||
docs = os.path.join(base, config.docs_dir)
|
||||
|
||||
# Resolve project root and path to changed file
|
||||
root = os.path.relpath(base)
|
||||
path = os.path.relpath(event.src_path, root)
|
||||
|
||||
# Check if mkdocs.yml or docs directory was deleted
|
||||
if event.src_path in [docs, config.config_file_path]:
|
||||
if event.event_type == "deleted":
|
||||
return
|
||||
|
||||
# Invoke handler and print message that we're scheduling a build
|
||||
log.info(f"Schedule build due to '{path}' in '{root}'")
|
||||
self.handler(self.project)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project added or removed
|
||||
class ProjectAddedOrRemoved(FileSystemEventHandler):
|
||||
|
||||
# Initialize event handler
|
||||
def __init__(self, project: Project, handler: Callable):
|
||||
self.project = project
|
||||
self.handler = handler
|
||||
|
||||
# Handle file creation event
|
||||
def on_created(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# Handle file deletion event
|
||||
def on_deleted(self, event: FileSystemEvent):
|
||||
self._handle(event)
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
# Invoke file event handler
|
||||
def _handle(self, event: FileSystemEvent):
|
||||
config = self.project.config
|
||||
|
||||
# Touch mkdocs.yml to trigger rebuild
|
||||
if os.path.isfile(config.config_file_path):
|
||||
os.utime(config.config_file_path, None)
|
||||
|
||||
# Invoke handler
|
||||
self.handler(self.project)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs")
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2016-2025 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 collections.abc import Callable
|
||||
from mkdocs.config.config_options import Choice, Optional, Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for log level
|
||||
LogLevel = (
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects plugin configuration
|
||||
class ProjectsConfig(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/projects")
|
||||
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = Choice(LogLevel, default = "info")
|
||||
|
||||
# Settings for projects
|
||||
projects = Type(bool, default = True)
|
||||
projects_dir = Type(str, default = "projects")
|
||||
projects_config_files = Type(str, default = "*/mkdocs.yml")
|
||||
projects_config_transform = Optional(Type(Callable))
|
||||
projects_root_dir = Optional(Type(str))
|
||||
|
||||
# Settings for hoisting
|
||||
hoisting = Type(bool, default = True)
|
||||
@@ -0,0 +1,292 @@
|
||||
# Copyright (c) 2016-2025 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 json
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from jinja2 import pass_context
|
||||
from jinja2.runtime import Context
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs import utils
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure import StructureItem
|
||||
from mkdocs.structure.files import Files
|
||||
from mkdocs.structure.nav import Link, Section
|
||||
from mkdocs.utils import get_theme_dir
|
||||
from urllib.parse import ParseResult as URL, urlparse
|
||||
|
||||
from .builder import ProjectsBuilder
|
||||
from .config import ProjectsConfig
|
||||
from .structure import Project, ProjectLink
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Projects plugin
|
||||
class ProjectsPlugin(BasePlugin[ProjectsConfig]):
|
||||
|
||||
# Projects builder
|
||||
builder: ProjectsBuilder = None
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
self.is_dirty = False
|
||||
|
||||
# Hack: Since we're building in topological order, we cannot let MkDocs
|
||||
# clean the directory, because it means that nested projects are always
|
||||
# deleted before a project is built. We also don't need to restore this
|
||||
# functionality, because it's only used once in the process.
|
||||
utils.clean_directory = lambda _: _
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
self.is_dirty = dirty
|
||||
|
||||
# Resolve projects – compared to our other concurrent plugins, this plugin
|
||||
# is forced to use a process pool in order to guarantee proper isolation, as
|
||||
# MkDocs itself is not thread-safe. Additionally, all project configurations
|
||||
# are resolved and written to the cache (if enabled), as it's sufficient to
|
||||
# resolve them once on the top-level before projects are built. We might
|
||||
# need adjacent project configurations for interlinking projects.
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built - we can only exit here if we're
|
||||
# at the top-level, but not when building a nested project
|
||||
root = self.config.projects_root_dir is None
|
||||
if root and not self.config.projects:
|
||||
return
|
||||
|
||||
# Set projects root directory to the top-level project
|
||||
if not self.config.projects_root_dir:
|
||||
self.config.projects_root_dir = os.path.dirname(
|
||||
config.config_file_path
|
||||
)
|
||||
|
||||
# Initialize manifest
|
||||
self.manifest: dict[str, str] = {}
|
||||
self.manifest_file = os.path.join(
|
||||
self.config.projects_root_dir,
|
||||
self.config.cache_dir,
|
||||
"manifest.json"
|
||||
)
|
||||
|
||||
# Load manifest if it exists and the cache should be used
|
||||
if os.path.isfile(self.manifest_file):
|
||||
try:
|
||||
with open(self.manifest_file) as f:
|
||||
self.manifest = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Building the top-level project, we must resolve and load all project
|
||||
# configurations, as we need all information upfront to build them in
|
||||
# the correct order, and to resolve links between projects. Furthermore,
|
||||
# the author might influence a project's path by setting the site URL.
|
||||
if root:
|
||||
if not self.builder:
|
||||
self.builder = ProjectsBuilder(config, self.config)
|
||||
|
||||
# @todo: detach project resolution from build
|
||||
self.manifest = { ".": os.path.relpath(config.config_file_path) }
|
||||
for job in self.builder.root.jobs():
|
||||
path = os.path.relpath(job.project.config.config_file_path)
|
||||
self.manifest[job.project.slug] = path
|
||||
|
||||
# Save manifest, a we need it in nested projects
|
||||
os.makedirs(os.path.dirname(self.manifest_file), exist_ok = True)
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# Schedule projects for building - the general case is that all projects
|
||||
# can be considered independent of each other, so we build them in parallel
|
||||
def on_pre_build(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built or we're not at the top-level
|
||||
if not self.config.projects or not self.builder:
|
||||
return
|
||||
|
||||
# Build projects
|
||||
self.builder.build(self.is_serve, self.is_dirty)
|
||||
|
||||
# Patch environment to allow for hoisting of media files provided by the
|
||||
# theme itself, which will also work for other themes, not only this one
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built or we're at the top-level
|
||||
if not self.config.projects or self.builder:
|
||||
return
|
||||
|
||||
# If hoisting is enabled and we're building a project, remove all media
|
||||
# files that are provided by the theme and hoist them to the top
|
||||
if self.config.hoisting:
|
||||
theme = get_theme_dir(config.theme.name)
|
||||
hoist = Files([])
|
||||
|
||||
# Retrieve top-level project and check if the current project uses
|
||||
# the same theme as the top-level project - if not, don't hoist
|
||||
root = Project("mkdocs.yml", self.config)
|
||||
if config.theme.name != root.config.theme["name"]:
|
||||
return
|
||||
|
||||
# Remove all media files that are provided by the theme
|
||||
for file in files.media_files():
|
||||
if file.abs_src_path.startswith(theme):
|
||||
files.remove(file)
|
||||
hoist.append(file)
|
||||
|
||||
# Resolve source and target project
|
||||
source: Project | None = None
|
||||
target: Project | None = None
|
||||
for ref, file in self.manifest.items():
|
||||
base = os.path.join(self.config.projects_root_dir, file)
|
||||
if file == os.path.relpath(
|
||||
config.config_file_path, self.config.projects_root_dir
|
||||
):
|
||||
source = Project(base, self.config, ref)
|
||||
if "." == ref:
|
||||
target = Project(base, self.config, ref)
|
||||
|
||||
# Compute path for slug from source and target project
|
||||
path = target.path(source)
|
||||
|
||||
# Fetch URL template filter from environment - the filter might
|
||||
# be overridden by other plugins, so we must retrieve and wrap it
|
||||
url_filter = env.filters["url"]
|
||||
|
||||
# Patch URL template filter to add support for correctly resolving
|
||||
# media files that were hoisted to the top-level project
|
||||
@pass_context
|
||||
def url_filter_with_hoisting(context: Context, url: str | None):
|
||||
if url and hoist.get_file_from_path(url):
|
||||
return posixpath.join(path, url_filter(context, url))
|
||||
else:
|
||||
return url_filter(context, url)
|
||||
|
||||
# Register custom template filters
|
||||
env.filters["url"] = url_filter_with_hoisting
|
||||
|
||||
# Adjust project navigation in page (run latest) - as always, allow
|
||||
# other plugins to alter the navigation before we process it here
|
||||
@event_priority(-100)
|
||||
def on_page_context(self, context, *, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built
|
||||
if not self.config.projects:
|
||||
return
|
||||
|
||||
# Replace project URLs in navigation
|
||||
self._replace(nav.items, config)
|
||||
|
||||
# Adjust project navigation in template (run latest) - as always, allow
|
||||
# other plugins to alter the navigation before we process it here
|
||||
@event_priority(-100)
|
||||
def on_template_context(self, context, *, template_name, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if projects should not be built
|
||||
if not self.config.projects:
|
||||
return
|
||||
|
||||
# Replace project URLs in navigation
|
||||
self._replace(context["nav"].items, config)
|
||||
|
||||
# Serve projects
|
||||
def on_serve(self, server, *, config, builder):
|
||||
if self.config.enabled:
|
||||
self.builder.serve(server, self.is_dirty)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Replace project links in the given list of navigation items
|
||||
def _replace(self, items: list[StructureItem], config: MkDocsConfig):
|
||||
for index, item in enumerate(items):
|
||||
|
||||
# Handle section
|
||||
if isinstance(item, Section):
|
||||
self._replace(item.children, config)
|
||||
|
||||
# Handle link
|
||||
if isinstance(item, Link):
|
||||
url = urlparse(item.url)
|
||||
if url.scheme == "project":
|
||||
project, url = self._resolve_project_url(url, config)
|
||||
|
||||
# Append file name if directory URLs are disabled
|
||||
if not project.config.use_directory_urls:
|
||||
url += "index.html"
|
||||
|
||||
# Replace link with project link
|
||||
items[index] = ProjectLink(
|
||||
item.title or project.config.site_name,
|
||||
url
|
||||
)
|
||||
|
||||
# Resolve project URL and slug
|
||||
def _resolve_project_url(self, url: URL, config: MkDocsConfig):
|
||||
|
||||
# Abort if the project URL contains a path, as we first need to collect
|
||||
# use cases for when, how and whether we need and want to support this
|
||||
if url.path != "":
|
||||
raise PluginError(
|
||||
f"Couldn't resolve project URL: paths currently not supported\n"
|
||||
f"Please only use 'project://{url.hostname}'"
|
||||
)
|
||||
|
||||
# Compute slug from host name and convert to dot notation
|
||||
slug = url.hostname
|
||||
slug = slug if slug.startswith(".") else f".{slug}"
|
||||
|
||||
# Resolve source and target project
|
||||
source: Project | None = None
|
||||
target: Project | None = None
|
||||
for ref, file in self.manifest.items():
|
||||
base = os.path.join(self.config.projects_root_dir, file)
|
||||
if file == os.path.relpath(
|
||||
config.config_file_path, self.config.projects_root_dir
|
||||
):
|
||||
source = Project(base, self.config, ref)
|
||||
if slug == ref:
|
||||
target = Project(base, self.config, ref)
|
||||
|
||||
# Abort if slug doesn't match a known project
|
||||
if not target:
|
||||
raise PluginError(f"Couldn't find project '{slug}'")
|
||||
|
||||
# Return project slug and path
|
||||
return target, target.path(source)
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
# Copyright (c) 2016-2025 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 os
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
from copy import deepcopy
|
||||
from glob import iglob
|
||||
from material.plugins.projects.config import ProjectsConfig
|
||||
from mkdocs.structure.nav import Link
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.config.config_options import Plugins
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project
|
||||
class Project:
|
||||
|
||||
# Initialize project - note that the configuration of the projects plugin
|
||||
# of the enclosing project is necessary to resolve nested projects
|
||||
def __init__(self, file: str, plugin: ProjectsConfig, slug = "."):
|
||||
self.config, self.plugin = self._resolve(file, plugin)
|
||||
|
||||
# The slug should not be changed after initialization, as it's used for
|
||||
# correct resolution of projects and nested projects
|
||||
self.slug = slug
|
||||
|
||||
# Find and yield nested projects of the current project - the project's
|
||||
# slug is prepended to the computed slug for a simple resolution of nested
|
||||
# projects, allowing authors to use the project:// protocol for linking to
|
||||
# projects from the top-level project or nested and adjacent projects
|
||||
def __iter__(self):
|
||||
seen: list[str] = []
|
||||
|
||||
# Compute project root and base directory
|
||||
root = os.path.dirname(self.config.config_file_path)
|
||||
base = os.path.join(root, self.plugin.projects_dir)
|
||||
|
||||
# Find and yield all projects - note that we need to filter for nested
|
||||
# projects at this point, as we're only interested in the projects on
|
||||
# the next level, not in projects inside projects as they are resolved
|
||||
# recursively to preserve topological ordering. This is also why we must
|
||||
# sort the list of projects by path, ordering shorted paths first which
|
||||
# ensures that nested projects are resolved before their parents.
|
||||
glob = os.path.join(base, self.plugin.projects_config_files)
|
||||
glob = iglob(os.path.normpath(glob), recursive = True)
|
||||
for file in sorted(glob, key = os.path.dirname):
|
||||
path = os.path.join(os.path.dirname(file), "")
|
||||
if any(path.startswith(_) for _ in seen):
|
||||
continue
|
||||
else:
|
||||
seen.append(path)
|
||||
|
||||
# Extract the first level of the project's directory relative to
|
||||
# the projects directory as the computed slug of the project. This
|
||||
# allows authors to build projects whose mkdocs.yml files are not
|
||||
# located at the project root, e.g., when using git submodules.
|
||||
slug = os.path.relpath(file, base)
|
||||
slug, *_ = slug.split(os.path.sep)
|
||||
|
||||
# Normalize slug to an internal dot notation which we convert to
|
||||
# file system or URL paths when necessary. Each slug starts with
|
||||
# a dot to denote that it is resolved from the top-level project,
|
||||
# which also allows for resolving slugs in nested projects.
|
||||
root = self.slug.rstrip(".")
|
||||
slug = f"{root}.{slug}"
|
||||
|
||||
# Create and yield project
|
||||
yield Project(file, self.plugin, slug)
|
||||
|
||||
# Compute project hash
|
||||
def __hash__(self):
|
||||
return hash(self.slug)
|
||||
|
||||
# Find and yield all nested projects (excluding this project) in reverse
|
||||
# topological order, by performing a post-order traversal on the tree of
|
||||
# projects. This function returns project jobs, which are projects with
|
||||
# their immediate dependencies, to build them in the correct order.
|
||||
def jobs(self):
|
||||
stack = [*self]
|
||||
while stack:
|
||||
|
||||
# Pop project from stack and get its dependencies
|
||||
project = stack.pop()
|
||||
dependencies = [*project]
|
||||
|
||||
# Add project dependencies to stack and yield job
|
||||
stack.extend(dependencies)
|
||||
yield ProjectJob(project, dependencies)
|
||||
|
||||
# Compute relative path between two projects
|
||||
def path(self, that: Project):
|
||||
|
||||
# If both, the top-level and the current project have a site URL set,
|
||||
# compute slug from the common path of both site URLs
|
||||
if self.config.site_url and that.config.site_url:
|
||||
source = self._path_from_config(that.config)
|
||||
target = self._path_from_config(self.config)
|
||||
|
||||
# Edge case: the author has set a site URL that does not include a
|
||||
# path, so the path of the project is equal to the top-level path.
|
||||
# In this case, we need to fall back to the path computed from the
|
||||
# slug - see https://t.ly/5vqMr
|
||||
if target == source:
|
||||
target = self._path_from_slug(self.slug)
|
||||
|
||||
# Otherwise, always compute the path from the slugs of both projects,
|
||||
# as we want to support consolidation of unrelated projects
|
||||
else:
|
||||
source = self._path_from_slug(that.slug)
|
||||
target = self._path_from_slug(self.slug)
|
||||
|
||||
# Compute path between projects, and add trailing slash
|
||||
path = posixpath.relpath(target, source)
|
||||
return posixpath.join(path, "")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Resolve project and plugin configuration
|
||||
def _resolve(self, file: str, plugin: ProjectsConfig):
|
||||
config = self._resolve_config(file)
|
||||
plugin = self._resolve_plugin(config, plugin)
|
||||
|
||||
# Return project and plugin configuration
|
||||
return config, plugin
|
||||
|
||||
# Resolve project configuration
|
||||
def _resolve_config(self, file: str):
|
||||
with open(file, encoding = "utf-8-sig") as f:
|
||||
config: MkDocsConfig = MkDocsConfig(config_file_path = file)
|
||||
config.load_file(f)
|
||||
|
||||
# Return project configuration
|
||||
return config
|
||||
|
||||
# Resolve project plugin configuration
|
||||
def _resolve_plugin(self, config: MkDocsConfig, plugin: ProjectsConfig):
|
||||
|
||||
# Make sure that every project has a plugin configuration set - we need
|
||||
# to deep copy the configuration object, as it's mutated during parsing.
|
||||
# We're using an internal method of the Plugins class to ensure that we
|
||||
# always stick to the syntaxes allowed by MkDocs (list and dictionary).
|
||||
plugins = Plugins._parse_configs(deepcopy(config.plugins))
|
||||
for index, (key, settings) in enumerate(plugins):
|
||||
if not re.match(r"^(material/)?projects$", key):
|
||||
continue
|
||||
|
||||
# Forward these settings of the plugin configuration to the project,
|
||||
# as we need to build nested projects consistently
|
||||
for name in ["cache", "projects", "projects_root_dir", "hoisting"]:
|
||||
settings[name] = plugin[name]
|
||||
|
||||
# Forward these settings only if they have not been set in the
|
||||
# project configuration, as they might be overwritten by the author
|
||||
for name in ["log", "log_level"]:
|
||||
if not name in settings:
|
||||
settings[name] = plugin[name]
|
||||
|
||||
# Initialize and expand the plugin configuration, and mutate the
|
||||
# plugin collection to persist the patched configuration
|
||||
plugin: ProjectsConfig = ProjectsConfig()
|
||||
plugin.load_dict(settings)
|
||||
if isinstance(config.plugins, list):
|
||||
config.plugins[index] = { key: dict(plugin.items()) }
|
||||
else:
|
||||
config.plugins[key] = dict(plugin.items())
|
||||
|
||||
# Return project plugin configuration
|
||||
return plugin
|
||||
|
||||
# If no plugin configuration was found, add the default configuration
|
||||
# and call this function recursively to ensure that it's present
|
||||
config.plugins.append("material/projects")
|
||||
return self._resolve_plugin(config, plugin)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Compute path from given slug - split slug at dots, ignoring the first one,
|
||||
# and join the segments to a path, prefixed with a dot. This is necessary
|
||||
# to compute the common path correctly, so we can use the same logic for
|
||||
# when the path is computed from the site URL (see below).
|
||||
def _path_from_slug(self, slug: str):
|
||||
_, *segments = slug.split(".")
|
||||
return posixpath.join(".", *segments)
|
||||
|
||||
# Compute path from given project configuration - parse site URL and return
|
||||
# canonicalized path. Paths always start with a dot and trailing slashes are
|
||||
# always removed. This is necessary so that we can compute the common path
|
||||
# correctly, since the site URL might or might not contain a trailing slash.
|
||||
def _path_from_config(self, config: MkDocsConfig):
|
||||
url = urlparse(config.site_url)
|
||||
|
||||
# Remove leading slash, if any
|
||||
path = url.path
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
|
||||
# Return normalized path
|
||||
path = posixpath.normpath(path) if path else path
|
||||
return posixpath.join(".", path)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project job
|
||||
class ProjectJob:
|
||||
|
||||
# Initialize project job
|
||||
def __init__(self, project: Project, dependencies: list[Project]):
|
||||
self.project = project
|
||||
self.dependencies = dependencies
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Project link
|
||||
class ProjectLink(Link):
|
||||
|
||||
# Indicate that the link points to a project
|
||||
is_project = True
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
# Copyright (c) 2016-2025 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 mkdocs.config.config_options import (
|
||||
Choice,
|
||||
Deprecated,
|
||||
Optional,
|
||||
ListOfItems,
|
||||
Type
|
||||
)
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.contrib.search import LangOption
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for search pipeline
|
||||
pipeline = ("stemmer", "stopWordFilter", "trimmer")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Search field configuration
|
||||
class SearchFieldConfig(Config):
|
||||
boost = Type((int, float), default = 1.0)
|
||||
|
||||
# Search plugin configuration
|
||||
class SearchConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for search
|
||||
lang = Optional(LangOption())
|
||||
separator = Optional(Type(str))
|
||||
pipeline = Optional(ListOfItems(Choice(pipeline)))
|
||||
fields = Type(dict, default = {})
|
||||
|
||||
# Settings for text segmentation (Chinese)
|
||||
jieba_dict = Optional(Type(str))
|
||||
jieba_dict_user = Optional(Type(str))
|
||||
|
||||
# Unsupported settings, originally implemented in MkDocs
|
||||
indexing = Deprecated(message = "Unsupported option")
|
||||
prebuild_index = Deprecated(message = "Unsupported option")
|
||||
min_search_length = Deprecated(message = "Unsupported option")
|
||||
@@ -0,0 +1,594 @@
|
||||
# Copyright (c) 2016-2025 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 json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from backrefs import bre
|
||||
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from mkdocs import utils
|
||||
from mkdocs.config.config_options import SubConfig
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from .config import SearchConfig, SearchFieldConfig
|
||||
|
||||
try:
|
||||
import jieba
|
||||
except ImportError:
|
||||
jieba = None
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Search plugin
|
||||
class SearchPlugin(BasePlugin[SearchConfig]):
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_dirty = False
|
||||
self.is_dirtyreload = False
|
||||
|
||||
# Initialize search index cache
|
||||
self.search_index_prev = None
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_dirty = dirty
|
||||
|
||||
# Initialize plugin
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Retrieve default value for language
|
||||
if not self.config.lang:
|
||||
self.config.lang = [self._translate(
|
||||
config, "search.config.lang"
|
||||
)]
|
||||
|
||||
# Retrieve default value for separator
|
||||
if not self.config.separator:
|
||||
self.config.separator = self._translate(
|
||||
config, "search.config.separator"
|
||||
)
|
||||
|
||||
# Retrieve default value for pipeline
|
||||
if self.config.pipeline is None:
|
||||
self.config.pipeline = list(filter(len, re.split(
|
||||
r"\s*,\s*", self._translate(config, "search.config.pipeline")
|
||||
)))
|
||||
|
||||
# Validate field configuration
|
||||
validator = SubConfig(SearchFieldConfig)
|
||||
for config in self.config.fields.values():
|
||||
validator.run_validation(config)
|
||||
|
||||
# Merge with default fields
|
||||
if "title" not in self.config.fields:
|
||||
self.config.fields["title"] = { "boost": 1e3 }
|
||||
if "text" not in self.config.fields:
|
||||
self.config.fields["text"] = { "boost": 1e0 }
|
||||
if "tags" not in self.config.fields:
|
||||
self.config.fields["tags"] = { "boost": 1e6 }
|
||||
|
||||
# Initialize search index
|
||||
self.search_index = SearchIndex(**self.config)
|
||||
|
||||
# Set jieba dictionary, if given
|
||||
if self.config.jieba_dict:
|
||||
path = os.path.normpath(self.config.jieba_dict)
|
||||
if os.path.isfile(path):
|
||||
jieba.set_dictionary(path)
|
||||
log.debug(f"Loading jieba dictionary: {path}")
|
||||
else:
|
||||
log.warning(
|
||||
f"Configuration error for 'search.jieba_dict': "
|
||||
f"'{self.config.jieba_dict}' does not exist."
|
||||
)
|
||||
|
||||
# Set jieba user dictionary, if given
|
||||
if self.config.jieba_dict_user:
|
||||
path = os.path.normpath(self.config.jieba_dict_user)
|
||||
if os.path.isfile(path):
|
||||
jieba.load_userdict(path)
|
||||
log.debug(f"Loading jieba user dictionary: {path}")
|
||||
else:
|
||||
log.warning(
|
||||
f"Configuration error for 'search.jieba_dict_user': "
|
||||
f"'{self.config.jieba_dict_user}' does not exist."
|
||||
)
|
||||
|
||||
# Add page to search index
|
||||
def on_page_context(self, context, *, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Index page
|
||||
self.search_index.add_entry_from_context(page)
|
||||
page.content = re.sub(
|
||||
r"\s?data-search-\w+=\"[^\"]+\"",
|
||||
"",
|
||||
page.content
|
||||
)
|
||||
|
||||
# Generate search index
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Write search index
|
||||
base = os.path.join(config.site_dir, "search")
|
||||
path = os.path.join(base, "search_index.json")
|
||||
|
||||
# Generate and write search index to file
|
||||
data = self.search_index.generate_search_index(self.search_index_prev)
|
||||
utils.write_file(data.encode("utf-8"), path)
|
||||
|
||||
# Persist search index for repeated invocation
|
||||
if self.is_dirty:
|
||||
self.search_index_prev = self.search_index
|
||||
|
||||
# Determine whether we're running under dirty reload
|
||||
def on_serve(self, server, *, config, builder):
|
||||
self.is_dirtyreload = self.is_dirty
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Translate the given placeholder value
|
||||
def _translate(self, config, value):
|
||||
env = config.theme.get_env()
|
||||
|
||||
# Load language template and return translation for placeholder
|
||||
language = "partials/language.html"
|
||||
template = env.get_template(language, None, { "config": config })
|
||||
return template.module.t(value)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Search index with support for additional fields
|
||||
class SearchIndex:
|
||||
|
||||
# Initialize search index
|
||||
def __init__(self, **config):
|
||||
self.config = config
|
||||
self.entries = []
|
||||
|
||||
# Add page to search index
|
||||
def add_entry_from_context(self, page):
|
||||
search = page.meta.get("search") or {}
|
||||
if search.get("exclude"):
|
||||
return
|
||||
|
||||
# Divide page content into sections
|
||||
parser = Parser()
|
||||
parser.feed(page.content)
|
||||
parser.close()
|
||||
|
||||
# Add sections to index
|
||||
for section in parser.data:
|
||||
if not section.is_excluded():
|
||||
self.create_entry_for_section(section, page.toc, page.url, page)
|
||||
|
||||
# Override: graceful indexing and additional fields
|
||||
def create_entry_for_section(self, section, toc, url, page):
|
||||
item = self._find_toc_by_id(toc, section.id)
|
||||
if item:
|
||||
url = url + item.url
|
||||
elif section.id:
|
||||
url = url + "#" + section.id
|
||||
|
||||
# Set page title as section title if none was given, which happens when
|
||||
# the first headline in a Markdown document is not a h1 headline. Also,
|
||||
# if a page title was set via front matter, use that even though a h1
|
||||
# might be given or the page name was specified in nav in mkdocs.yml
|
||||
if not section.title:
|
||||
section.title = [str(page.meta.get("title", page.title))]
|
||||
|
||||
# Compute title and text
|
||||
title = "".join(section.title).strip()
|
||||
text = "".join(section.text).strip()
|
||||
|
||||
# Segment Chinese characters if jieba is available
|
||||
if jieba:
|
||||
title = self._segment_chinese(title)
|
||||
text = self._segment_chinese(text)
|
||||
|
||||
# Create entry for section
|
||||
entry = {
|
||||
"location": url,
|
||||
"title": title,
|
||||
"text": text
|
||||
}
|
||||
|
||||
# Set document tags
|
||||
tags = page.meta.get("tags")
|
||||
if isinstance(tags, list):
|
||||
entry["tags"] = []
|
||||
for name in tags:
|
||||
if name and isinstance(name, (str, int, float, bool)):
|
||||
entry["tags"].append(str(name))
|
||||
|
||||
# Set document boost
|
||||
search = page.meta.get("search") or {}
|
||||
if "boost" in search:
|
||||
entry["boost"] = search["boost"]
|
||||
|
||||
# Add entry to index
|
||||
self.entries.append(entry)
|
||||
|
||||
# Generate search index
|
||||
def generate_search_index(self, prev):
|
||||
config = {
|
||||
key: self.config[key]
|
||||
for key in ["lang", "separator", "pipeline", "fields"]
|
||||
}
|
||||
|
||||
# Hack: if we're running under dirty reload, the search index will only
|
||||
# include the entries for the current page. However, MkDocs > 1.4 allows
|
||||
# us to persist plugin state across rebuilds, which is exactly what we
|
||||
# do by passing the previously built index to this method. Thus, we just
|
||||
# remove the previous entries for the current page, and append the new
|
||||
# entries to the end of the index, as order doesn't matter.
|
||||
if prev and self.entries:
|
||||
path = self.entries[0]["location"]
|
||||
|
||||
# Since we're sure that we're running under dirty reload, the list
|
||||
# of entries will only contain sections for a single page. Thus, we
|
||||
# use the first entry to remove all entries from the previous run
|
||||
# that belong to the current page. The rationale behind this is that
|
||||
# authors might add or remove section headers, so we need to make
|
||||
# sure that sections are synchronized correctly.
|
||||
entries = [
|
||||
entry for entry in prev.entries
|
||||
if not entry["location"].startswith(path)
|
||||
]
|
||||
|
||||
# Merge previous with current entries
|
||||
self.entries = entries + self.entries
|
||||
|
||||
# Otherwise just set previous entries
|
||||
if prev and not self.entries:
|
||||
self.entries = prev.entries
|
||||
|
||||
# Return search index as JSON
|
||||
data = { "config": config, "docs": self.entries }
|
||||
return json.dumps(
|
||||
data,
|
||||
separators = (",", ":"),
|
||||
default = str
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Retrieve item for anchor
|
||||
def _find_toc_by_id(self, toc, id):
|
||||
for toc_item in toc:
|
||||
if toc_item.id == id:
|
||||
return toc_item
|
||||
|
||||
# Recurse into children of item
|
||||
toc_item = self._find_toc_by_id(toc_item.children, id)
|
||||
if toc_item is not None:
|
||||
return toc_item
|
||||
|
||||
# No item found
|
||||
return None
|
||||
|
||||
# Find and segment Chinese characters in string
|
||||
def _segment_chinese(self, data):
|
||||
expr = bre.compile(r"(\p{script: Han}+)", bre.UNICODE)
|
||||
|
||||
# Replace callback
|
||||
def replace(match):
|
||||
value = match.group(0)
|
||||
|
||||
# Replace occurrence in original string with segmented version and
|
||||
# surround with zero-width whitespace for efficient indexing
|
||||
return "".join([
|
||||
"\u200b",
|
||||
"\u200b".join(jieba.cut(value.encode("utf-8"))),
|
||||
"\u200b",
|
||||
])
|
||||
|
||||
# Return string with segmented occurrences
|
||||
return expr.sub(replace, data).strip("\u200b")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# HTML element
|
||||
class Element:
|
||||
"""
|
||||
An element with attributes, essentially a small wrapper object for the
|
||||
parser to access attributes in other callbacks than handle_starttag.
|
||||
"""
|
||||
|
||||
# Initialize HTML element
|
||||
def __init__(self, tag, attrs = None):
|
||||
self.tag = tag
|
||||
self.attrs = attrs or {}
|
||||
|
||||
# String representation
|
||||
def __repr__(self):
|
||||
return self.tag
|
||||
|
||||
# Support comparison (compare by tag only)
|
||||
def __eq__(self, other):
|
||||
if other is Element:
|
||||
return self.tag == other.tag
|
||||
else:
|
||||
return self.tag == other
|
||||
|
||||
# Support set operations
|
||||
def __hash__(self):
|
||||
return hash(self.tag)
|
||||
|
||||
# Check whether the element should be excluded
|
||||
def is_excluded(self):
|
||||
return "data-search-exclude" in self.attrs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# HTML section
|
||||
class Section:
|
||||
"""
|
||||
A block of text with markup, preceded by a title (with markup), i.e., a
|
||||
headline with a certain level (h1-h6). Internally used by the parser.
|
||||
"""
|
||||
|
||||
# Initialize HTML section
|
||||
def __init__(self, el, depth = 0):
|
||||
self.el = el
|
||||
self.depth = depth
|
||||
|
||||
# Initialize section data
|
||||
self.text = []
|
||||
self.title = []
|
||||
self.id = None
|
||||
|
||||
# String representation
|
||||
def __repr__(self):
|
||||
if self.id:
|
||||
return "#".join([self.el.tag, self.id])
|
||||
else:
|
||||
return self.el.tag
|
||||
|
||||
# Check whether the section should be excluded
|
||||
def is_excluded(self):
|
||||
return self.el.is_excluded()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# HTML parser
|
||||
class Parser(HTMLParser):
|
||||
"""
|
||||
This parser divides the given string of HTML into a list of sections, each
|
||||
of which are preceded by a h1-h6 level heading. A white- and blacklist of
|
||||
tags dictates which tags should be preserved as part of the index, and
|
||||
which should be ignored in their entirety.
|
||||
"""
|
||||
|
||||
# Initialize HTML parser
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Tags to skip
|
||||
self.skip = set([
|
||||
"object", # Objects
|
||||
"script", # Scripts
|
||||
"style" # Styles
|
||||
])
|
||||
|
||||
# Tags to keep
|
||||
self.keep = set([
|
||||
"p", # Paragraphs
|
||||
"code", "pre", # Code blocks
|
||||
"li", "ol", "ul", # Lists
|
||||
"sub", "sup" # Sub- and superscripts
|
||||
])
|
||||
|
||||
# Current context and section
|
||||
self.context = []
|
||||
self.section = None
|
||||
|
||||
# All parsed sections
|
||||
self.data = []
|
||||
|
||||
# Called at the start of every HTML tag
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
|
||||
# Ignore self-closing tags
|
||||
el = Element(tag, attrs)
|
||||
if not tag in void:
|
||||
self.context.append(el)
|
||||
else:
|
||||
return
|
||||
|
||||
# Handle heading
|
||||
if tag in ([f"h{x}" for x in range(1, 7)]):
|
||||
depth = len(self.context)
|
||||
if "id" in attrs:
|
||||
|
||||
# Ensure top-level section
|
||||
if tag != "h1" and not self.data:
|
||||
self.section = Section(Element("hx"), depth)
|
||||
self.data.append(self.section)
|
||||
|
||||
# Set identifier, if not first section
|
||||
self.section = Section(el, depth)
|
||||
if self.data:
|
||||
self.section.id = attrs["id"]
|
||||
|
||||
# Append section to list
|
||||
self.data.append(self.section)
|
||||
|
||||
# Handle preface - ensure top-level section
|
||||
if not self.section:
|
||||
self.section = Section(Element("hx"))
|
||||
self.data.append(self.section)
|
||||
|
||||
# Handle special cases to skip
|
||||
for key, value in attrs.items():
|
||||
|
||||
# Skip block if explicitly excluded from search
|
||||
if key == "data-search-exclude":
|
||||
self.skip.add(el)
|
||||
return
|
||||
|
||||
# Skip line numbers - see https://bit.ly/3GvubZx
|
||||
if key == "class" and value == "linenodiv":
|
||||
self.skip.add(el)
|
||||
return
|
||||
|
||||
# Render opening tag if kept
|
||||
if not self.skip.intersection(self.context) and tag in self.keep:
|
||||
|
||||
# Check whether we're inside the section title
|
||||
data = self.section.text
|
||||
if self.section.el in self.context:
|
||||
data = self.section.title
|
||||
|
||||
# Append to section title or text
|
||||
data.append(f"<{tag}>")
|
||||
|
||||
# Called at the end of every HTML tag
|
||||
def handle_endtag(self, tag):
|
||||
if not self.context or self.context[-1] != tag:
|
||||
return
|
||||
|
||||
# Check whether we're exiting the current context, which happens when
|
||||
# a headline is nested in another element. In that case, we close the
|
||||
# current section, continuing to append data to the previous section,
|
||||
# which could also be a nested section – see https://bit.ly/3IxxIJZ
|
||||
if self.section.depth > len(self.context):
|
||||
for section in reversed(self.data):
|
||||
if section.depth <= len(self.context):
|
||||
|
||||
# Set depth to infinity in order to denote that the current
|
||||
# section is exited and must never be considered again.
|
||||
self.section.depth = float("inf")
|
||||
self.section = section
|
||||
break
|
||||
|
||||
# Remove element from skip list
|
||||
el = self.context.pop()
|
||||
if el in self.skip:
|
||||
if el.tag not in ["script", "style", "object"]:
|
||||
self.skip.remove(el)
|
||||
return
|
||||
|
||||
# Render closing tag if kept
|
||||
if not self.skip.intersection(self.context) and tag in self.keep:
|
||||
|
||||
# Check whether we're inside the section title
|
||||
data = self.section.text
|
||||
if self.section.el in self.context:
|
||||
data = self.section.title
|
||||
|
||||
# Search for corresponding opening tag
|
||||
index = data.index(f"<{tag}>")
|
||||
for i in range(index + 1, len(data)):
|
||||
if not data[i].isspace():
|
||||
index = len(data)
|
||||
break
|
||||
|
||||
# Remove element if empty (or only whitespace)
|
||||
if len(data) > index:
|
||||
while len(data) > index:
|
||||
data.pop()
|
||||
|
||||
# Append to section title or text
|
||||
else:
|
||||
data.append(f"</{tag}>")
|
||||
|
||||
# Called for the text contents of each tag
|
||||
def handle_data(self, data):
|
||||
if self.skip.intersection(self.context):
|
||||
return
|
||||
|
||||
# Collapse whitespace in non-pre contexts
|
||||
if not "pre" in self.context:
|
||||
if not data.isspace():
|
||||
data = data.replace("\n", " ")
|
||||
else:
|
||||
data = " "
|
||||
|
||||
# Handle preface - ensure top-level section
|
||||
if not self.section:
|
||||
self.section = Section(Element("hx"))
|
||||
self.data.append(self.section)
|
||||
|
||||
# Handle section headline
|
||||
if self.section.el in self.context:
|
||||
permalink = False
|
||||
for el in self.context:
|
||||
if el.tag == "a" and el.attrs.get("class") == "headerlink":
|
||||
permalink = True
|
||||
|
||||
# Ignore permalinks
|
||||
if not permalink:
|
||||
self.section.title.append(
|
||||
escape(data, quote = False)
|
||||
)
|
||||
|
||||
# Collapse adjacent whitespace
|
||||
elif data.isspace():
|
||||
if not self.section.text or not self.section.text[-1].isspace():
|
||||
self.section.text.append(data)
|
||||
elif "pre" in self.context:
|
||||
self.section.text.append(data)
|
||||
|
||||
# Handle everything else
|
||||
else:
|
||||
self.section.text.append(
|
||||
escape(data, quote = False)
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.search")
|
||||
|
||||
# Tags that are self-closing
|
||||
void = set([
|
||||
"area", # Image map areas
|
||||
"base", # Document base
|
||||
"br", # Line breaks
|
||||
"col", # Table columns
|
||||
"embed", # External content
|
||||
"hr", # Horizontal rules
|
||||
"img", # Images
|
||||
"input", # Input fields
|
||||
"link", # Links
|
||||
"meta", # Metadata
|
||||
"param", # External parameters
|
||||
"source", # Image source sets
|
||||
"track", # Text track
|
||||
"wbr" # Line break opportunities
|
||||
])
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2016-2025 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, ListOfItems, Type
|
||||
from mkdocs.config.defaults import _LogLevel
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Social plugin configuration
|
||||
class SocialConfig(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/social")
|
||||
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = _LogLevel(default = "warn")
|
||||
|
||||
# Settings for cards
|
||||
cards = Type(bool, default = True)
|
||||
cards_dir = Type(str, default = "assets/images/social")
|
||||
cards_layout_dir = Type(str, default = "layouts")
|
||||
cards_layout = Type(str, default = "default")
|
||||
cards_layout_options = Type(dict, default = {})
|
||||
cards_include = ListOfItems(Type(str), default = [])
|
||||
cards_exclude = ListOfItems(Type(str), default = [])
|
||||
|
||||
# Settings for debugging
|
||||
debug = Type(bool, default = False)
|
||||
debug_on_build = Type(bool, default = False)
|
||||
debug_grid = Type(bool, default = True)
|
||||
debug_grid_step = Type(int, default = 32)
|
||||
debug_color = Type(str, default = "grey")
|
||||
|
||||
# Deprecated settings
|
||||
cards_color = Deprecated(
|
||||
message =
|
||||
"Deprecated, use 'cards_layout_options.background_color' "
|
||||
"and 'cards_layout_options.color' with 'default' layout"
|
||||
)
|
||||
cards_font = Deprecated(
|
||||
message = "Deprecated, use 'cards_layout_options.font_family'"
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
# Copyright (c) 2016-2025 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 re
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import (
|
||||
Choice, DictOfItems, ListOfItems, SubConfig, Type
|
||||
)
|
||||
try:
|
||||
from PIL.Image import Image as _Image
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for origin
|
||||
Origin = (
|
||||
"start top", "center top", "end top",
|
||||
"start center", "center", "end center",
|
||||
"start bottom", "center bottom", "end bottom",
|
||||
"start", "end"
|
||||
)
|
||||
|
||||
# Options for overflow
|
||||
Overflow = (
|
||||
"truncate",
|
||||
"shrink"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Size
|
||||
class Size(Config):
|
||||
width = Type(int, default = 0)
|
||||
height = Type(int, default = 0)
|
||||
|
||||
# Offset
|
||||
class Offset(Config):
|
||||
x = Type(int, default = 0)
|
||||
y = Type(int, default = 0)
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Background
|
||||
class Background(Config):
|
||||
color = Type(str, default = "")
|
||||
image = Type(str, default = "")
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Icon
|
||||
class Icon(Config):
|
||||
value = Type(str, default = "")
|
||||
color = Type(str, default = "")
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Line
|
||||
class Line(Config):
|
||||
amount = Type((int, float), default = 1)
|
||||
height = Type((int, float), default = 1)
|
||||
|
||||
# Font
|
||||
class Font(Config):
|
||||
family = Type(str, default = "Roboto")
|
||||
variant = Type(str, default = "")
|
||||
style = Type(str, default = "Regular")
|
||||
|
||||
# Typography
|
||||
class Typography(Config):
|
||||
content = Type(str, default = "")
|
||||
align = Choice(Origin, default = "start top")
|
||||
overflow = Choice(Overflow, default = "truncate")
|
||||
color = Type(str, default = "")
|
||||
line = SubConfig(Line)
|
||||
font = SubConfig(Font)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Layer
|
||||
class Layer(Config):
|
||||
size = SubConfig(Size)
|
||||
offset = SubConfig(Offset)
|
||||
origin = Choice(Origin, default = "start top")
|
||||
background = SubConfig(Background)
|
||||
icon = SubConfig(Icon)
|
||||
typography = SubConfig(Typography)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Layout
|
||||
class Layout(Config):
|
||||
definitions = ListOfItems(Type(str), default = [])
|
||||
tags = DictOfItems(Type(str), default = {})
|
||||
size = SubConfig(Size)
|
||||
layers = ListOfItems(SubConfig(Layer), default = [])
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Get layer or layout size as tuple
|
||||
def get_size(layer: Layer | Layout):
|
||||
return layer.size.width, layer.size.height
|
||||
|
||||
# Get layer offset as tuple
|
||||
def get_offset(layer: Layer, image: _Image):
|
||||
x, y = layer.offset.x, layer.offset.y
|
||||
|
||||
# Compute offset from origin - if an origin is given, compute the offset
|
||||
# relative to the image and layer size to allow for flexible positioning
|
||||
if layer.origin != "start top":
|
||||
origin = re.split(r"\s+", layer.origin)
|
||||
|
||||
# Get layer size
|
||||
w, h = get_size(layer)
|
||||
|
||||
# Compute origin on x-axis
|
||||
if "start" in origin: pass
|
||||
elif "end" in origin: x += (image.width - w) - 2 * x
|
||||
elif "center" in origin: x += (image.width - w) >> 1
|
||||
|
||||
# Compute origin on y-axis
|
||||
if "top" in origin: pass
|
||||
elif "bottom" in origin: y += (image.height - h) - 2 * y
|
||||
elif "center" in origin: y += (image.height - h) >> 1
|
||||
|
||||
# Return offset
|
||||
return x, y
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 18684 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user