docs: integrate mkdocs into repository

This commit is contained in:
2025-02-23 11:48:27 -07:00
parent cf76a511fa
commit e9ddaa0706
13251 changed files with 23348 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,42 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 = {})

View File

@@ -0,0 +1,90 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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
# -----------------------------------------------------------------------------
# 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_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_allowed = Type(list, default = [])
categories_toc = Optional(Type(bool))
# Settings for authors
authors = Type(bool, default = True)
authors_file = Type(str, default = "{blog}/.authors.yml")
# 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")

View File

@@ -0,0 +1,868 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 posixpath
import yaml
from babel.dates import format_date
from datetime import datetime
from jinja2 import pass_context
from jinja2.runtime import Context
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure import StructureItem
from mkdocs.structure.files import File, Files, InclusionLevel
from mkdocs.structure.nav import Navigation, Section
from mkdocs.structure.pages import Page
from mkdocs.utils import copy_file, get_relative_url
from mkdocs.utils.templates import url_filter
from paginate import Page as Pagination
from shutil import rmtree
from tempfile import mkdtemp
from yaml import SafeLoader
from .author import Authors
from .config import BlogConfig
from .readtime import readtime
from .structure import Archive, Category, Excerpt, Post, View
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Blog plugin
class BlogPlugin(BasePlugin[BlogConfig]):
supports_multiple_instances = True
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
self.is_dirty = False
# Initialize temporary directory
self.temp_dir = mkdtemp()
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
self.is_dirty = dirty
# Initialize authors and set defaults
def on_config(self, config):
if not self.config.enabled:
return
# Initialize entrypoint
self.blog: View
# Initialize and resolve authors, if enabled
if self.config.authors:
self.authors = self._resolve_authors(config)
# Initialize table of contents settings
if not isinstance(self.config.archive_toc, bool):
self.config.archive_toc = self.config.blog_toc
if not isinstance(self.config.categories_toc, bool):
self.config.categories_toc = self.config.blog_toc
# By default, drafts are rendered when the documentation is served,
# but not when it is built, for a better user experience
if self.is_serve and self.config.draft_on_serve:
self.config.draft = True
# Resolve and load posts and generate views (run later) - we want to allow
# other plugins to add generated posts or views, so we run this plugin as
# late as possible. We also need to remove the posts from the navigation
# before navigation is constructed, as the entrypoint should be considered
# to be the active page for each post. The URLs of posts are computed before
# Markdown processing, so that when linking to and from posts, behavior is
# exactly the same as with regular documentation pages. We create all pages
# related to posts as part of this plugin, so we control the entire process.
@event_priority(-50)
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Resolve path to entrypoint and site directory
root = posixpath.normpath(self.config.blog_dir)
site = config.site_dir
# Compute and normalize path to posts directory
path = self.config.post_dir.format(blog = root)
path = posixpath.normpath(path)
# Adjust destination paths for media files
for file in files.media_files():
if not file.src_uri.startswith(path):
continue
# We need to adjust destination paths for assets to remove the
# purely functional posts directory prefix when building
file.dest_uri = file.dest_uri.replace(path, root)
file.abs_dest_path = os.path.join(site, file.dest_path)
file.url = file.url.replace(path, root)
# Resolve entrypoint and posts sorted by descending date - if the posts
# directory or entrypoint do not exist, they are automatically created
self.blog = self._resolve(files, config)
self.blog.posts = sorted(
self._resolve_posts(files, config),
key = lambda post: post.config.date.created,
reverse = True
)
# Generate views for archive
if self.config.archive:
self.blog.views.extend(
self._generate_archive(config, files)
)
# Generate views for categories
if self.config.categories:
self.blog.views.extend(sorted(
self._generate_categories(config, files),
key = lambda view: view.name,
reverse = False
))
# Generate pages for views
if self.config.pagination:
for view in self._resolve_views(self.blog):
for page in self._generate_pages(view, config, files):
view.pages.append(page)
# Ensure that entrypoint is always included in navigation
self.blog.file.inclusion = InclusionLevel.INCLUDED
# Attach posts and views to navigation (run later) - again, we allow other
# plugins to alter the navigation before we start to attach posts and views
# generated by this plugin at the correct locations in the navigation. Also,
# we make sure to correct links to the parent and siblings of each page.
@event_priority(-50)
def on_nav(self, nav, *, config, files):
if not self.config.enabled:
return
# If we're not building a standalone blog, the entrypoint will always
# have a parent when it is included in the navigation. The parent is
# essential to correctly resolve the location where the archive and
# category views are attached. If the entrypoint doesn't have a parent,
# we know that the author did not include it in the navigation, so we
# explicitly mark it as not included.
if not self.blog.parent and self.config.blog_dir != ".":
self.blog.file.inclusion = InclusionLevel.NOT_IN_NAV
# Attach posts to entrypoint without adding them to the navigation, so
# that the entrypoint is considered to be the active page for each post
self._attach(self.blog, [None, *reversed(self.blog.posts), None])
for post in self.blog.posts:
post.file.inclusion = InclusionLevel.NOT_IN_NAV
# Revert temporary exclusion of views from navigation
for view in self._resolve_views(self.blog):
view.file.inclusion = self.blog.file.inclusion
for page in view.pages:
page.file.inclusion = self.blog.file.inclusion
# Attach views for archive
if self.config.archive:
title = self._translate(self.config.archive_name, config)
views = [_ for _ in self.blog.views if isinstance(_, Archive)]
# Attach and link views for archive
if self.blog.file.inclusion.is_in_nav():
self._attach_to(self.blog, Section(title, views), nav)
# Attach views for categories
if self.config.categories:
title = self._translate(self.config.categories_name, config)
views = [_ for _ in self.blog.views if isinstance(_, Category)]
# Attach and link views for categories, if any
if self.blog.file.inclusion.is_in_nav() and views:
self._attach_to(self.blog, Section(title, views), nav)
# Attach pages for views
if self.config.pagination:
for view in self._resolve_views(self.blog):
for at in range(1, len(view.pages)):
self._attach_at(view.parent, view, view.pages[at])
# Prepare post for rendering (run later) - allow other plugins to alter
# the contents or metadata of a post before it is rendered and make sure
# that the post includes a separator, which is essential for rendering
# excerpts that should be included in views
@event_priority(-50)
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Skip if page is not a post managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
if page not in self.blog.posts:
if not self.config.pagination:
return
# We set the contents of the view to its title if pagination should
# not keep the content of the original view on paginated views
if not self.config.pagination_keep_content:
view = self._resolve_original(page)
if view in self._resolve_views(self.blog):
# If the current view is paginated, use the rendered title
# of the original view in case the author set the title in
# the page's contents, or it would be overridden with the
# one set in mkdocs.yml, leading to inconsistent headings
assert isinstance(view, View)
if view != page:
name = view._title_from_render or view.title
return f"# {name}"
# Nothing more to be done for views
return
# Extract and assign authors to post, if enabled
if self.config.authors:
for name in page.config.authors:
if name not in self.authors:
raise PluginError(f"Couldn't find author '{name}'")
# Append to list of authors
page.authors.append(self.authors[name])
# Extract settings for excerpts
separator = self.config.post_excerpt_separator
max_authors = self.config.post_excerpt_max_authors
max_categories = self.config.post_excerpt_max_categories
# Ensure presence of separator and throw, if its absent and required -
# we append the separator to the end of the contents of the post, if it
# is not already present, so we can remove footnotes or other content
# from the excerpt without affecting the content of the excerpt
if separator not in page.markdown:
if self.config.post_excerpt == "required":
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(page.file.abs_src_path, docs)
raise PluginError(
f"Couldn't find '{separator}' in post '{path}' in '{docs}'"
)
# Create excerpt for post and inherit authors and categories - excerpts
# can contain a subset of the authors and categories of the post
page.excerpt = Excerpt(page, config, files)
page.excerpt.authors = page.authors[:max_authors]
page.excerpt.categories = page.categories[:max_categories]
# Process posts
def on_page_content(self, html, *, page, config, files):
if not self.config.enabled:
return
# Skip if page is not a post managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
if page not in self.blog.posts:
return
# Compute readtime of post, if enabled and not explicitly set
if self.config.post_readtime:
words_per_minute = self.config.post_readtime_words_per_minute
if not page.config.readtime:
page.config.readtime = readtime(html, words_per_minute)
# Register template filters for plugin
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Filter for formatting dates related to posts
def date_filter(date: datetime):
return self._format_date_for_post(date, config)
# Patch URL template filter to add support for paginated views, i.e.,
# that paginated views never link to themselves but to the main view
@pass_context
def url_filter_with_pagination(context: Context, url: str | None):
page = context["page"]
# If the current page is a view, check if the URL links to the page
# itself, and replace it with the URL of the main view
if isinstance(page, View):
view = self._resolve_original(page)
if page.url == url:
url = view.url
# Forward to original template filter
return url_filter(context, url)
# Register custom template filters
env.filters["date"] = date_filter
env.filters["url"] = url_filter_with_pagination
# Prepare view for rendering (run latest) - views are rendered last, as we
# need to mutate the navigation to account for pagination. The main problem
# is that we need to replace the view in the navigation, because otherwise
# the view would not be considered active.
@event_priority(-100)
def on_page_context(self, context, *, page, config, nav):
if not self.config.enabled:
return
# Skip if page is not a view managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
view = self._resolve_original(page)
if view not in self._resolve_views(self.blog):
return
# Render excerpts and prepare pagination
posts, pagination = self._render(page)
# Render pagination links
def pager(args: object):
return pagination.pager(
format = self.config.pagination_format,
show_if_single_page = self.config.pagination_if_single_page,
**args
)
# Assign posts and pagination to context
context["posts"] = posts
context["pagination"] = pager if pagination else None
# Remove temporary directory on shutdown
def on_shutdown(self):
rmtree(self.temp_dir)
# -------------------------------------------------------------------------
# Check if the given post is excluded
def _is_excluded(self, post: Post):
if self.config.draft:
return False
# If a post was not explicitly marked or unmarked as draft, and the
# date should be taken into account, we automatically mark it as draft
# if the publishing date is in the future. This, of course, is opt-in
# and must be explicitly enabled by the author.
if not isinstance(post.config.draft, bool):
if self.config.draft_if_future_date:
return post.config.date.created > datetime.now()
# Post might be a draft
return bool(post.config.draft)
# -------------------------------------------------------------------------
# Resolve entrypoint - the entrypoint of the blog must have been created
# if it did not exist before, and hosts all posts sorted by descending date
def _resolve(self, files: Files, config: MkDocsConfig):
path = os.path.join(self.config.blog_dir, "index.md")
path = os.path.normpath(path)
# Create entrypoint, if it does not exist - note that the entrypoint is
# created in the docs directory, not in the temporary directory
docs = os.path.relpath(config.docs_dir)
name = os.path.join(docs, path)
if not os.path.isfile(name):
file = self._path_to_file(path, config, temp = False)
files.append(file)
# Create file in docs directory
self._save_to_file(file.abs_src_path, "# Blog\n\n")
# Create and return entrypoint
file = files.get_file_from_path(path)
return View(None, file, config)
# Resolve post - the caller must make sure that the given file points to an
# actual post (and not a page), or behavior might be unpredictable
def _resolve_post(self, file: File, config: MkDocsConfig):
post = Post(file, config)
# Compute path and create a temporary file for path resolution
path = self._format_path_for_post(post, config)
temp = self._path_to_file(path, config, temp = False)
# Replace destination file system path and URL
file.dest_uri = temp.dest_uri
file.abs_dest_path = temp.abs_dest_path
file.url = temp.url
# Replace canonical URL and return post
post._set_canonical_url(config.site_url)
return post
# Resolve posts from directory - traverse all documentation pages and filter
# and yield those that are located in the posts directory
def _resolve_posts(self, files: Files, config: MkDocsConfig):
path = self.config.post_dir.format(blog = self.config.blog_dir)
path = os.path.normpath(path)
# Create posts directory, if it does not exist
docs = os.path.relpath(config.docs_dir)
name = os.path.join(docs, path)
if not os.path.isdir(name):
os.makedirs(name, exist_ok = True)
# Filter posts from pages
for file in files.documentation_pages():
if not file.src_path.startswith(path):
continue
# Temporarily remove post from navigation
file.inclusion = InclusionLevel.EXCLUDED
# Resolve post - in order to determine whether a post should be
# excluded, we must load it and analyze its metadata. All posts
# marked as drafts are excluded, except for when the author has
# configured drafts to be included in the navigation.
post = self._resolve_post(file, config)
if not self._is_excluded(post):
yield post
# Resolve authors - check if there's an authors file at the configured
# location, and if one was found, load and validate it
def _resolve_authors(self, config: MkDocsConfig):
path = self.config.authors_file.format(blog = self.config.blog_dir)
path = os.path.normpath(path)
# Resolve path relative to docs directory
docs = os.path.relpath(config.docs_dir)
file = os.path.join(docs, path)
# If the authors file does not exist, return here
config: Authors = Authors()
if not os.path.isfile(file):
return config.authors
# Open file and parse as YAML
with open(file, encoding = "utf-8") as f:
config.config_file_path = os.path.abspath(file)
try:
config.load_dict(yaml.load(f, SafeLoader) or {})
# The authors 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 authors file '{path}' in '{docs}':\n"
f"{e}"
)
# Validate authors and throw if errors occurred
errors, warnings = config.validate()
for _, w in warnings:
log.warning(w)
for _, e in errors:
raise PluginError(
f"Error reading authors file '{path}' in '{docs}':\n"
f"{e}"
)
# Return authors
return config.authors
# Resolve views of the given view in pre-order
def _resolve_views(self, view: View):
yield view
# Resolve views recursively
for page in view.views:
for next in self._resolve_views(page):
assert isinstance(next, View)
yield next
# Resolve siblings of a navigation item
def _resolve_siblings(self, item: StructureItem, nav: Navigation):
if isinstance(item.parent, Section):
return item.parent.children
else:
return nav.items
# Resolve original page or view (e.g. for paginated views)
def _resolve_original(self, page: Page):
if isinstance(page, View) and page.pages:
return page.pages[0]
else:
return page
# -------------------------------------------------------------------------
# Generate views for archive - analyze posts and generate the necessary
# views, taking the date format provided by the author into account
def _generate_archive(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
date = post.config.date.created
# Compute name and path of archive view
name = self._format_date_for_archive(date, config)
path = self._format_path_for_archive(post, config)
# Create file for view, if it does not exist
file = files.get_file_from_path(path)
if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
self._save_to_file(file.abs_src_path, f"# {name}")
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, Archive):
yield Archive(name, file, config)
# Assign post to archive
assert isinstance(file.page, Archive)
file.page.posts.append(post)
# Generate views for categories - analyze posts and generate the necessary
# views, taking the allowed categories as set by the author into account
def _generate_categories(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
for name in post.config.categories:
path = self._format_path_for_category(name)
# Ensure category is in non-empty allow list
categories = self.config.categories_allowed or [name]
if name not in categories:
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(post.file.abs_src_path, docs)
raise PluginError(
f"Error reading categories of post '{path}' in "
f"'{docs}': category '{name}' not in allow list"
)
# Create file for view, if it does not exist
file = files.get_file_from_path(path)
if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
self._save_to_file(file.abs_src_path, f"# {name}")
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, Category):
yield Category(name, file, config)
# Assign post to category and vice versa
assert isinstance(file.page, Category)
file.page.posts.append(post)
post.categories.append(file.page)
# Generate pages for pagination - analyze view and generate the necessary
# pages, creating a chain of views for simple rendering and replacement
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
yield view
# Compute pagination boundaries and create pages - pages are internally
# handled as copies of a view, as they map to the same source location
step = self.config.pagination_per_page
for at in range(step, len(view.posts), step):
path = self._format_path_for_pagination(view, 1 + at // step)
# Create file for view, if it does not exist
file = files.get_file_from_path(path)
if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config)
files.append(file)
# Copy file to temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
copy_file(view.file.abs_src_path, file.abs_src_path)
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, View):
yield view.__class__(None, file, config)
# Assign pages and posts to view
assert isinstance(file.page, View)
file.page.pages = view.pages
file.page.posts = view.posts
# -------------------------------------------------------------------------
# Attach a list of pages to each other and to the given parent item without
# explicitly adding them to the navigation, which can be done by the caller
def _attach(self, parent: StructureItem, pages: list[Page]):
for tail, page, head in zip(pages, pages[1:], pages[2:]):
# Link page to parent and siblings
page.parent = parent
page.previous_page = tail
page.next_page = head
# If the page is a view, we know that we generated it and need to
# link its siblings back to the view
if isinstance(page, View):
view = self._resolve_original(page)
if tail: tail.next_page = view
if head: head.previous_page = view
# Attach a page to the given parent and link it to the previous and next
# page of the given host - this is exclusively used for paginated views
def _attach_at(self, parent: StructureItem, host: Page, page: Page):
self._attach(parent, [host.previous_page, page, host.next_page])
# Attach a section as a sibling to the given view, make sure its pages are
# part of the navigation, and ensure all pages are linked correctly
def _attach_to(self, view: View, section: Section, nav: Navigation):
section.parent = view.parent
# Resolve siblings, which are the children of the parent section, or
# the top-level list of navigation items if the view is at the root of
# the project, and append the given section to it. It's currently not
# possible to chose the position of a section.
items = self._resolve_siblings(view, nav)
items.append(section)
# Find last sibling that is a page, skipping sections, as we need to
# append the given section after all other pages
tail = next(item for item in reversed(items) if isinstance(item, Page))
head = tail.next_page
# Attach section to navigation and pages to each other
nav.pages.extend(section.children)
self._attach(section, [tail, *section.children, head])
# -------------------------------------------------------------------------
# Render excerpts and pagination for the given view
def _render(self, view: View):
posts, pagination = view.posts, None
# Create pagination, if enabled
if self.config.pagination:
at = view.pages.index(view)
# Compute pagination boundaries
step = self.config.pagination_per_page
p, q = at * step, at * step + step
# Extract posts in pagination boundaries
posts = view.posts[p:q]
pagination = self._render_pagination(view, (p, q))
# Render excerpts for selected posts
posts = [
self._render_post(post.excerpt, view)
for post in posts if post.excerpt
]
# Return posts and pagination
return posts, pagination
# Render excerpt in the context of the given view
def _render_post(self, excerpt: Excerpt, view: View):
excerpt.render(view, self.config.post_excerpt_separator)
# Determine whether to add posts to the table of contents of the view -
# note that those settings can be changed individually for each type of
# view, which is why we need to check the type of view and the table of
# contents setting for that type of view
toc = self.config.blog_toc
if isinstance(view, Archive):
toc = self.config.archive_toc
if isinstance(view, Category):
toc = self.config.categories_toc
# Attach top-level table of contents item to view if it should be added
# and both, the view and excerpt contain table of contents items
if toc and excerpt.toc.items and view.toc.items:
view.toc.items[0].children.append(excerpt.toc.items[0])
# Return excerpt
return excerpt
# Create pagination for the given view and range
def _render_pagination(self, view: View, range: tuple[int, int]):
p, q = range
# Create URL from the given page to another page
def url_maker(n: int):
return get_relative_url(view.pages[n - 1].url, view.url)
# Return pagination
return Pagination(
view.posts, page = q // (q - p),
items_per_page = q - p,
url_maker = url_maker
)
# -------------------------------------------------------------------------
# Format path for post
def _format_path_for_post(self, post: Post, config: MkDocsConfig):
categories = post.config.categories[:self.config.post_url_max_categories]
categories = [self._slugify_category(name) for name in categories]
# Replace placeholders in format string
date = post.config.date.created
path = self.config.post_url_format.format(
categories = "/".join(categories),
date = self._format_date_for_post_url(date, config),
file = post.file.name,
slug = post.config.slug or self._slugify_post(post)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for archive
def _format_path_for_archive(self, post: Post, config: MkDocsConfig):
date = post.config.date.created
path = self.config.archive_url_format.format(
date = self._format_date_for_archive_url(date, config)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for category
def _format_path_for_category(self, name: str):
path = self.config.categories_url_format.format(
slug = self._slugify_category(name)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for pagination
def _format_path_for_pagination(self, view: View, page: int):
path = self.config.pagination_url_format.format(
page = page
)
# Compute base path for pagination - if the given view is an index file,
# we need to pop the file name from the base so it's not part of the URL
# and we need to append `index` to the path, so the paginated view is
# also an index page - see https://t.ly/71MKF
base, _ = posixpath.splitext(view.file.src_uri)
if view.is_index:
base = posixpath.dirname(base)
path = posixpath.join(path, "index")
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(base, f"{path}.md")
# -------------------------------------------------------------------------
# Format date
def _format_date(self, date: datetime, format: str, config: MkDocsConfig):
locale: str = config.theme["language"].replace("-", "_")
return format_date(date, format = format, locale = locale)
# Format date for post
def _format_date_for_post(self, date: datetime, config: MkDocsConfig):
format = self.config.post_date_format
return self._format_date(date, format, config)
# Format date for post URL
def _format_date_for_post_url(self, date: datetime, config: MkDocsConfig):
format = self.config.post_url_date_format
return self._format_date(date, format, config)
# Format date for archive
def _format_date_for_archive(self, date: datetime, config: MkDocsConfig):
format = self.config.archive_date_format
return self._format_date(date, format, config)
# Format date for archive URL
def _format_date_for_archive_url(self, date: datetime, config: MkDocsConfig):
format = self.config.archive_url_date_format
return self._format_date(date, format, config)
# -------------------------------------------------------------------------
# Slugify post title
def _slugify_post(self, post: Post):
separator = self.config.post_slugify_separator
return self.config.post_slugify(post.title, separator)
# Slugify category
def _slugify_category(self, name: str):
separator = self.config.categories_slugify_separator
return self.config.categories_slugify(name, separator)
# -------------------------------------------------------------------------
# Create a file for the given path, which must point to a valid source file,
# either inside the temporary directory or the docs directory
def _path_to_file(self, path: str, config: MkDocsConfig, *, temp = True):
assert path.endswith(".md")
file = File(
path,
config.docs_dir if not temp else self.temp_dir,
config.site_dir,
config.use_directory_urls
)
# Hack: mark file as generated, so other plugins don't think it's part
# of the file system. This is more or less a new quasi-standard that
# still needs to be adopted by MkDocs, and was introduced by the
# git-revision-date-localized-plugin - see https://bit.ly/3ZUmdBx
if temp:
file.generated_by = "material/blog"
# Return file
return file
# Create a file with the given content on disk
def _save_to_file(self, path: str, content: str):
os.makedirs(os.path.dirname(path), exist_ok = True)
with open(path, "w", encoding = "utf-8") as f:
f.write(content)
# -------------------------------------------------------------------------
# Translate the placeholder referenced by the given key
def _translate(self, key: str, config: MkDocsConfig) -> str:
env = config.theme.get_env()
template = env.get_template(
"partials/language.html", globals = { "config": config }
)
# Translate placeholder
return template.module.t(key)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.blog")

View File

@@ -0,0 +1,53 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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)

View File

@@ -0,0 +1,47 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Readtime parser
class ReadtimeParser(HTMLParser):
# Initialize parser
def __init__(self):
super().__init__(convert_charrefs = True)
# Keep track of text and images
self.text = []
self.images = 0
# Collect images
def handle_starttag(self, tag, attrs):
if tag == "img":
self.images += 1
# Collect text
def handle_data(self, data):
self.text.append(data)

View File

@@ -0,0 +1,308 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.structure.files import File, Files
from mkdocs.structure.nav import 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") 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}"
)
# 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
# -----------------------------------------------------------------------------
# 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")

View File

@@ -0,0 +1,39 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 ListOfItems, Optional, Type
from .options import PostDate
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Post configuration
class PostConfig(Config):
authors = ListOfItems(Type(str), default = [])
categories = ListOfItems(Type(str), default = [])
date = PostDate()
draft = Optional(Type(bool))
readtime = Optional(Type(int))
slug = Optional(Type(str))

View File

@@ -0,0 +1,60 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 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):
self.page = page
self.base = base
# Transform HTML after Markdown processing
def run(self, root: Element):
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

View File

@@ -0,0 +1,97 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
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):
continue
# Handle date - we set 00:00:00 as the default time, if the author
# only supplied a date, and convert it to datetime
if isinstance(value, date):
config[key_name][key] = datetime.combine(value, time())
# 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

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,33 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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))

View File

@@ -0,0 +1,163 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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")

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,37 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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)

View File

@@ -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/",
]

View File

@@ -0,0 +1,498 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 glob
import json
import logging
import os
import platform
import regex
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 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]
# 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 = [
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(os.getcwd()):
paths_to_validate.remove(path)
# Report the invalid paths to the user
if paths_to_validate:
log.error(f"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 config.site_dir.startswith(os.getcwd()):
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(os.getcwd()):
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)
# 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)
# 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
# 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()
]))
)
# 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", ""),
"sys.path": sys.path,
"excluded_entries": self.excluded_entries
},
default = str,
indent = 2
)
)
# Retrieve list of processed files
for a in f.filelist:
files.append("".join([
Fore.LIGHTBLACK_EX, 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")
# 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 regex.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, "r", 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 = abspath.replace(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") 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
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.info")

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,32 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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)

View File

@@ -0,0 +1,71 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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}")

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,45 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 DictOfItems, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Privacy plugin configuration
class PrivacyConfig(Config):
enabled = Type(bool, default = True)
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
# Settings for caching
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/privacy")
# Settings for external assets
assets = Type(bool, default = True)
assets_fetch = Type(bool, default = True)
assets_fetch_dir = Type(str, default = "assets/external")
assets_expr_map = DictOfItems(Type(str), default = {})

View File

@@ -0,0 +1,43 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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))

View File

@@ -0,0 +1,552 @@
# #
# Copyright (c) 2025 Aetherinox
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# #
from __future__ import annotations
import errno
import logging
import os
import posixpath
import re
import requests
import sys
from colorama import Fore, Style
from concurrent.futures import Future, ThreadPoolExecutor, wait
from hashlib import sha1
from mkdocs.config.config_options import ExtraScriptValue
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure.files import File, Files
from mkdocs.utils import is_error_template
from re import Match
from urllib.parse import ParseResult as URL, urlparse, unquote
from xml.etree.ElementTree import Element, tostring
from .config import PrivacyConfig
from .parser import FragmentParser
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Privacy plugin
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
# Initialize thread pools and asset collections
def on_config(self, config):
self.site = urlparse(config.site_url or "")
if not self.config.enabled:
return
# Initialize thread pool
self.pool = ThreadPoolExecutor(self.config.concurrency)
self.pool_jobs: list[Future] = []
# Initialize collections of external assets
self.assets = Files([])
self.assets_expr_map = {
".css": r"url\((\s*http?[^)]+)\)",
".js": r"[\"'](http[^\"']+\.(?:css|js(?:on)?))[\"']",
**self.config.assets_expr_map
}
# Process external style sheets and scripts (run latest) - run this after
# all other plugins, so they can add additional assets
@event_priority(-100)
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Skip if external assets must not be processed
if not self.config.assets:
return
# Find all external style sheet and script files that are provided as
# part of the build (= already known to MkDocs on startup)
for initiator in files.media_files():
file = None
# Check if the file has dependent external assets that must be
# downloaded. Create and enqueue a job for each external asset.
for url in self._parse_media(initiator):
if not self._is_excluded(url, initiator):
file = self._queue(url, config, concurrent = True)
# If site URL is not given, ensure that Mermaid.js is always
# present. This is a special case, as Material for MkDocs
# automatically loads Mermaid.js when a Mermaid diagram is
# found in the page - https://bit.ly/36tZXsA.
if "mermaid.min.js" in url.path and not config.site_url:
path = url.geturl()
if path not in config.extra_javascript:
config.extra_javascript.append(
ExtraScriptValue(path)
)
# The local asset references at least one external asset, which
# means we must download and replace them later
if file:
self.assets.append(initiator)
files.remove(initiator)
# Process external style sheet files
for path in config.extra_css:
url = urlparse(path)
if not self._is_excluded(url):
self._queue(url, config, concurrent = True)
# Process external script files
for script in config.extra_javascript:
if isinstance(script, str):
script = ExtraScriptValue(script)
# Enqueue a job if the script needs to downloaded
url = urlparse(script.path)
if not self._is_excluded(url):
self._queue(url, config, concurrent = True)
# Process external images in page (run latest) - this stage is the earliest
# we can start processing external images, since images are the most common
# type of external asset when writing. Thus, we create and enqueue a job for
# each image we find that checks if the image needs to be downloaded.
@event_priority(-100)
def on_page_content(self, html, *, page, config, files):
if not self.config.enabled:
return
# Skip if external assets must not be processed
if not self.config.assets:
return
# Find all external images and download them if not excluded
for match in re.findall(
r"<img[^>]+src=['\"]?http[^>]+>",
html, flags = re.I | re.M
):
el = self._parse_fragment(match)
# Create and enqueue job to fetch external image
url = urlparse(el.get("src"))
if not self._is_excluded(url, page.file):
self._queue(url, config, concurrent = True)
# Process external assets in template (run later)
@event_priority(-50)
def on_post_template(self, output_content, *, template_name, config):
if not self.config.enabled:
return
# Skip sitemap.xml and other non-HTML files
if not template_name.endswith(".html"):
return
# Parse and replace links to external assets in template
initiator = File(template_name, config.docs_dir, config.site_dir, False)
return self._parse_html(output_content, initiator, config)
# Process external assets in page (run later)
@event_priority(-50)
def on_post_page(self, output, *, page, config):
if not self.config.enabled:
return
# Parse and replace links to external assets
return self._parse_html(output, page.file, config)
# Reconcile jobs (run earlier)
@event_priority(50)
def on_post_build(self, *, config):
if not self.config.enabled:
return
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
# same thread pool for patching all links to external assets
wait(self.pool_jobs)
self.pool_jobs.clear()
# Spawn concurrent job to patch all links to dependent external asset
# in all style sheet and script files
for file in self.assets:
_, extension = posixpath.splitext(file.dest_uri)
if extension in [".css", ".js"]:
self.pool_jobs.append(self.pool.submit(
self._patch, file
))
# Otherwise just copy external asset to output directory
else:
file.copy_file()
# Reconcile concurrent jobs for the last time, so the plugins following
# in the build process always have a consistent state to work with
wait(self.pool_jobs)
self.pool.shutdown()
# -------------------------------------------------------------------------
# Check if the given URL is external
def _is_external(self, url: URL):
hostname = url.hostname or self.site.hostname
return hostname != self.site.hostname
# Check if the given URL is excluded
def _is_excluded(self, url: URL, initiator: File | None = None):
if not self._is_external(url):
return True
# Skip if external assets must not be processed
if not self.config.assets:
return True
# If initiator is given, format for printing
via = ""
if initiator:
via = "".join([
Fore.WHITE, Style.DIM,
f"in '{initiator.src_uri}' ",
Style.RESET_ALL
])
# Print warning if fetching is not enabled
if not self.config.assets_fetch:
log.warning(f"External file: {url.geturl()} {via}")
return True
# File is not excluded
return False
# -------------------------------------------------------------------------
# Parse a fragment
def _parse_fragment(self, fragment: str):
parser = FragmentParser()
parser.feed(fragment)
parser.close()
# Return element
assert isinstance(parser.result, Element)
return parser.result
# Parse and extract all external assets from a media file using a preset
# regular expression, and return all URLs found.
def _parse_media(self, initiator: File) -> list[URL]:
_, extension = posixpath.splitext(initiator.dest_uri)
if extension not in self.assets_expr_map:
return []
# Find and extract all external asset URLs
expr = re.compile(self.assets_expr_map[extension], flags = re.I | re.M)
with open(initiator.abs_src_path, encoding = "utf-8") as f:
return [urlparse(url) for url in re.findall(expr, f.read())]
# Parse template or page HTML and find all external links that need to be
# replaced. Many of the assets should already be downloaded earlier, i.e.,
# everything that was directly referenced in the document, but there may
# still exist external assets that were added by third-party plugins.
def _parse_html(self, output: str, initiator: File, config: MkDocsConfig):
# Resolve callback
def resolve(file: File):
if is_error_template(initiator.src_uri):
base = urlparse(config.site_url or "/")
return posixpath.join(base.path, file.url)
else:
return file.url_relative_to(initiator)
# Replace callback
def replace(match: Match):
el = self._parse_fragment(match.group())
# Handle external style sheet or preconnect hint
if el.tag == "link":
url = urlparse(el.get("href"))
if not self._is_excluded(url, initiator):
rel = el.get("rel", "")
# Replace external preconnect hint
if rel == "preconnect":
return ""
# Replace external style sheet or favicon
if rel == "stylesheet" or rel == "icon":
file = self._queue(url, config)
el.set("href", resolve(file))
# Handle external script or image
if el.tag == "script" or el.tag == "img":
url = urlparse(el.get("src"))
if not self._is_excluded(url, initiator):
file = self._queue(url, config)
el.set("src", resolve(file))
# Return element as string
return self._print(el)
# Find and replace all external asset URLs in current page
return re.sub(
r"<(?:(?:a|link)[^>]+href|(?:script|img)[^>]+src)=['\"]?http[^>]+>",
replace, output, flags = re.I | re.M
)
# -------------------------------------------------------------------------
# Print element as string - what could possibly go wrong? We're parsing
# HTML5 with an XML parser, and XML doesn't allow for boolean attributes,
# which is why we must add a dummy value to all attributes that are not
# strings before printing the element as string.
def _print(self, el: Element):
temp = "__temp__"
for name in el.attrib:
if not isinstance(el.attrib[name], str):
el.attrib[name] = temp
# Return void or opening tag as string, strip closing tag
data = tostring(el, encoding = "unicode")
return data.replace(" />", ">").replace(f"\"{temp}\"", "")
# Enqueue external asset for download, if not already done
def _queue(self, url: URL, config: MkDocsConfig, concurrent = False):
path = self._path_from_url(url)
full = posixpath.join(self.config.assets_fetch_dir, path)
# Try to retrieve existing file
file = self.assets.get_file_from_path(full)
if not file:
# Compute path to external asset, which is sourced from the cache
# directory, and generate file to register it with MkDocs as soon
# as it was downloaded. This allows other plugins to apply
# additional processing.
file = self._path_to_file(path, config)
file.url = url.geturl()
# Spawn concurrent job to fetch external asset if the extension is
# known and the concurrent flag is set. In that case, this function
# is called in a context where no replacements are carried out, so
# the caller must only ensure to reconcile the concurrent jobs.
_, extension = posixpath.splitext(url.path)
if extension and concurrent:
self.pool_jobs.append(self.pool.submit(
self._fetch, file, config
))
# Fetch external asset synchronously, as it either has no extension
# or is fetched from a context in which replacements are done
else:
self._fetch(file, config)
# Register external asset as file
self.assets.append(file)
# If the URL of the external asset includes a hash fragment, add it to
# the returned file, e.g. for dark/light images - see https://t.ly/7b16Y
if url.fragment:
file.url += f"#{url.fragment}"
# Return file associated with external asset
return file
# Fetch external asset referenced through the given file
def _fetch(self, file: File, config: MkDocsConfig):
# Check if external asset needs to be downloaded
if not os.path.isfile(file.abs_src_path) or not self.config.cache:
path = file.abs_src_path
# Download external asset
log.info(f"Downloading external file: {file.url}")
res = requests.get(file.url, headers = {
# Set user agent explicitly, so Google Fonts gives us *.woff2
# files, which according to caniuse.com is the only format we
# need to download as it covers the entire range of browsers
# we're officially supporting.
"User-Agent": " ".join([
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"AppleWebKit/537.36 (KHTML, like Gecko)",
"Chrome/98.0.4758.102 Safari/537.36"
])
})
# Compute expected file extension and append if missing
mime = res.headers["content-type"].split(";")[0]
extension = extensions.get(mime)
if extension and not path.endswith(extension):
path += extension
# Save to file and create symlink if no extension was present
self._save_to_file(path, res.content)
if path != file.abs_src_path:
# Creating symlinks might fail on Windows. Thus, we just print
# a warning and continue - see https://bit.ly/3xYFzcZ
try:
os.symlink(os.path.basename(path), file.abs_src_path)
except OSError as e:
if e.errno != errno.EEXIST:
log.warning(
f"Couldn't create symbolic link: {file.src_uri}"
)
# Fall back for when the symlink could not be created. This
# means that the plugin will download the original file on
# every build, as the content type cannot be resolved from
# the file extension.
file.abs_src_path = path
# Resolve destination if file points to a symlink
_, extension = os.path.splitext(file.abs_src_path)
if os.path.isfile(file.abs_src_path):
file.abs_src_path = os.path.realpath(file.abs_src_path)
_, extension = os.path.splitext(file.abs_src_path)
# If the symlink could not be created, we already set the correct
# extension, so we need to make sure not to append it again
if not file.abs_dest_path.endswith(extension):
file.src_uri += extension
# Compute destination file system path
file.dest_uri += extension
file.abs_dest_path += extension
# Compute destination URL
file.url = file.dest_uri
# Parse and enqueue dependent external assets
for url in self._parse_media(file):
if not self._is_excluded(url, file):
self._queue(url, config, concurrent = True)
# Patch all links to external assets in the given file
def _patch(self, initiator: File):
with open(initiator.abs_src_path, encoding = "utf-8") as f:
# Replace callback
def replace(match: Match):
value = match.group(1)
# Map URL to canonical path
path = self._path_from_url(urlparse(value))
full = posixpath.join(self.config.assets_fetch_dir, path)
# Try to retrieve existing file
file = self.assets.get_file_from_path(full)
if not file:
name = os.readlink(os.path.join(self.config.cache_dir, full))
full = posixpath.join(posixpath.dirname(full), name)
# Try again after resolving symlink
file = self.assets.get_file_from_path(full)
# This can theoretically never happen, as we're sure that we
# only replace files that we successfully extracted. However,
# we might have missed several cases, so it's better to throw
# here than to swallow the error.
if not file:
log.error(
"File not found. This is likely a bug in the built-in "
"privacy plugin. Please create an issue with a minimal "
"reproduction."
)
sys.exit(1)
# Create absolute URL for asset in script
if file.url.endswith(".js"):
url = posixpath.join(self.site.geturl(), file.url)
# Create relative URL for everything else
else:
url = file.url_relative_to(initiator)
# Switch external asset URL to local path
return match.group().replace(value, url)
# Resolve replacement expression according to asset type
_, extension = posixpath.splitext(initiator.dest_uri)
expr = re.compile(self.assets_expr_map[extension], re.I | re.M)
# Resolve links to external assets in file
self._save_to_file(
initiator.abs_dest_path,
expr.sub(replace, f.read())
)
# -------------------------------------------------------------------------
# Normalize (= canonicalize) path by removing trailing slashes, and ensure
# that hidden folders (`.` after `/`) are unhidden. Otherwise MkDocs will
# not consider them being part of the build and refuse to copy them.
def _path_from_url(self, url: URL):
path = posixpath.normpath(url.path)
path = re.sub(r"/\.", "/_", path)
# Compute digest of query string, as some URLs yield different results
# for different query strings, e.g. https://unsplash.com/random?Coffee
if url.query:
name, extension = posixpath.splitext(path)
# Inject digest after file name and before file extension, as
# done for style sheet and script files as well
digest = sha1(url.query.encode("utf-8")).hexdigest()[:8]
path = f"{name}.{digest}{extension}"
# Create and return URL without leading double slashes
url = url._replace(scheme = "", query = "", fragment = "", path = path)
return url.geturl()[2:]
# Create a file for the given path
def _path_to_file(self, path: str, config: MkDocsConfig):
return File(
posixpath.join(self.config.assets_fetch_dir, 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"
}

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,60 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 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)))
# 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")

View File

@@ -0,0 +1,581 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 regex as re
from html import escape
from html.parser import HTMLParser
from mkdocs import utils
from mkdocs.plugins import BasePlugin
from .config import SearchConfig
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")
)))
# 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(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"]
}
# 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 = re.compile(r"(\p{IsHan}+)", re.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
])

View File

@@ -0,0 +1,21 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #

View File

@@ -0,0 +1,50 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 Deprecated, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Social plugin configuration
class SocialConfig(Config):
enabled = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/social")
# Settings for social cards
cards = Type(bool, default = True)
cards_dir = Type(str, default = "assets/images/social")
cards_layout_options = Type(dict, default = {})
# Deprecated settings
cards_color = Deprecated(
option_type = Type(dict, default = {}),
message =
"Deprecated, use 'cards_layout_options.background_color' "
"and 'cards_layout_options.color' with 'default' layout"
)
cards_font = Deprecated(
option_type = Type(str),
message = "Deprecated, use 'cards_layout_options.font_family'"
)

View File

@@ -0,0 +1,518 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #
# -----------------------------------------------------------------------------
# Disclaimer
# -----------------------------------------------------------------------------
# Please note: this version of the social plugin is not actively development
# anymore. Instead, Material for MkDocs Insiders ships a complete rewrite of
# the plugin which is much more powerful and addresses all shortcomings of
# this implementation. Additionally, the new social plugin allows to create
# entirely custom social cards. You can probably imagine, that this was a lot
# of work to pull off. If you run into problems, or want to have additional
# functionality, please consider sponsoring the project. You can then use the
# new version of the plugin immediately.
# -----------------------------------------------------------------------------
import concurrent.futures
import functools
import logging
import os
import posixpath
import re
import requests
import sys
from collections import defaultdict
from hashlib import md5
from io import BytesIO
from mkdocs.commands.build import DuplicateFilter
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin
from shutil import copyfile
from tempfile import TemporaryFile
from zipfile import ZipFile
try:
from cairosvg import svg2png
from PIL import Image, ImageDraw, ImageFont
except ImportError:
pass
from .config import SocialConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Social plugin
class SocialPlugin(BasePlugin[SocialConfig]):
def __init__(self):
self._executor = concurrent.futures.ThreadPoolExecutor(4)
# Retrieve configuration
def on_config(self, config):
self.color = colors.get("indigo")
self.config.cards = self.config.enabled
if not self.config.cards:
return
# Check dependencies
if "Image" not in globals():
raise PluginError(
"Required dependencies of \"social\" plugin not found. "
"Install with: pip install \"mkdocs-material[imaging]\""
)
# Move color options
if self.config.cards_color:
# Move background color to new option
value = self.config.cards_color.get("fill")
if value:
self.config.cards_layout_options["background_color"] = value
# Move color to new option
value = self.config.cards_color.get("text")
if value:
self.config.cards_layout_options["color"] = value
# Move font family to new option
if self.config.cards_font:
value = self.config.cards_font
self.config.cards_layout_options["font_family"] = value
# Check if site URL is defined
if not config.site_url:
log.warning(
"The \"site_url\" option is not set. The cards are generated, "
"but not linked, so they won't be visible on social media."
)
# Ensure presence of cache directory
self.cache = self.config.cache_dir
if not os.path.isdir(self.cache):
os.makedirs(self.cache)
# Retrieve palette from theme configuration
theme = config.theme
if "palette" in theme:
palette = theme["palette"]
# Use first palette, if multiple are defined
if isinstance(palette, list):
palette = palette[0]
# Set colors according to palette
if "primary" in palette and palette["primary"]:
primary = palette["primary"].replace(" ", "-")
self.color = colors.get(primary, self.color)
# Retrieve color overrides
options = self.config.cards_layout_options
self.color = {
"fill": options.get("background_color", self.color["fill"]),
"text": options.get("color", self.color["text"])
}
# Retrieve logo and font
self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config)
self.font = self._load_font(config)
self._image_promises = []
# Create social cards
def on_page_markdown(self, markdown, page, config, files):
if not self.config.cards:
return
# Resolve image directory
directory = self.config.cards_dir
file, _ = os.path.splitext(page.file.src_path)
# Resolve path of image
path = "{}.png".format(os.path.join(
config.site_dir,
directory,
file
))
# Resolve path of image directory
directory = os.path.dirname(path)
if not os.path.isdir(directory):
os.makedirs(directory)
# Compute site name
site_name = config.site_name
# Compute page title and description
title = page.meta.get("title", page.title)
description = config.site_description or ""
if "description" in page.meta:
description = page.meta["description"]
# Check type of meta title - see https://t.ly/m1Us
if not isinstance(title, str):
log.error(
f"Page meta title of page '{page.file.src_uri}' must be a "
f"string, but is of type \"{type(title)}\"."
)
sys.exit(1)
# Check type of meta description - see https://t.ly/m1Us
if not isinstance(description, str):
log.error(
f"Page meta description of '{page.file.src_uri}' must be a "
f"string, but is of type \"{type(description)}\"."
)
sys.exit(1)
# Generate social card if not in cache
hash = md5("".join([
site_name,
str(title),
description
]).encode("utf-8"))
file = os.path.join(self.cache, f"{hash.hexdigest()}.png")
self._image_promises.append(self._executor.submit(
self._cache_image,
cache_path = file, dest_path = path,
render_function = lambda: self._render_card(site_name, title, description)
))
# Inject meta tags into page
meta = page.meta.get("meta", [])
page.meta["meta"] = meta + self._generate_meta(page, config)
def on_post_build(self, config):
if not self.config.cards:
return
# Check for exceptions
for promise in self._image_promises:
promise.result()
# -------------------------------------------------------------------------
# Render image to cache (if not present), then copy from cache to site
def _cache_image(self, cache_path, dest_path, render_function):
if not os.path.isfile(cache_path):
image = render_function()
image.save(cache_path)
# Copy file from cache
copyfile(cache_path, dest_path)
@functools.lru_cache(maxsize=None)
def _get_font(self, kind, size):
return ImageFont.truetype(self.font[kind], size)
# Render social card
def _render_card(self, site_name, title, description):
# Render background and logo
image = self._render_card_background((1200, 630), self.color["fill"])
image.alpha_composite(
self._resized_logo_promise.result(),
(1200 - 228, 64 - 4)
)
# Render site name
font = self._get_font("Bold", 36)
image.alpha_composite(
self._render_text((826, 48), font, site_name, 1, 20),
(64 + 4, 64)
)
# Render page title
font = self._get_font("Bold", 92)
image.alpha_composite(
self._render_text((826, 328), font, title, 3, 30),
(64, 160)
)
# Render page description
font = self._get_font("Regular", 28)
image.alpha_composite(
self._render_text((826, 80), font, description, 2, 14),
(64 + 4, 512)
)
# Return social card image
return image
# Render social card background
def _render_card_background(self, size, fill):
return Image.new(mode = "RGBA", size = size, color = fill)
@functools.lru_cache(maxsize=None)
def _tmp_context(self):
image = Image.new(mode = "RGBA", size = (50, 50))
return ImageDraw.Draw(image)
@functools.lru_cache(maxsize=None)
def _text_bounding_box(self, text, font):
return self._tmp_context().textbbox((0, 0), text, font = font)
# Render social card text
def _render_text(self, size, font, text, lmax, spacing = 0):
width = size[0]
lines, words = [], []
# Remove remnant HTML tags
text = re.sub(r"(<[^>]+>)", "", text)
# Retrieve y-offset of textbox to correct for spacing
yoffset = 0
# Create drawing context and split text into lines
for word in text.split(" "):
combine = " ".join(words + [word])
textbox = self._text_bounding_box(combine, font = font)
yoffset = textbox[1]
if not words or textbox[2] <= width:
words.append(word)
else:
lines.append(words)
words = [word]
# Join words for each line and create image
lines.append(words)
lines = [" ".join(line) for line in lines]
image = Image.new(mode = "RGBA", size = size)
# Create drawing context and split text into lines
context = ImageDraw.Draw(image)
context.text(
(0, spacing / 2 - yoffset), "\n".join(lines[:lmax]),
font = font, fill = self.color["text"], spacing = spacing - yoffset
)
# Return text image
return image
# -------------------------------------------------------------------------
# Generate meta tags
def _generate_meta(self, page, config):
directory = self.config.cards_dir
file, _ = os.path.splitext(page.file.src_uri)
# Compute page title
title = page.meta.get("title", page.title)
if not page.is_homepage:
title = f"{title} - {config.site_name}"
# Compute page description
description = config.site_description
if "description" in page.meta:
description = page.meta["description"]
# Resolve image URL
url = "{}.png".format(posixpath.join(
config.site_url or ".",
directory,
file
))
# Ensure forward slashes
url = url.replace(os.path.sep, "/")
# Return meta tags
return [
# Meta tags for Open Graph
{ "property": "og:type", "content": "website" },
{ "property": "og:title", "content": title },
{ "property": "og:description", "content": description },
{ "property": "og:image", "content": url },
{ "property": "og:image:type", "content": "image/png" },
{ "property": "og:image:width", "content": "1200" },
{ "property": "og:image:height", "content": "630" },
{ "property": "og:url", "content": page.canonical_url },
# Meta tags for Twitter
{ "name": "twitter:card", "content": "summary_large_image" },
# { "name": "twitter:site", "content": user },
# { "name": "twitter:creator", "content": user },
{ "name": "twitter:title", "content": title },
{ "name": "twitter:description", "content": description },
{ "name": "twitter:image", "content": url }
]
def _load_resized_logo(self, config, width = 144):
logo = self._load_logo(config)
height = int(width * logo.height / logo.width)
return logo.resize((width, height))
# Retrieve logo image or icon
def _load_logo(self, config):
theme = config.theme
# Handle images (precedence over icons)
if "logo" in theme:
_, extension = os.path.splitext(theme["logo"])
path = os.path.join(config.docs_dir, theme["logo"])
# Allow users to put the logo inside their custom_dir (theme["logo"] case)
if theme.custom_dir:
custom_dir_logo = os.path.join(theme.custom_dir, theme["logo"])
if os.path.exists(custom_dir_logo):
path = custom_dir_logo
# Load SVG and convert to PNG
if extension == ".svg":
return self._load_logo_svg(path)
# Load PNG, JPEG, etc.
return Image.open(path).convert("RGBA")
# Handle icons
icon = theme["icon"] or {}
if "logo" in icon and icon["logo"]:
logo = icon["logo"]
else:
logo = "material/library"
# Resolve path of package
base = os.path.abspath(os.path.join(
os.path.dirname(__file__),
"../.."
))
path = f"{base}/templates/.icons/{logo}.svg"
# Allow users to put the logo inside their custom_dir (theme["icon"]["logo"] case)
if theme.custom_dir:
custom_dir_logo = os.path.join(theme.custom_dir, ".icons", f"{logo}.svg")
if os.path.exists(custom_dir_logo):
path = custom_dir_logo
# Load icon data and fill with color
return self._load_logo_svg(path, self.color["text"])
# Load SVG file and convert to PNG
def _load_logo_svg(self, path, fill = None):
file = BytesIO()
data = open(path).read()
# Fill with color, if given
if fill:
data = data.replace("<svg", f"<svg fill=\"{fill}\"")
# Convert to PNG and return image
svg2png(bytestring = data, write_to = file, scale = 10)
return Image.open(file)
# Retrieve font
def _load_font(self, config):
name = self.config.cards_layout_options.get("font_family")
if not name:
# Retrieve from theme (default: Roboto)
theme = config.theme
if isinstance(theme["font"], dict) and "text" in theme["font"]:
name = theme["font"]["text"]
else:
name = "Roboto"
# Google fonts can return varients like OpenSans_Condensed-Regular.ttf so
# we only use the font requested e.g. OpenSans-Regular.ttf
font_filename_base = name.replace(' ', '')
filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$"
font = {}
# Check for cached files - note these may be in subfolders
for currentpath, folders, files in os.walk(self.cache):
for file in files:
# Map available font weights to file paths
fname = os.path.join(currentpath, file)
match = re.search(filename_regex, fname)
if match:
font[match.group(1)] = fname
# If none found, fetch from Google and try again
if len(font) == 0:
self._load_font_from_google(name)
for currentpath, folders, files in os.walk(self.cache):
for file in files:
# Map available font weights to file paths
fname = os.path.join(currentpath, file)
match = re.search(filename_regex, fname)
if match:
font[match.group(1)] = fname
# Return available font weights with fallback
return defaultdict(lambda: font["Regular"], font)
# Retrieve font from Google Fonts
def _load_font_from_google(self, name):
url = "https://fonts.google.com/download?family={}"
res = requests.get(url.format(name.replace(" ", "+")), stream = True)
# Write archive to temporary file
tmp = TemporaryFile()
for chunk in res.iter_content(chunk_size = 32768):
tmp.write(chunk)
# Unzip fonts from temporary file
zip = ZipFile(tmp)
files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")]
zip.extractall(self.cache, files)
# Close and delete temporary file
tmp.close()
return files
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")
log.addFilter(DuplicateFilter())
# Color palette
colors = {
"red": { "fill": "#ef5552", "text": "#ffffff" },
"pink": { "fill": "#e92063", "text": "#ffffff" },
"purple": { "fill": "#ab47bd", "text": "#ffffff" },
"deep-purple": { "fill": "#7e56c2", "text": "#ffffff" },
"indigo": { "fill": "#4051b5", "text": "#ffffff" },
"blue": { "fill": "#2094f3", "text": "#ffffff" },
"light-blue": { "fill": "#02a6f2", "text": "#ffffff" },
"cyan": { "fill": "#00bdd6", "text": "#ffffff" },
"teal": { "fill": "#009485", "text": "#ffffff" },
"green": { "fill": "#4cae4f", "text": "#ffffff" },
"light-green": { "fill": "#8bc34b", "text": "#ffffff" },
"lime": { "fill": "#cbdc38", "text": "#000000" },
"yellow": { "fill": "#ffec3d", "text": "#000000" },
"amber": { "fill": "#ffc105", "text": "#000000" },
"orange": { "fill": "#ffa724", "text": "#000000" },
"deep-orange": { "fill": "#ff6e42", "text": "#ffffff" },
"brown": { "fill": "#795649", "text": "#ffffff" },
"grey": { "fill": "#757575", "text": "#ffffff" },
"blue-grey": { "fill": "#546d78", "text": "#ffffff" },
"black": { "fill": "#000000", "text": "#ffffff" },
"white": { "fill": "#ffffff", "text": "#000000" }
}

View File

@@ -0,0 +1,29 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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.
# #
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Casefold a string for comparison when sorting
def casefold(tag: str):
return tag.casefold()

View File

@@ -0,0 +1,37 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 Optional, Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Tags plugin configuration
class TagsConfig(Config):
enabled = Type(bool, default = True)
# Settings for tags
tags = Type(bool, default = True)
tags_file = Optional(Type(str))

View File

@@ -0,0 +1,186 @@
# #
# Copyright (c) 2025 Aetherinox
#
# 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 sys
from collections import defaultdict
from markdown.extensions.toc import slugify
from mkdocs import utils
from mkdocs.plugins import BasePlugin
# deprecated, but kept for downward compatibility. Use 'material.plugins.tags'
# as an import source instead. This import is removed in the next major version.
from . import casefold
from .config import TagsConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Tags plugin
class TagsPlugin(BasePlugin[TagsConfig]):
supports_multiple_instances = True
# Initialize plugin
def on_config(self, config):
if not self.config.enabled:
return
# Skip if tags should not be built
if not self.config.tags:
return
# Initialize tags
self.tags = defaultdict(list)
self.tags_file = None
# Retrieve tags mapping from configuration
self.tags_map = config.extra.get("tags")
# Use override of slugify function
toc = { "slugify": slugify, "separator": "-" }
if "toc" in config.mdx_configs:
toc = { **toc, **config.mdx_configs["toc"] }
# Partially apply slugify function
self.slugify = lambda value: (
toc["slugify"](str(value), toc["separator"])
)
# Hack: 2nd pass for tags index page(s)
def on_nav(self, nav, config, files):
if not self.config.enabled:
return
# Skip if tags should not be built
if not self.config.tags:
return
# Resolve tags index page
file = self.config.tags_file
if file:
self.tags_file = self._get_tags_file(files, file)
# Build and render tags index page
def on_page_markdown(self, markdown, page, config, files):
if not self.config.enabled:
return
# Skip if tags should not be built
if not self.config.tags:
return
# Skip, if page is excluded
if page.file.inclusion.is_excluded():
return
# Render tags index page
if page.file == self.tags_file:
return self._render_tag_index(markdown)
# Add page to tags index
for tag in page.meta.get("tags", []):
self.tags[tag].append(page)
# Inject tags into page (after search and before minification)
def on_page_context(self, context, page, config, nav):
if not self.config.enabled:
return
# Skip if tags should not be built
if not self.config.tags:
return
# Provide tags for page
if "tags" in page.meta:
context["tags"] = [
self._render_tag(tag)
for tag in page.meta["tags"]
]
# -------------------------------------------------------------------------
# Obtain tags file
def _get_tags_file(self, files, path):
file = files.get_file_from_path(path)
if not file:
log.error(f"Tags file '{path}' does not exist.")
sys.exit(1)
# Add tags file to files
files.append(file)
return file
# Render tags index
def _render_tag_index(self, markdown):
if "[TAGS]" in markdown:
markdown = markdown.replace("[TAGS]", "<!-- material/tags -->")
if not "<!-- material/tags -->" in markdown:
markdown += "\n<!-- material/tags -->"
# Replace placeholder in Markdown with rendered tags index
return markdown.replace("<!-- material/tags -->", "\n".join([
self._render_tag_links(*args)
for args in sorted(self.tags.items())
]))
# Render the given tag and links to all pages with occurrences
def _render_tag_links(self, tag, pages):
classes = ["md-tag"]
if isinstance(self.tags_map, dict):
classes.append("md-tag-icon")
type = self.tags_map.get(tag)
if type:
classes.append(f"md-tag--{type}")
# Render section for tag and a link to each page
classes = " ".join(classes)
content = [f"## <span class=\"{classes}\">{tag}</span>", ""]
for page in pages:
url = utils.get_relative_url(
page.file.src_uri,
self.tags_file.src_uri
)
# Render link to page
title = page.meta.get("title", page.title)
content.append(f"- [{title}]({url})")
# Return rendered tag links
return "\n".join(content)
# Render the given tag, linking to the tags index (if enabled)
def _render_tag(self, tag):
type = self.tags_map.get(tag) if self.tags_map else None
if not self.tags_file or not self.slugify:
return dict(name = tag, type = type)
else:
url = f"{self.tags_file.url}#{self.slugify(tag)}"
return dict(name = tag, type = type, url = url)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.tags")