This commit is contained in:
CreaRobotics committed 2026-03-02 11:34:40 +01:00
1 parent 6ccab0347b
commit 954a2fd6d6
18684 files changed
+601012 -190

No files matched your search

@@ -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
)
@@ -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)
@@ -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
@@ -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)
@@ -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()
@@ -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")
@@ -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))
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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")
@@ -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")
@@ -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)
@@ -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)
@@ -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
@@ -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.
@@ -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.
@@ -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
@@ -0,0 +1,29 @@
# 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
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Filter for coercing everthing that is falsy to an empty string
def x_filter(value: str | None):
return value or ""
@@ -0,0 +1,244 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular
@@ -0,0 +1,234 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("accent") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set accent = palette.get("accent", "indigo") -%}
{%- set accent = accent.replace(" ", "-") -%}
{{ {
"red": "#ff1a47",
"pink": "#f50056",
"purple": "#df41fb",
"deep-purple": "#7c4dff",
"indigo": "#526cfe",
"blue": "#4287ff",
"light-blue": "#0091eb",
"cyan": "#00bad6",
"teal": "#00bda4",
"green": "#00c753",
"light-green": "#63de17",
"lime": "#b0eb00",
"yellow": "#ffd500",
"amber": "#ffaa00",
"orange": "#ff9100",
"deep-orange": "#ff6e42"
}[accent] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("accent") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set accent = palette.get("accent", "indigo") -%}
{%- set accent = accent.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff"
}[accent] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular
@@ -0,0 +1,244 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: white)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Text color (default: indigo)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular
@@ -0,0 +1,77 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image }}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
@@ -0,0 +1,255 @@
# 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.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Page icon
- &page_icon >-
{{ page.meta.icon | x }}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Page icon
- size: { width: 630, height: 630 }
offset: { x: 800, y: 0 }
icon:
value: *page_icon
color: "#00000033"
# Logo
- size: { width: 64, height: 64 }
offset: { x: 64, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 768, height: 42 }
offset: { x: 160, y: 74 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 864, height: 256 }
offset: { x: 62, y: 192 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 864, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular
@@ -0,0 +1,46 @@
# 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.mapping import Mapping
from .structure.tag import Tag
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Return tag name for sorting
def tag_name(tag: Tag, *args):
return tag.name
# Return casefolded tag name for sorting
def tag_name_casefold(tag: Tag, *args):
return tag.name.casefold()
# -----------------------------------------------------------------------------
# Return item title for sorting
def item_title(mapping: Mapping):
# Note that this must be coerced to a string, as the title might be sourced
# from metadata, which can be of any type - see https://t.ly/1AXyo
return str(mapping.item.title)
# Return item URL for sorting
def item_url(mapping: Mapping):
return mapping.item.url
Loaded 100 of 18684 files, more files were not shown because too many files have changed in this diff. Show more