Prepare 9.7.0 release

This commit is contained in:
squidfunk
2025-11-06 20:21:28 +01:00
committed by Martin Donath
parent 764178b012
commit b583ea7765
149 changed files with 15145 additions and 2100 deletions

View File

@@ -1,3 +1,43 @@
mkdocs-material-9.7.0 (2025-11-11)
⚠️ Material for MkDocs is now in maintenance mode
This is the last release of Material for MkDocs that will receive new features.
Going forward, the Material for MkDocs team focuses on Zensical, a next-gen
static site generator built from first principles. We will provide critical
bug fixes and security updates for Material for MkDocs for 12 months at least.
Read the full announcement on our blog:
https://squidfunk.github.io/mkdocs-material/blog/2025/11/05/zensical/
This release includes all features that were previously exclusively available
as part of the Insiders edition. They are now free for everybody to use. The
projects and typeset plugins turned out to be dead ends, which is why they
are deprecated and not part of Material for MkDocs. The sources of those plugins
are distributed with the project, and need to be re-enabled in pyproject.toml,
so third party contributors can take on maintenance, if desired.
Changes:
- Added support for pinned blog posts and author profiles
- Added support for customizing pagination for blog index pages
- Added support for customizing blog category sort order
- Added support for staying on page when switching languages
- Added support for disabling tags in table of contents
- Added support for nested tags and shadow tags
- Added support for footnote tooltips
- Added support for instant previews
- Added support for instant prefetching
- Added support for custom social card layouts
- Added support for custom social card background images
- Added support for selectable rangs in code blocks
- Added support for custom selectors for code annotations
- Added support for configurable log level in privacy plugin
- Added support for processing of external links in privacy plugin
- Added support for automatic image optimization via optimize plugin
- Added support for navigation paths (breadcrumbs)
- Fixed #8519: Vector accents do not render when using KaTeX
mkdocs-material-9.6.23 (2025-11-01)
* Updated Burmese translation

View File

@@ -2,6 +2,50 @@
## Material for MkDocs
### 9.7.0 <small>November 11, 2025</small> { id="9.7.0" }
⚠️ __Material for MkDocs is now in maintenance mode__
This is the last release of Material for MkDocs that will receive new features.
Going forward, the Material for MkDocs team focuses on [Zensical], a next-gen
static site generator built from first principles. We will provide critical
bug fixes and security updates for Material for MkDocs for 12 months at least.
[Read the full announcement on our blog]
This release includes all features that were previously exclusively available
as part of the Insiders edition. They are now free for everybody to use. The
[projects] and [typeset] plugins turned out to be dead ends, which is why they
are deprecated and not part of Material for MkDocs. The sources of those plugins
are distributed with the project, and need to be re-enabled in `pyproject.toml`,
so third party contributors can take on maintenance, if desired.
__Changes__:
- Added support for pinned blog posts and author profiles
- Added support for customizing pagination for blog index pages
- Added support for customizing blog category sort order
- Added support for staying on page when switching languages
- Added support for disabling tags in table of contents
- Added support for nested tags and shadow tags
- Added support for footnote tooltips
- Added support for instant previews
- Added support for instant prefetching
- Added support for custom social card layouts
- Added support for custom social card background images
- Added support for selectable rangs in code blocks
- Added support for custom selectors for code annotations
- Added support for configurable log level in privacy plugin
- Added support for processing of external links in privacy plugin
- Added support for automatic image optimization via optimize plugin
- Added support for navigation paths (breadcrumbs)
- Fixed #8519: Vector accents do not render when using KaTeX
[Zensical]: https://zensical.org
[Read the full announcement on our blog]: https://squidfunk.github.io/mkdocs-material/blog/2025/11/05/zensical/
[projects]: https://squidfunk.github.io/mkdocs-material/plugins/projects/
[typeset]: https://squidfunk.github.io/mkdocs-material/plugins/typeset/
### 9.6.23 <small>November 1, 2025</small> { id="9.6.23" }
- Updated Burmese translation

View File

@@ -18,4 +18,4 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
__version__ = "9.6.23"
__version__ = "9.7.0"

View File

@@ -0,0 +1,223 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
from material.utilities.filter import FileFilter, FilterConfig
from mkdocs.structure.pages import _RelativePathTreeprocessor
from markdown import Extension, Markdown
from markdown.treeprocessors import Treeprocessor
from mkdocs.exceptions import ConfigurationError
from urllib.parse import urlparse
from xml.etree.ElementTree import Element
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class PreviewProcessor(Treeprocessor):
"""
A Markdown treeprocessor to enable instant previews on links.
Note that this treeprocessor is dependent on the `relpath` treeprocessor
registered programmatically by MkDocs before rendering a page.
"""
def __init__(self, md: Markdown, config: dict):
"""
Initialize the treeprocessor.
Arguments:
md: The Markdown instance.
config: The configuration.
"""
super().__init__(md)
self.config = config
def run(self, root: Element):
"""
Run the treeprocessor.
Arguments:
root: The root element of the parsed Markdown document.
"""
at = self.md.treeprocessors.get_index_for_name("relpath")
# Hack: Python Markdown has no notion of where it is, i.e., which file
# is being processed. This seems to be a deliberate design decision, as
# it is not possible to access the file path of the current page, but
# it might also be an oversight that is now impossible to fix. However,
# since this extension is only useful in the context of Material for
# MkDocs, we can assume that the _RelativePathTreeprocessor is always
# present, telling us the file path of the current page. If that ever
# changes, we would need to wrap this extension in a plugin, but for
# the time being we are sneaky and will probably get away with it.
processor = self.md.treeprocessors[at]
if not isinstance(processor, _RelativePathTreeprocessor):
raise TypeError("Relative path processor not registered")
# Normalize configurations
configurations = self.config["configurations"]
configurations.append({
"sources": self.config.get("sources"),
"targets": self.config.get("targets")
})
# Walk through all configurations - @todo refactor so that we don't
# iterate multiple times over the same elements
for configuration in configurations:
# Skip, if the configuration defines nothing we could also fix
# this in the file filter, but we first fix it here and check if
# it generalizes well enough to other inclusion/exclusion sites,
# because here, it would hinder the ability to automaticaly
# include all sources, while excluding specific targets.
if (
not configuration.get("sources") and
not configuration.get("targets")
):
continue
# Skip if page should not be considered
filter = get_filter(configuration, "sources")
if not filter(processor.file):
continue
# Walk through all links and add preview attributes
filter = get_filter(configuration, "targets")
for el in root.iter("a"):
href = el.get("href")
if not href:
continue
# Skip footnotes
if "footnote-ref" in el.get("class", ""):
continue
# Skip external links
url = urlparse(href)
if url.scheme or url.netloc:
continue
# Add preview attribute to internal links
for path in processor._possible_target_uris(
processor.file, url.path,
processor.config.use_directory_urls
):
target = processor.files.get_file_from_path(path)
if not target:
continue
# Include, if filter matches
if filter(target):
el.set("data-preview", "")
# -----------------------------------------------------------------------------
class PreviewExtension(Extension):
"""
A Markdown extension to enable instant previews on links.
This extensions allows to automatically add the `data-preview` attribute to
internal links matching specific criteria, so Material for MkDocs renders a
nice preview on hover as part of a tooltip. It is the recommended way to
add previews to links in a programmatic way.
"""
def __init__(self, *args, **kwargs):
"""
"""
self.config = {
"configurations": [[], "Filter configurations"],
"sources": [{}, "Link sources"],
"targets": [{}, "Link targets"]
}
super().__init__(*args, **kwargs)
def extendMarkdown(self, md: Markdown):
"""
Register Markdown extension.
Arguments:
md: The Markdown instance.
"""
md.registerExtension(self)
# Create and register treeprocessor - we use the same priority as the
# `relpath` treeprocessor, the latter of which is guaranteed to run
# after our treeprocessor, so we can check the original Markdown URIs
# before they are resolved to URLs.
processor = PreviewProcessor(md, self.getConfigs())
md.treeprocessors.register(processor, "preview", 0)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def get_filter(settings: dict, key: str):
"""
Get file filter from settings.
Arguments:
settings: The settings.
key: The key in the settings.
Returns:
The file filter.
"""
config = FilterConfig()
config.load_dict(settings.get(key) or {})
# Validate filter configuration
errors, warnings = config.validate()
for _, w in warnings:
log.warning(
f"Error reading filter configuration in '{key}':\n"
f"{w}"
)
for _, e in errors:
raise ConfigurationError(
f"Error reading filter configuration in '{key}':\n"
f"{e}"
)
# Return file filter
return FileFilter(config = config) # type: ignore
def makeExtension(**kwargs):
"""
Register Markdown extension.
Arguments:
**kwargs: Configuration options.
Returns:
The Markdown extension.
"""
return PreviewExtension(**kwargs)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.extensions.preview")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"version":3,"sources":["src/overrides/assets/stylesheets/custom/_typeset.scss","../../../../src/overrides/assets/stylesheets/custom.scss","src/templates/assets/stylesheets/utilities/_break.scss","src/overrides/assets/stylesheets/custom/layout/_banner.scss","src/overrides/assets/stylesheets/custom/layout/_hero.scss","src/overrides/assets/stylesheets/custom/layout/_iconsearch.scss","src/overrides/assets/stylesheets/custom/layout/_sponsorship.scss"],"names":[],"mappings":"AA2BA,iBACE,cAIE,kBC7BF,CDgCA,QAEE,qBC/BF,CACF,CD0CE,qBACE,aCxCJ,CD6CE,sBACE,aC3CJ,CD+CE,uBACE,UC7CJ,CDgDI,8BAGE,QAAA,CACA,sBAAA,CAHA,iBAAA,CACA,UC5CN,CDkDI,8BAOE,WAAA,CAFA,WAAA,CAFA,MAAA,CAGA,eAAA,CALA,iBAAA,CACA,KAAA,CAEA,UC7CN,CDqDE,uBACE,2BCnDJ,CDuDE,0BACE,aCrDJ,CD2DE,uBACE,eCzDJ,CD4DI,8BACE,4BAAA,CACA,4BAAA,CACA,2CAAA,CAEA,aC3DN,CD8DM,uCACE,2BC5DR,CDiEI,8BACE,WAAA,CACA,iBC/DN,CDmEI,uCAGE,4BChEN,CD6DI,uCAGE,6BChEN,CD6DI,uCAIE,+BCjEN,CD6DI,uCAIE,gCCjEN,CD6DI,6BAEE,iDAAA,CADA,aC9DN,CDoEM,wCACE,mBClER,CDuEI,uCAEE,6BCnEN,CDiEI,uCAEE,4BCnEN,CDiEI,uCAGE,gCCpEN,CDiEI,uCAGE,+BCpEN,CDiEI,6BAIE,iEAAA,CAHA,mBClEN,CD4EE,+BACE,cAAA,CACA,uBC1EJ,CD6EI,0EACE,WC3EN,CD+EI,oCAGE,2CAAA,CADA,gCAAA,CADA,aC3EN,CDqFI,wDAEE,cCnFN,CCgII,0CF/CA,wDAMI,eClFN,CACF,CDsFI,4BACE,kBCpFN,CDyFE,uBACE,eCvFJ,CD0FI,0BACE,eCxFN,CD2FM,6BACE,iBCzFR,CD8FI,6BACE,YAAA,CACA,SC5FN,CDgGI,gCACE,YAAA,CACA,MAAA,CACA,qBC9FN,CDiGM,qCAEE,oBAAA,CADA,mBAAA,CAEA,6BC/FR,CDmGM,kDACE,aCjGR,CDqGM,qCACE,WCnGR,CDyGE,wBAEE,sBAAA,CADA,iBCtGJ,CD0GI,iDACE,0BCxGN,CD4GI,+BAEE,eAAA,CADA,iBAAA,CAGA,2BAAA,CADA,uCCzGN,CDgHQ,wDACE,SC9GV,CDkHQ,wDACE,0BChHV,CDoHQ,wDACE,SClHV,CDwHI,+BACE,yCACE,CAGF,oDAAA,CADA,mBCvHN,CD2HM,mCACE,aCzHR,CD8HI,+BAKE,kDAAA,CADA,gCAAA,CAFA,aAAA,CAIA,SAAA,CAHA,mBAAA,CAFA,iBAAA,CAMA,mBC5HN,CDiIM,8DACE,2BC/HR,CD8HM,8DACE,2BC5HR,CD2HM,8DACE,2BCzHR,CDwHM,8DACE,uBCtHR,CDqHM,8DACE,0BCnHR,CDkHM,6DACE,0BChHR,CD+GM,8DACE,0BC7GR,CE3JA,WACE,wCF8JF,CE3JE,kBAEE,kBF6JJ,CE1JE,+BAJE,+BFiKJ,CE1JI,sCAEE,kBF2JN,CEzJM,wDACE,0CAAA,CACA,eF2JR,CEtJE,oBAME,kBAAA,CACA,0CAAA,CANA,oBAAA,CAEA,aAAA,CACA,cAAA,CAIA,mBAAA,CAHA,qBAAA,CAHA,YF8JJ,CEtJI,wBACE,aAAA,CACA,eFwJN,CG3LA,eAEE,uYACE,CAFF,gBH+LF,CGpLE,4CACE,yYHsLJ,CG1KA,UAEE,gCAAA,CADA,cH8KF,CG1KE,aAGE,kBAAA,CADA,eAAA,CADA,kBH8KJ,CCpBI,0CE3JF,aAOI,gBH4KJ,CACF,CGxKE,mBACE,mBH0KJ,CC/CI,mCE7IJ,UAwBI,mBAAA,CADA,YH0KF,CGtKE,mBAGE,iBAAA,CAFA,eAAA,CACA,mBHyKJ,CGpKE,iBACE,OAAA,CAEA,0BAAA,CADA,WHuKJ,CACF,CC/DI,sCEhGA,iBACE,0BHkKJ,CACF,CG9JE,qBAGE,gCAAA,CADA,kBAAA,CADA,gBHkKJ,CG7JI,sDAEE,0CAAA,CACA,sCAAA,CAFA,+BHiKN,CG3JI,8BAEE,2CAAA,CACA,uCAAA,CAFA,aH+JN,CItPE,4BAEE,2CAAA,CACA,mBAAA,CACA,8BAAA,CAHA,iBAAA,CAIA,2BJyPJ,CItPI,2EACE,8BJwPN,CIpPI,sCACE,qCAAA,CACA,eJsPN,CInPM,mEACE,kCJqPR,CI/OE,mCAIE,kCAAA,CAAA,0BAAA,CAHA,eAAA,CACA,eAAA,CAIA,yDAAA,CACA,oBAAA,CAFA,kBJkPJ,CI7OI,+CACE,mBJ+ON,CI3OI,sDAEE,YAAA,CADA,WJ8ON,CIzOI,4DACE,oDJ2ON,CIxOM,kEACE,0CJ0OR,CIrOI,yCAKE,yCAAA,CADA,gBAAA,CAHA,iBAAA,CAEA,WAAA,CADA,SJ0ON,CC9GI,0CG9HA,yCASI,YJuON,CACF,CInOI,2CAOE,qDAAA,CACA,WAAA,CACA,mBAAA,CAHA,uCAAA,CADA,gBAAA,CADA,oBAAA,CAAA,iBAAA,CAHA,iBAAA,CAEA,WAAA,CADA,SAAA,CAQA,6CJqON,CIlOM,kGAGE,0CAAA,CADA,+BAAA,CAEA,YJmOR,CI/NM,wEACE,YJiOR,CI5NI,mDAKE,aJ6NN,CIlOI,mDAKE,cJ6NN,CIlOI,yCAME,eAAA,CAJA,QAAA,CADA,SJiON,CIxNI,mDAKE,aJyNN,CI9NI,mDAKE,cJyNN,CI9NI,yCAME,+DAAA,CAJA,QAAA,CADA,mBJ6NN,CIrNM,oDACE,kBJuNR,CInNM,2CACE,kBJqNR,CIjNM,6CAEE,YAAA,CADA,WJoNR,CIhNQ,0FACE,gBJkNV,CKnVI,2BACE,YAAA,CACA,iBLsVN,CKlVI,6BACE,cLoVN,CKhVI,sCACE,YAAA,CACA,cAAA,CACA,sBLkVN,CK/UM,wCACE,aAAA,CACA,aLiVR,CKxUI,mCACE,YL0UN,CKvUM,yCAEE,UAAA,CACA,UAAA,CAFA,aL2UR,CKpUI,6CAEE,UL6UN,CK/UI,6CAEE,WL6UN,CK/UI,mCAOE,kBAAA,CANA,aAAA,CAGA,aAAA,CACA,YAAA,CACA,eAAA,CAKA,kBAAA,CAHA,sCACE,CANF,YL4UN,CKjUM,kFACE,oBLmUR,CKhUQ,0FACE,mBLkUV,CK7TM,4CAME,+CAAA,CAFA,yCAAA,CAHA,eAAA,CACA,eAAA,CACA,kBAAA,CAEA,iBLgUR,CK3TM,uCACE,aAAA,CAGA,mCAAA,CADA,WAAA,CAEA,uBAAA,CAHA,ULgUR,CKvTE,oCACE,eLyTJ,CKrTE,sEAEE,eLuTJ","file":"custom.css"}

View File

@@ -3,7 +3,7 @@
-#}
{% extends "base.html" %}
{% block extrahead %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/custom.a2a27114.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/custom.css' | url }}">
{% endblock %}
{% block announce %}
For updates follow <strong>@squidfunk</strong> on
@@ -23,5 +23,5 @@
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ 'assets/javascripts/custom.95054b18.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/custom.js' | url }}"></script>
{% endblock %}

View File

@@ -17,3 +17,17 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from .structure import View
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Sort views by name
def view_name(view: View):
return view.name
# Sort views by post count
def view_post_count(view: View):
return len(view.posts)

View File

@@ -23,6 +23,8 @@ from mkdocs.config.config_options import Choice, Deprecated, Optional, Type
from mkdocs.config.base import Config
from pymdownx.slugs import slugify
from . import view_name
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@@ -56,6 +58,8 @@ class BlogConfig(Config):
archive_date_format = Type(str, default = "yyyy")
archive_url_date_format = Type(str, default = "yyyy")
archive_url_format = Type(str, default = "archive/{date}")
archive_pagination = Optional(Type(bool))
archive_pagination_per_page = Optional(Type(int))
archive_toc = Optional(Type(bool))
# Settings for categories
@@ -64,12 +68,22 @@ class BlogConfig(Config):
categories_url_format = Type(str, default = "category/{slug}")
categories_slugify = Type(Callable, default = slugify(case = "lower"))
categories_slugify_separator = Type(str, default = "-")
categories_sort_by = Type(Callable, default = view_name)
categories_sort_reverse = Type(bool, default = False)
categories_allowed = Type(list, default = [])
categories_pagination = Optional(Type(bool))
categories_pagination_per_page = Optional(Type(int))
categories_toc = Optional(Type(bool))
# Settings for authors
authors = Type(bool, default = True)
authors_file = Type(str, default = "{blog}/.authors.yml")
authors_profiles = Type(bool, default = False)
authors_profiles_name = Type(str, default = "blog.authors")
authors_profiles_url_format = Type(str, default = "author/{slug}")
authors_profiles_pagination = Optional(Type(bool))
authors_profiles_pagination_per_page = Optional(Type(int))
authors_profiles_toc = Optional(Type(bool))
# Settings for pagination
pagination = Type(bool, default = True)

View File

@@ -45,10 +45,15 @@ from tempfile import mkdtemp
from urllib.parse import urlparse
from yaml import SafeLoader
from .author import Authors
from . import view_name
from .author import Author, Authors
from .config import BlogConfig
from .readtime import readtime
from .structure import Archive, Category, Excerpt, Post, Reference, View
from .structure import (
Archive, Category, Profile,
Excerpt, Post, View,
Reference
)
# -----------------------------------------------------------------------------
# Classes
@@ -86,12 +91,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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:
@@ -134,27 +133,40 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.blog = self._resolve(files, config)
self.blog.posts = sorted(
self._resolve_posts(files, config),
key = lambda post: post.config.date.created,
key = lambda post: (
post.config.pin,
post.config.date.created
),
reverse = True
)
# Generate views for archive
if self.config.archive:
self.blog.views.extend(
self._generate_archive(config, files)
)
views = self._generate_archive(config, files)
self.blog.views.extend(views)
# Generate views for categories
if self.config.categories:
views = self._generate_categories(config, files)
# We always sort the list of categories by name first, so that any
# custom sorting function that returns the same value for two items
# returns them in a predictable and logical order, because sorting
# in Python is stable, i.e., order of equal items is preserved
self.blog.views.extend(sorted(
self._generate_categories(config, files),
key = lambda view: view.name,
reverse = False
sorted(views, key = view_name),
key = self.config.categories_sort_by,
reverse = self.config.categories_sort_reverse
))
# Generate views for profiles
if self.config.authors_profiles:
views = self._generate_profiles(config, files)
self.blog.views.extend(views)
# Generate pages for views
if self.config.pagination:
for view in self._resolve_views(self.blog):
for view in self._resolve_views(self.blog):
if self._config_pagination(view):
for page in self._generate_pages(view, config, files):
view.pages.append(page)
@@ -209,9 +221,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
if self.blog.file.inclusion.is_in_nav() and views:
self._attach_to(self.blog, Section(title, views), nav)
# Attach views for profiles
if self.config.authors_profiles:
title = self._translate(self.config.authors_profiles_name, config)
views = [_ for _ in self.blog.views if isinstance(_, Profile)]
# 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 view in self._resolve_views(self.blog):
if self._config_pagination(view):
for at in range(1, len(view.pages)):
self._attach_at(view.parent, view, view.pages[at])
@@ -227,7 +248,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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:
if not self._config_pagination(page):
return
# We set the contents of the view to its title if pagination should
@@ -250,12 +271,12 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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}'")
for id in page.config.authors:
if id not in self.authors:
raise PluginError(f"Couldn't find author '{id}'")
# Append to list of authors
page.authors.append(self.authors[name])
page.authors.append(self.authors[id])
# Extract settings for excerpts
separator = self.config.post_excerpt_separator
@@ -314,7 +335,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
url_filter = env.filters["url"]
# Patch URL template filter to add support for paginated views, i.e.,
# that paginated views never link to themselves but to the main view
# that paginated views never link to themselves but to the main vie
@pass_context
def url_filter_with_pagination(context: Context, url: str | None):
page = context["page"]
@@ -590,6 +611,37 @@ class BlogPlugin(BasePlugin[BlogConfig]):
file.page.posts.append(post)
post.categories.append(file.page)
# Generate views for profiles - analyze posts and generate the necessary
# views to provide a profile page for each author listing all posts
def _generate_profiles(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
for id in post.config.authors:
author = self.authors[id]
path = self._format_path_for_profile(id, author)
# Create file for view, if it does not exist
file = files.get_file_from_path(path)
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {author.name}")
# Temporarily remove view from navigation and assign profile
# URL to author, if not explicitly set
file.inclusion = InclusionLevel.EXCLUDED
if not author.url:
author.url = file.url
# Create and yield view
if not isinstance(file.page, Profile):
yield Profile(author.name, file, config)
# Assign post to profile
assert isinstance(file.page, Profile)
file.page.posts.append(post)
# 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):
@@ -597,7 +649,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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
step = self._config_pagination_per_page(view)
for at in range(step, len(view.posts), step):
path = self._format_path_for_pagination(view, 1 + at // step)
@@ -747,11 +799,11 @@ class BlogPlugin(BasePlugin[BlogConfig]):
posts, pagination = view.posts, None
# Create pagination, if enabled
if self.config.pagination:
if self._config_pagination(view):
at = view.pages.index(view)
# Compute pagination boundaries
step = self.config.pagination_per_page
step = self._config_pagination_per_page(view)
p, q = at * step, at * step + step
# Extract posts in pagination boundaries
@@ -771,18 +823,9 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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
toc = self._config_toc(view)
if toc and excerpt.toc.items and view.toc.items:
view.toc.items[0].children.append(excerpt.toc.items[0])
@@ -806,6 +849,48 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# -------------------------------------------------------------------------
# Retrieve configuration value or return default
def _config(self, key: str, default: any):
return default if self.config[key] is None else self.config[key]
# Retrieve configuration value for table of contents
def _config_toc(self, view: View):
default = self.config.blog_toc
if isinstance(view, Archive):
return self._config("archive_toc", default)
if isinstance(view, Category):
return self._config("categories_toc", default)
if isinstance(view, Profile):
return self._config("authors_profiles_toc", default)
else:
return default
# Retrieve configuration value for pagination
def _config_pagination(self, view: View):
default = self.config.pagination
if isinstance(view, Archive):
return self._config("archive_pagination", default)
if isinstance(view, Category):
return self._config("categories_pagination", default)
if isinstance(view, Profile):
return self._config("authors_profiles_pagination", default)
else:
return default
# Retrieve configuration value for pagination per page
def _config_pagination_per_page(self, view: View):
default = self.config.pagination_per_page
if isinstance(view, Archive):
return self._config("archive_pagination_per_page", default)
if isinstance(view, Category):
return self._config("categories_pagination_per_page", default)
if isinstance(view, Profile):
return self._config("authors_profiles_pagination_per_page", default)
else:
return default
# -------------------------------------------------------------------------
# Format path for post
def _format_path_for_post(self, post: Post, config: MkDocsConfig):
categories = post.config.categories[:self.config.post_url_max_categories]
@@ -845,6 +930,17 @@ class BlogPlugin(BasePlugin[BlogConfig]):
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for profile
def _format_path_for_profile(self, id: str, author: Author):
path = self.config.authors_profiles_url_format.format(
slug = author.slug or id,
name = author.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(

View File

@@ -273,6 +273,12 @@ class Category(View):
# -----------------------------------------------------------------------------
# Profile view
class Profile(View):
pass
# -----------------------------------------------------------------------------
# Reference
class Reference(Link):

View File

@@ -33,6 +33,7 @@ class PostConfig(Config):
categories = UniqueListOfItems(Type(str), default = [])
date = PostDate()
draft = Optional(Type(bool))
pin = Type(bool, default = False)
links = Optional(PostLinks())
readtime = Optional(Type(int))
slug = Optional(Type(str))

View File

@@ -36,7 +36,7 @@ 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 mkdocs.utils.yaml import get_yaml_loader
from zipfile import ZipFile, ZIP_DEFLATED
from .config import InfoConfig
@@ -180,7 +180,7 @@ class InfoPlugin(BasePlugin[InfoConfig]):
# Report the invalid paths to the user
if paths_to_validate:
log.error(f"One or more paths aren't children of root")
log.error("One or more paths aren't children of root")
self._help_on_not_in_cwd(paths_to_validate)
# Create in-memory archive and prompt author for a short descriptive

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,52 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import os
from mkdocs.config.base import Config
from mkdocs.config.config_options import ListOfItems, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Optimize plugin configuration
class OptimizeConfig(Config):
enabled = Type(bool, default = True)
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
# Settings for caching
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/optimize")
# Settings for optimization
optimize = Type(bool, default = True)
optimize_png = Type(bool, default = True)
optimize_png_speed = Type(int, default = 3)
optimize_png_strip = Type(bool, default = True)
optimize_jpg = Type(bool, default = True)
optimize_jpg_quality = Type(int, default = 60)
optimize_jpg_progressive = Type(bool, default = True)
optimize_include = ListOfItems(Type(str), default = [])
optimize_exclude = ListOfItems(Type(str), default = [])
# Settings for reporting
print_gain = Type(bool, default = True)
print_gain_summary = Type(bool, default = True)

View File

@@ -0,0 +1,388 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import functools
import json
import logging
import os
import subprocess
import sys
from fnmatch import fnmatch
from colorama import Fore, Style
from concurrent.futures import Future
from concurrent.futures.thread import ThreadPoolExecutor
from hashlib import sha1
from mkdocs import utils
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin
from mkdocs.structure.files import File
from shutil import which
try:
from PIL import Image
except ImportError:
pass
from .config import OptimizeConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Optimize plugin
class OptimizePlugin(BasePlugin[OptimizeConfig]):
supports_multiple_instances = True
# Manifest
manifest: dict[str, str] = {}
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
# Initialize thread pool
self.pool = ThreadPoolExecutor(self.config.concurrency)
self.pool_jobs: dict[str, Future] = {}
# Resolve and load manifest
def on_config(self, config):
if not self.config.enabled:
return
# Resolve cache directory (once) - this is necessary, so the cache is
# always relative to the configuration file, and thus project, and not
# relative to the current working directory, or it would not work with
# the projects plugin.
path = os.path.abspath(self.config.cache_dir)
if path != self.config.cache_dir:
self.config.cache_dir = os.path.join(
os.path.dirname(config.config_file_path),
os.path.normpath(self.config.cache_dir)
)
# Ensure cache directory exists
os.makedirs(self.config.cache_dir, exist_ok = True)
# Initialize manifest
self.manifest_file = os.path.join(
self.config.cache_dir, "manifest.json"
)
# Load manifest if it exists and the cache should be used
if os.path.isfile(self.manifest_file) and self.config.cache:
try:
with open(self.manifest_file) as f:
self.manifest = json.load(f)
except:
pass
# Initialize optimization pipeline
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Skip if media files should not be optimized
if not self.config.optimize:
return
# Filter all optimizable media files and steal reponsibility from MkDocs
# by removing them from the files collection. Then, start a concurrent
# job that checks if an image was already optimized and can be returned
# from the cache, or optimize it accordingly.
for file in files.media_files():
if self._is_excluded(file):
continue
# Spawn concurrent job to optimize the given image and add future
# to job dictionary, as it returns the file we need to copy later
path = os.path.join(self.config.cache_dir, file.src_path)
self.pool_jobs[file.abs_src_path] = self.pool.submit(
self._optimize_image, file, path, config
)
# Steal responsibility from MkDocs
files.remove(file)
# Finish optimization pipeline
def on_post_build(self, *, config):
if not self.config.enabled:
return
# Skip if media files should not be optimized
if not self.config.optimize:
return
# Reconcile concurrent jobs - we need to wait for all jobs to finish
# before we can copy the optimized files to the output directory. If an
# exception occurred in one of the jobs, we raise it here, so the build
# fails and the author can fix the issue.
for path, future in self.pool_jobs.items():
if future.exception():
raise future.exception()
else:
file: File = future.result()
file.copy_file()
# Save manifest if cache should be used
if self.config.cache:
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# Compute and print gains through optimization
if self.config.print_gain_summary:
print(Style.NORMAL)
print(f" Optimizations:")
# Print summary for file extension
for seek in [".png", ".jpg"]:
size = size_opt = 0
for path, future in self.pool_jobs.items():
file: File = future.result()
# Skip files that are not of the given type
_, extension = os.path.splitext(path)
extension = ".jpg" if extension == ".jpeg" else extension
if extension != seek:
continue
# Compute size before and after optimization
size += os.path.getsize(path)
size_opt += os.path.getsize(file.abs_dest_path)
# Compute absolute and relative gain
if size and size_opt:
gain_abs = size - size_opt
gain_rel = (1 - size_opt / size) * 100
# Print summary for files
print(
f" *{seek} {Fore.GREEN}{_size(size_opt)}"
f"{Fore.WHITE}{Style.DIM}"
f"{_size(gain_abs)} [{gain_rel:3.1f}%]"
f"{Style.RESET_ALL}"
)
# Reset all styles
print(Style.RESET_ALL)
# Save manifest on shutdown
def on_shutdown(self):
if not self.config.enabled:
return
# Shutdown thread pool - if we're on Python 3.9 and above, cancel all
# pending futures that have not yet been scheduled
if sys.version_info >= (3, 9):
self.pool.shutdown(cancel_futures = True)
else:
self.pool.shutdown()
# Save manifest if cache should be used
if self.manifest and self.config.cache:
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# -------------------------------------------------------------------------
# Check if a file can be optimized
def _is_optimizable(self, file: File):
# Check if PNG images should be optimized
if file.url.endswith((".png")):
return self.config.optimize_png
# Check if JPG images should be optimized
if file.url.endswith((".jpg", ".jpeg")):
return self.config.optimize_jpg
# File can not be optimized by the plugin
return False
# Check if the given file is excluded
def _is_excluded(self, file: File):
if not self._is_optimizable(file):
return True
# Check if file matches one of the inclusion patterns
path = file.src_path
if self.config.optimize_include:
for pattern in self.config.optimize_include:
if fnmatch(file.src_uri, pattern):
return False
# File is not included
log.debug(f"Excluding file '{path}' due to inclusion patterns")
return True
# Check if file matches one of the exclusion patterns
for pattern in self.config.optimize_exclude:
if fnmatch(file.src_uri, pattern):
log.debug(f"Excluding file '{path}' due to exclusion patterns")
return True
# File is not excluded
return False
# Optimize image and write to cache
def _optimize_image(self, file: File, path: str, config: MkDocsConfig):
with open(file.abs_src_path, "rb") as f:
data = f.read()
hash = sha1(data).hexdigest()
# Check if file hash changed, so we need to optimize again
prev = self.manifest.get(file.url, "")
if hash != prev or not os.path.isfile(path):
os.makedirs(os.path.dirname(path), exist_ok = True)
# Optimize PNG image using pngquant
if file.url.endswith((".png")):
self._optimize_image_png(file, path, config)
# Optimize JPG image using pillow
if file.url.endswith((".jpg", ".jpeg")):
self._optimize_image_jpg(file, path, config)
# Compute size before and after optimization
size = len(data)
size_opt = os.path.getsize(path)
# Compute absolute and relative gain
gain_abs = size - size_opt
gain_rel = (1 - size_opt / size) * 100
# Print how much we gained, if we did and desired
gain = ""
if gain_abs and self.config.print_gain:
gain += ""
gain += " ".join([_size(gain_abs), f"[{gain_rel:3.1f}%]"])
# Print summary for file
log.info(
f"Optimized media file: {file.src_uri} "
f"{Fore.GREEN}{_size(size_opt)}"
f"{Fore.WHITE}{Style.DIM}{gain}"
f"{Style.RESET_ALL}"
)
# Update manifest by associating file with hash
self.manifest[file.url] = hash
# Compute project root
root = os.path.dirname(config.config_file_path)
# Compute source file system path
file.abs_src_path = path
file.src_path = os.path.relpath(path, root)
# Return file to be copied from cache
return file
# Optimize PNG image - we first tried to use libimagequant, but encountered
# the occassional segmentation fault, which means it's probably not a good
# choice. Instead, we just rely on pngquant which seems much more stable.
def _optimize_image_png(self, file: File, path: str, config: MkDocsConfig):
# Check if the required dependencies for optimizing are available, which
# is, at the absolute minimum, the 'pngquant' binary, and raise an error
# to the caller, so he can decide what to do with the error. The caller
# can treat this as a warning or an error to abort the build.
if not which("pngquant"):
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(file.abs_src_path, docs)
raise PluginError(
f"Couldn't optimize image '{path}' in '{docs}': 'pngquant' "
f"not found. Make sure 'pngquant' is installed and in your path"
)
# Build command line arguments
args = ["pngquant",
"--force", "--skip-if-larger",
"--output", path,
"--speed", f"{self.config.optimize_png_speed}"
]
# Add flag to remove optional metadata
if self.config.optimize_png_strip:
args.append("--strip")
# Set input file and run, then check if pngquant actually wrote a file,
# as we instruct it not to if the size of the optimized file is larger.
# This can happen if files are already compressed and optimized by
# the author. In that case, just copy the original file.
subprocess.run([*args, file.abs_src_path])
if not os.path.isfile(path):
utils.copy_file(file.abs_src_path, path)
# Optimize JPG image
def _optimize_image_jpg(self, file: File, path: str, config: MkDocsConfig):
# Check if the required dependencies for optimizing are available, which
# is, at the absolute minimum, the 'pillow' package, and raise an error
# to the caller, so he can decide what to do with the error. The caller
# can treat this as a warning or an error to abort the build.
if not _supports("Image"):
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(file.abs_src_path, docs)
raise PluginError(
f"Couldn't optimize image '{path}' in '{docs}': install "
f"required dependencies pip install 'mkdocs-material[imaging]'"
)
# Open and save optimized image
image = Image.open(file.abs_src_path)
image.save(path, "jpeg",
quality = self.config.optimize_jpg_quality,
progressive = self.config.optimize_jpg_progressive
)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Check for presence of optional imports
@functools.lru_cache(maxsize = None)
def _supports(name: str):
return name in globals()
# -----------------------------------------------------------------------------
# Print human-readable size
def _size(value):
for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
if abs(value) < 1000.0:
return f"{value:3.1f} {unit}"
value /= 1000.0
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.optimize")

View File

@@ -21,7 +21,21 @@
import os
from mkdocs.config.base import Config
from mkdocs.config.config_options import DictOfItems, Type
from mkdocs.config.config_options import (
Choice, Deprecated, DictOfItems, ListOfItems, Type
)
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
# Options for log level
LogLevel = (
"error",
"warn",
"info",
"debug"
)
# -----------------------------------------------------------------------------
# Classes
@@ -36,8 +50,29 @@ class PrivacyConfig(Config):
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/privacy")
# Settings for logging
log = Type(bool, default = True)
log_level = Choice(LogLevel, default = "info")
# Settings for external assets
assets = Type(bool, default = True)
assets_fetch = Type(bool, default = True)
assets_fetch_dir = Type(str, default = "assets/external")
assets_include = ListOfItems(Type(str), default = [])
assets_exclude = ListOfItems(Type(str), default = [])
assets_expr_map = DictOfItems(Type(str), default = {})
# Settings for external links
links = Type(bool, default = True)
links_attr_map = DictOfItems(Type(str), default = {})
links_noopener = Type(bool, default = True)
# Deprecated settings
external_assets = Deprecated(message = "Deprecated, use 'assets_fetch'")
external_assets_dir = Deprecated(moved_to = "assets_fetch_dir")
external_assets_include = Deprecated(moved_to = "assets_include")
external_assets_exclude = Deprecated(moved_to = "assets_exclude")
external_assets_expr = Deprecated(moved_to = "assets_expr_map")
external_links = Deprecated(moved_to = "links")
external_links_attr_map = Deprecated(moved_to = "links_attr_map")
external_links_noopener = Deprecated(moved_to = "links_noopener")

View File

@@ -29,7 +29,9 @@ import requests
import sys
from colorama import Fore, Style
from concurrent.futures import Future, ThreadPoolExecutor, wait
from concurrent.futures import Future, wait
from concurrent.futures.thread import ThreadPoolExecutor
from fnmatch import fnmatch
from hashlib import sha1
from mkdocs.config.config_options import ExtraScriptValue
from mkdocs.config.defaults import MkDocsConfig
@@ -52,6 +54,7 @@ DEFAULT_TIMEOUT_IN_SECS = 5
# Privacy plugin
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
supports_multiple_instances = True
# Initialize thread pools and asset collections
def on_config(self, config):
@@ -65,12 +68,20 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
# Initialize collections of external assets
self.assets = Files([])
self.assets_done: list[File] = []
self.assets_expr_map = {
".css": r"url\(\s*([\"']?)(?P<url>http?[^)'\"]+)\1\s*\)",
".js": r"[\"'](?P<url>http[^\"']+\.(?:css|js(?:on)?))[\"']",
**self.config.assets_expr_map
}
# Set log level or disable logging altogether - @todo when refactoring
# this plugin for the next time, we should put this into a factory
if not self.config.log:
log.disabled = True
else:
log.setLevel(self.config.log_level.upper())
# Process external style sheets and scripts (run latest) - run this after
# all other plugins, so they can add additional assets
@event_priority(-100)
@@ -127,7 +138,13 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
# 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.
# each image we find that checks if the image needs to be downloaded. Also,
# downloading all external images at this stage, we reconcile all concurrent
# jobs in `on_env`, which is the stage in which the optimize plugin will
# evaluate what images can and need to be optimized. This means we can pass
# external images through the optimization pipeline. Additionally, we run
# this after all other plugins, so we allow them to add additional images
# to the content of the page. How cool is that?
@event_priority(-100)
def on_page_content(self, html, *, page, config, files):
if not self.config.enabled:
@@ -149,13 +166,27 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
if not self._is_excluded(url, page.file):
self._queue(url, config, concurrent = True)
# Sync all concurrent jobs
# Reconcile jobs and pass external assets to MkDocs (run earlier) - allow
# other plugins (e.g. optimize plugin) to post-process external assets
@event_priority(50)
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Wait until all jobs until now are finished
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
# same thread pool for fetching all remaining external assets
wait(self.pool_jobs)
self.pool_jobs.clear()
# Append all downloaded assets that are not style sheets or scripts to
# MkDocs's collection of files, making them available to other plugins
# for further processing. The remaining exteral assets are patched
# before copying, which is done at the end of the build process.
for file in self.assets:
_, extension = posixpath.splitext(file.dest_uri)
if extension not in [".css", ".js"]:
self.assets_done.append(file)
files.append(file)
# Process external assets in template (run later)
@event_priority(-50)
@@ -180,7 +211,8 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
# Parse and replace links to external assets
return self._parse_html(output, page.file, config)
# Reconcile jobs (run earlier)
# Reconcile jobs (run earlier) - allow other plugins (e.g. optimize plugin)
# to process all downloaded assets, which is why we must reconcile here
@event_priority(50)
def on_post_build(self, *, config):
if not self.config.enabled:
@@ -200,10 +232,10 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
self._patch, file
))
# Otherwise just copy external asset to output directory if it
# exists, i.e., if the download succeeded
else:
if os.path.exists(file.abs_src_path):
# Otherwise just copy external asset to output directory, if we
# haven't handed control to MkDocs in `on_env` before
elif file not in self.assets_done:
if os.path.exists(str(file.abs_src_path)):
file.copy_file()
# Reconcile concurrent jobs for the last time, so the plugins following
@@ -236,6 +268,28 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
Style.RESET_ALL
])
# Check if URL matches one of the inclusion patterns
if self.config.assets_include:
for pattern in self.config.assets_include:
if fnmatch(self._path_from_url(url), pattern):
return False
# File is not included
log.debug(
f"Excluding external file '{url.geturl()}' {via}due to "
f"inclusion patterns"
)
return True
# Check if URL matches one of the exclusion patterns
for pattern in self.config.assets_exclude:
if fnmatch(self._path_from_url(url), pattern):
log.debug(
f"Excluding external file '{url.geturl()}' {via}due to "
f"exclusion patterns"
)
return True
# Print warning if fetching is not enabled
if not self.config.assets_fetch:
log.warning(f"External file: {url.geturl()} {via}")
@@ -301,6 +355,21 @@ class PrivacyPlugin(BasePlugin[PrivacyConfig]):
def replace(match: Match):
el = self._parse_fragment(match.group())
# Handle external link
if self.config.links and el.tag == "a":
for key, value in self.config.links_attr_map.items():
el.set(key, value)
# Set `rel=noopener` if link opens in a new window
if self.config.links_noopener:
if el.get("target") == "_blank":
rel = re.findall(r"\S+", el.get("rel", ""))
if "noopener" not in rel:
rel.append("noopener")
# Set relationships after adding `noopener`
el.set("rel", " ".join(rel))
# Handle external style sheet or preconnect hint
if el.tag == "link":
url = urlparse(el.get("href"))

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,320 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import multiprocessing
import os
import posixpath
from collections.abc import Callable
from concurrent.futures import Future, as_completed
from concurrent.futures.process import ProcessPoolExecutor
from logging import Logger
from material.plugins.projects.config import ProjectsConfig
from material.plugins.projects.structure import Project, ProjectJob
from mkdocs.commands.build import build
from mkdocs.config.base import ConfigErrors, ConfigWarnings
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import Abort
from mkdocs.livereload import LiveReloadServer
from urllib.parse import urlparse
from .log import (
get_log_for,
get_log_formatter,
get_log_handler,
get_log_level_for
)
from .watcher import ProjectsWatcher
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects builder
class ProjectsBuilder:
# Set of projects
projects: set[Project] = set()
# Projects watcher
watcher: ProjectsWatcher | None = None
# Initialize projects builder - compared to our other concurrent plugins,
# this plugin is forced to use a process pool in order to guarantee proper
# isolation, as MkDocs itself is not thread-safe. We also need to recreate
# the process pool on every build, or CTRL-C is broken
def __init__(self, config: MkDocsConfig, plugin: ProjectsConfig):
# Initialize root project
self.root = Project(config.config_file_path, plugin)
self.root.config = config
# Initialize process pool
self.pool = ProcessPoolExecutor
self.pool_jobs: dict[str, Future] = {}
# Build projects
def build(self, serve: bool = False, dirty: bool = False):
self.pool = ProcessPoolExecutor(
self.root.plugin.concurrency,
mp_context = multiprocessing.get_context("spawn")
)
# Determine projects in topological order and prepare for building
built: list[str] = []
queue = [*self.root.jobs()]
for job in queue:
_setup(job.project, self.root, serve)
if serve:
self._link(job.project)
# Schedule projects for building
for job in reversed(queue):
if self._schedule(job, serve, dirty):
queue.remove(job)
# Build loop - iteratively build more projects while there are still
# projects to be built, sticking to the topological order.
while len(built) < len(self.pool_jobs):
for future in as_completed(self.pool_jobs.values()):
slug, errors, warnings = future.result()
if slug in built:
continue
# Mark project as built
built.append(slug)
# Schedule projects for building
for job in reversed(queue):
if self._schedule(job, serve, dirty):
queue.remove(job)
# Print errors and warnings
for project in self.projects:
if project.slug == slug:
_print(get_log_for(project), errors, warnings)
break
# Shutdown process pool
self.pool.shutdown()
if self.watcher:
# Update watched paths
for project in self.projects:
if project.slug not in built:
self.pool_jobs.pop(project.slug, None)
self.watcher.unwatch(project)
else:
self.watcher.watch(project, self.taint)
# Taint a project to schedule it for building
def taint(self, project: Project):
self.pool_jobs.pop(project.slug, None)
# Watch and serve projects
def serve(self, server: LiveReloadServer, is_dirty: bool = False):
self.watcher = ProjectsWatcher(server)
self.watcher.watch(self.root, self.taint)
for project in self.projects:
self.watcher.watch(project, self.taint)
# -------------------------------------------------------------------------
# Create symlink for project if we're serving the site
def _link(self, project: Project):
# Compute path for slug from current project - normalize path,
# as paths computed from slugs or site URLs use forward slashes
path = project.path(self.root)
path = os.path.normpath(path)
# Create symbolic link, if we haven't already
path = os.path.join(self.root.config.site_dir, path)
if not os.path.islink(path):
# Ensure link target exists
target = os.path.realpath(os.path.dirname(path))
if not os.path.exists(target):
os.makedirs(target, exist_ok = True)
# Create symbolic link
os.makedirs(os.path.dirname(path), exist_ok = True)
os.symlink(project.config.site_dir, path)
# Schedule project for building - spawn concurrent job to build the project
# and add a future to the jobs dictionary to link build results to projects
def _schedule(self, job: ProjectJob, serve: bool, dirty: bool):
self.projects.add(job.project)
# Exit early, if project doesn't need to be re built
if job.project.slug in self.pool_jobs:
return True
# Check if dependencies have been built already, and if so, remove
# them from the list of dependencies. If a dependency has failed to
# build, we'll raise an exception, which will be caught by the main
# process, and the entire build will be aborted.
for dependency in [*job.dependencies]:
future = self.pool_jobs[dependency.slug]
if future.running():
continue
# If the dependency has failed to build, we'll raise an exception
# to abort the entire build, as we can't build the project itself
# without the dependency. This will be caught by the main process.
# Otherwise, we'll remove the dependency from the list.
if future.exception():
raise future.exception()
elif future.done():
job.dependencies.remove(dependency)
# If all dependencies of the project have been built, we can build
# the project itself by spawning a concurrent job
if not job.dependencies:
self.pool_jobs[job.project.slug] = self.pool.submit(
_build, job.project, serve, dirty,
get_log_level_for(job.project)
)
# Return whether the project has been scheduled
return not job.dependencies
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Setup project by preparing it for building
def _setup(project: Project, root: Project, serve: bool):
assert project.slug != "."
# Retrieve configuration of top-level project and transform project
transform = root.plugin.projects_config_transform
if isinstance(transform, Callable):
transform(project, root)
# If the top-level project defines a site URL, we need to make sure that the
# site URL of the project is set as well, setting it to the path we derive
# from the slug. This allows to define the URL independent of the entire
# project's directory structure. If the top-level project doesn't define a
# site URL, it might be the case that the author is building a consolidated
# project of several nested projects that are independent, but which should
# be bundled together for distribution. As this is a case that is quite
# common, we're not raising a warning or error.
path = project.path(root)
if root.config.site_url:
# If the project doesn't have a site URL, compute it from the site URL
# of the top-level project and the path derived from the slug
if not project.config.site_url:
project.config.site_url = posixpath.join(
root.config.site_url,
path
)
# If we're serving the site, replace the project's host name with the
# dev server address, so we can serve nested projects as well
if serve:
url = urlparse(project.config.site_url)
url = url._replace(
scheme = "http",
netloc = str(root.config.dev_addr)
)
# Update site URL with dev server address
project.config.site_url = url.geturl()
# If we're building the site, the project's output must be written to the
# site directory of the top-level project, so we can serve it from there
if not serve:
project.config.site_dir = os.path.join(
root.config.site_dir,
os.path.normpath(path)
)
# If we're serving the site, we must fall back to symbolic links, as MkDocs
# will empty the entire site directory every time it performs a build
else:
project.config.site_dir = os.path.join(
os.path.dirname(project.config.config_file_path),
project.config.site_dir
)
# Build project - note that regardless of whether MkDocs was started in build
# or serve mode, projects must always be built, as they're served by the root
def _build(project: Project, serve: bool, dirty: bool, level = logging.WARN):
config = project.config
# Change working directory to project root - this is necessary, or relative
# paths used in extensions and plugins will be resolved incorrectly
os.chdir(os.path.dirname(config.config_file_path))
# Validate configuration
errors, warnings = config.validate()
if not errors:
# Retrieve and configure MkDocs' logger
log = logging.getLogger("mkdocs")
log.setLevel(level)
# Hack: there seems to be an inconsistency between operating systems,
# and it's yet unclear where this is coming from - on macOS, the MkDocs
# default logger has no designated handler registered, but on Linux it
# does. If there's no handler, we need to create one. If there is, we
# must only set the formatter, as otherwise we'll end up with the same
# message printed on two log handlers - see https://t.ly/q7UEq
handler = get_log_handler(project)
if not log.hasHandlers():
log.addHandler(handler)
else:
for handler in log.handlers:
handler.setFormatter(get_log_formatter(project))
# Build project and dispatch startup and shutdown plugin events - note
# that we must pass the correct command to the event handler, but run
# the build command anyway, because otherwise some plugins will not
# run in serve mode.
command = "serve" if serve else "build"
config.plugins.run_event("startup", command = command, dirty = dirty)
try:
build(config, dirty = dirty)
finally:
config.plugins.run_event("shutdown")
log.removeHandler(handler)
# Return slug, errors and warnings
return project.slug, errors, warnings
# Print errors and warnings resulting from building a project
def _print(log: Logger, errors: ConfigErrors, warnings: ConfigWarnings):
# Print warnings
for value, message in warnings:
log.warning(f"Config value '{value}': {message}")
# Print errors
for value, message in errors:
log.error(f"Config value '{value}': {message}")
# Abort if there were errors
if errors:
raise Abort(f"Aborted with {len(errors)} configuration errors")

View File

@@ -0,0 +1,100 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
from click import style
from logging import Filter
from material.plugins.projects.structure import Project
from mkdocs.__main__ import ColorFormatter
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Dirty build warning filter
class ProjectsFilter(Filter):
# Filter log messages
def filter(self, record):
message = record.getMessage()
return not message.startswith("A 'dirty' build")
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Retrieve logger for project
def get_log_for(project: Project):
log = logging.getLogger("".join(["mkdocs.material.projects", project.slug]))
# Ensure logger does not propagate messags to parent logger, or messages
# will be printed multiple times, and attach handler with color formatter
log.propagate = False
if not log.hasHandlers():
log.addHandler(get_log_handler(project))
log.setLevel(get_log_level_for(project))
# Return logger
return log
# Retrieve log level for project
def get_log_level_for(project: Project):
level = logging.INFO
# Determine log level as set in MkDocs - if the build is started with the
# `--quiet` flag, the log level is set to `ERROR` to suppress all messages,
# except for errors. If it's started with `--verbose`, MkDocs sets the log
# level to `DEBUG`, the most verbose of all log levels.
log = logging.getLogger("mkdocs")
for handler in log.handlers:
level = handler.level
break
# Determine if MkDocs was invoked with the `--quiet` flag and the log level
# as configured in the plugin configuration. When `--quiet` is set, or the
# projects plugin configuration disables logging, ignore the configured log
# level and set it to `ERROR` to suppress all messages.
quiet = level == logging.ERROR
level = project.plugin.log_level.upper()
if quiet or not project.plugin.log:
level = logging.ERROR
# Retun log level
return level
# -----------------------------------------------------------------------------
# Retrieve log handler for project
def get_log_handler(project: Project):
handler = logging.StreamHandler()
handler.setFormatter(get_log_formatter(project))
# Add filter to suppress dirty build warning, or we'll get as many of those
# as projects are built - one warning is surely enough, KTHXBYE
handler.addFilter(ProjectsFilter())
return handler
# Retrieve log formatter for project
def get_log_formatter(project: Project):
prefix = style(f"project://{project.slug}", underline = True)
return ColorFormatter(f"[{prefix}] %(message)s")

View File

@@ -0,0 +1,98 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import os
from collections.abc import Callable
from material.plugins.projects.structure import Project
from mkdocs.livereload import LiveReloadServer
from .handler import ProjectChanged, ProjectAddedOrRemoved
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects watcher
class ProjectsWatcher:
# Set of watched paths
watched: set[str] = set()
# Initialize projects watcher
def __init__(self, server: LiveReloadServer):
self.server = server
# Watch project and invoke function on change
def watch(self, project: Project, fn: Callable):
self._on_project_changed(project, fn)
self._on_project_added_or_removed(project, fn)
# Stop watching project
def unwatch(self, project: Project):
# Traverse all watched paths
root = os.path.dirname(project.config.config_file_path)
for path in [*self.watched]:
# Remove path from watched paths
if path.startswith(root):
self.server.unwatch(path)
self.watched.remove(path)
# -------------------------------------------------------------------------
# Register event handler for project changes
def _on_project_changed(self, project: Project, fn: Callable):
# Resolve project root and docs directory
root = os.path.dirname(project.config.config_file_path)
docs = os.path.join(root, project.config.docs_dir)
# Collect all paths to watch
paths = set([docs, project.config.config_file_path])
for path in project.config.watch:
paths.add(os.path.join(root, path))
# Register event handler for unwatched paths
handler = ProjectChanged(project, fn)
for path in paths - self.watched:
self.server.watch(path)
# Add path and its descendents to watched paths
self.server.observer.schedule(handler, path, recursive = True)
self.watched.add(path)
# Register event handler for project additions and removals
def _on_project_added_or_removed(self, project: Project, fn: Callable):
# Resolve project root and path to projects directory
root = os.path.dirname(project.config.config_file_path)
path = os.path.join(root, project.plugin.projects_dir)
# Register event handler for unwatched paths
handler = ProjectAddedOrRemoved(project, fn)
if path not in self.watched and os.path.isdir(path):
# Add path to watched paths
self.server.observer.schedule(handler, path)
self.watched.add(path)

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import os
from collections.abc import Callable
from material.plugins.projects.structure import Project
from watchdog.events import FileSystemEvent, FileSystemEventHandler
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Project changed
class ProjectChanged(FileSystemEventHandler):
# Initialize event handler
def __init__(self, project: Project, handler: Callable):
self.project = project
self.handler = handler
# Handle file event
def on_any_event(self, event: FileSystemEvent):
self._handle(event)
# -------------------------------------------------------------------------
# Invoke file event handler
def _handle(self, event: FileSystemEvent):
config = self.project.config
# Resolve path to docs directory
base = os.path.dirname(config.config_file_path)
docs = os.path.join(base, config.docs_dir)
# Resolve project root and path to changed file
root = os.path.relpath(base)
path = os.path.relpath(event.src_path, root)
# Check if mkdocs.yml or docs directory was deleted
if event.src_path in [docs, config.config_file_path]:
if event.event_type == "deleted":
return
# Invoke handler and print message that we're scheduling a build
log.info(f"Schedule build due to '{path}' in '{root}'")
self.handler(self.project)
# -----------------------------------------------------------------------------
# Project added or removed
class ProjectAddedOrRemoved(FileSystemEventHandler):
# Initialize event handler
def __init__(self, project: Project, handler: Callable):
self.project = project
self.handler = handler
# Handle file creation event
def on_created(self, event: FileSystemEvent):
self._handle(event)
# Handle file deletion event
def on_deleted(self, event: FileSystemEvent):
self._handle(event)
# ------------------------------------------------------------------------
# Invoke file event handler
def _handle(self, event: FileSystemEvent):
config = self.project.config
# Touch mkdocs.yml to trigger rebuild
if os.path.isfile(config.config_file_path):
os.utime(config.config_file_path, None)
# Invoke handler
self.handler(self.project)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import os
from collections.abc import Callable
from mkdocs.config.config_options import Choice, Optional, Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
# Options for log level
LogLevel = (
"error",
"warn",
"info",
"debug"
)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects plugin configuration
class ProjectsConfig(Config):
enabled = Type(bool, default = True)
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
# Settings for caching
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/projects")
# Settings for logging
log = Type(bool, default = True)
log_level = Choice(LogLevel, default = "info")
# Settings for projects
projects = Type(bool, default = True)
projects_dir = Type(str, default = "projects")
projects_config_files = Type(str, default = "*/mkdocs.yml")
projects_config_transform = Optional(Type(Callable))
projects_root_dir = Optional(Type(str))
# Settings for hoisting
hoisting = Type(bool, default = True)

View File

@@ -0,0 +1,292 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import json
import os
import posixpath
from jinja2 import pass_context
from jinja2.runtime import Context
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs import utils
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure import StructureItem
from mkdocs.structure.files import Files
from mkdocs.structure.nav import Link, Section
from mkdocs.utils import get_theme_dir
from urllib.parse import ParseResult as URL, urlparse
from .builder import ProjectsBuilder
from .config import ProjectsConfig
from .structure import Project, ProjectLink
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects plugin
class ProjectsPlugin(BasePlugin[ProjectsConfig]):
# Projects builder
builder: ProjectsBuilder = None
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
self.is_dirty = False
# Hack: Since we're building in topological order, we cannot let MkDocs
# clean the directory, because it means that nested projects are always
# deleted before a project is built. We also don't need to restore this
# functionality, because it's only used once in the process.
utils.clean_directory = lambda _: _
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
self.is_dirty = dirty
# Resolve projects compared to our other concurrent plugins, this plugin
# is forced to use a process pool in order to guarantee proper isolation, as
# MkDocs itself is not thread-safe. Additionally, all project configurations
# are resolved and written to the cache (if enabled), as it's sufficient to
# resolve them once on the top-level before projects are built. We might
# need adjacent project configurations for interlinking projects.
def on_config(self, config):
if not self.config.enabled:
return
# Skip if projects should not be built - we can only exit here if we're
# at the top-level, but not when building a nested project
root = self.config.projects_root_dir is None
if root and not self.config.projects:
return
# Set projects root directory to the top-level project
if not self.config.projects_root_dir:
self.config.projects_root_dir = os.path.dirname(
config.config_file_path
)
# Initialize manifest
self.manifest: dict[str, str] = {}
self.manifest_file = os.path.join(
self.config.projects_root_dir,
self.config.cache_dir,
"manifest.json"
)
# Load manifest if it exists and the cache should be used
if os.path.isfile(self.manifest_file):
try:
with open(self.manifest_file) as f:
self.manifest = json.load(f)
except:
pass
# Building the top-level project, we must resolve and load all project
# configurations, as we need all information upfront to build them in
# the correct order, and to resolve links between projects. Furthermore,
# the author might influence a project's path by setting the site URL.
if root:
if not self.builder:
self.builder = ProjectsBuilder(config, self.config)
# @todo: detach project resolution from build
self.manifest = { ".": os.path.relpath(config.config_file_path) }
for job in self.builder.root.jobs():
path = os.path.relpath(job.project.config.config_file_path)
self.manifest[job.project.slug] = path
# Save manifest, a we need it in nested projects
os.makedirs(os.path.dirname(self.manifest_file), exist_ok = True)
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# Schedule projects for building - the general case is that all projects
# can be considered independent of each other, so we build them in parallel
def on_pre_build(self, config):
if not self.config.enabled:
return
# Skip if projects should not be built or we're not at the top-level
if not self.config.projects or not self.builder:
return
# Build projects
self.builder.build(self.is_serve, self.is_dirty)
# Patch environment to allow for hoisting of media files provided by the
# theme itself, which will also work for other themes, not only this one
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Skip if projects should not be built or we're at the top-level
if not self.config.projects or self.builder:
return
# If hoisting is enabled and we're building a project, remove all media
# files that are provided by the theme and hoist them to the top
if self.config.hoisting:
theme = get_theme_dir(config.theme.name)
hoist = Files([])
# Retrieve top-level project and check if the current project uses
# the same theme as the top-level project - if not, don't hoist
root = Project("mkdocs.yml", self.config)
if config.theme.name != root.config.theme["name"]:
return
# Remove all media files that are provided by the theme
for file in files.media_files():
if file.abs_src_path.startswith(theme):
files.remove(file)
hoist.append(file)
# Resolve source and target project
source: Project | None = None
target: Project | None = None
for ref, file in self.manifest.items():
base = os.path.join(self.config.projects_root_dir, file)
if file == os.path.relpath(
config.config_file_path, self.config.projects_root_dir
):
source = Project(base, self.config, ref)
if "." == ref:
target = Project(base, self.config, ref)
# Compute path for slug from source and target project
path = target.path(source)
# Fetch URL template filter from environment - the filter might
# be overridden by other plugins, so we must retrieve and wrap it
url_filter = env.filters["url"]
# Patch URL template filter to add support for correctly resolving
# media files that were hoisted to the top-level project
@pass_context
def url_filter_with_hoisting(context: Context, url: str | None):
if url and hoist.get_file_from_path(url):
return posixpath.join(path, url_filter(context, url))
else:
return url_filter(context, url)
# Register custom template filters
env.filters["url"] = url_filter_with_hoisting
# Adjust project navigation in page (run latest) - as always, allow
# other plugins to alter the navigation before we process it here
@event_priority(-100)
def on_page_context(self, context, *, page, config, nav):
if not self.config.enabled:
return
# Skip if projects should not be built
if not self.config.projects:
return
# Replace project URLs in navigation
self._replace(nav.items, config)
# Adjust project navigation in template (run latest) - as always, allow
# other plugins to alter the navigation before we process it here
@event_priority(-100)
def on_template_context(self, context, *, template_name, config):
if not self.config.enabled:
return
# Skip if projects should not be built
if not self.config.projects:
return
# Replace project URLs in navigation
self._replace(context["nav"].items, config)
# Serve projects
def on_serve(self, server, *, config, builder):
if self.config.enabled:
self.builder.serve(server, self.is_dirty)
# -------------------------------------------------------------------------
# Replace project links in the given list of navigation items
def _replace(self, items: list[StructureItem], config: MkDocsConfig):
for index, item in enumerate(items):
# Handle section
if isinstance(item, Section):
self._replace(item.children, config)
# Handle link
if isinstance(item, Link):
url = urlparse(item.url)
if url.scheme == "project":
project, url = self._resolve_project_url(url, config)
# Append file name if directory URLs are disabled
if not project.config.use_directory_urls:
url += "index.html"
# Replace link with project link
items[index] = ProjectLink(
item.title or project.config.site_name,
url
)
# Resolve project URL and slug
def _resolve_project_url(self, url: URL, config: MkDocsConfig):
# Abort if the project URL contains a path, as we first need to collect
# use cases for when, how and whether we need and want to support this
if url.path != "":
raise PluginError(
f"Couldn't resolve project URL: paths currently not supported\n"
f"Please only use 'project://{url.hostname}'"
)
# Compute slug from host name and convert to dot notation
slug = url.hostname
slug = slug if slug.startswith(".") else f".{slug}"
# Resolve source and target project
source: Project | None = None
target: Project | None = None
for ref, file in self.manifest.items():
base = os.path.join(self.config.projects_root_dir, file)
if file == os.path.relpath(
config.config_file_path, self.config.projects_root_dir
):
source = Project(base, self.config, ref)
if slug == ref:
target = Project(base, self.config, ref)
# Abort if slug doesn't match a known project
if not target:
raise PluginError(f"Couldn't find project '{slug}'")
# Return project slug and path
return target, target.path(source)

View File

@@ -0,0 +1,241 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import os
import posixpath
import re
from copy import deepcopy
from glob import iglob
from material.plugins.projects.config import ProjectsConfig
from mkdocs.structure.nav import Link
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.config.config_options import Plugins
from urllib.parse import urlparse
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Project
class Project:
# Initialize project - note that the configuration of the projects plugin
# of the enclosing project is necessary to resolve nested projects
def __init__(self, file: str, plugin: ProjectsConfig, slug = "."):
self.config, self.plugin = self._resolve(file, plugin)
# The slug should not be changed after initialization, as it's used for
# correct resolution of projects and nested projects
self.slug = slug
# Find and yield nested projects of the current project - the project's
# slug is prepended to the computed slug for a simple resolution of nested
# projects, allowing authors to use the project:// protocol for linking to
# projects from the top-level project or nested and adjacent projects
def __iter__(self):
seen: list[str] = []
# Compute project root and base directory
root = os.path.dirname(self.config.config_file_path)
base = os.path.join(root, self.plugin.projects_dir)
# Find and yield all projects - note that we need to filter for nested
# projects at this point, as we're only interested in the projects on
# the next level, not in projects inside projects as they are resolved
# recursively to preserve topological ordering. This is also why we must
# sort the list of projects by path, ordering shorted paths first which
# ensures that nested projects are resolved before their parents.
glob = os.path.join(base, self.plugin.projects_config_files)
glob = iglob(os.path.normpath(glob), recursive = True)
for file in sorted(glob, key = os.path.dirname):
path = os.path.join(os.path.dirname(file), "")
if any(path.startswith(_) for _ in seen):
continue
else:
seen.append(path)
# Extract the first level of the project's directory relative to
# the projects directory as the computed slug of the project. This
# allows authors to build projects whose mkdocs.yml files are not
# located at the project root, e.g., when using git submodules.
slug = os.path.relpath(file, base)
slug, *_ = slug.split(os.path.sep)
# Normalize slug to an internal dot notation which we convert to
# file system or URL paths when necessary. Each slug starts with
# a dot to denote that it is resolved from the top-level project,
# which also allows for resolving slugs in nested projects.
root = self.slug.rstrip(".")
slug = f"{root}.{slug}"
# Create and yield project
yield Project(file, self.plugin, slug)
# Compute project hash
def __hash__(self):
return hash(self.slug)
# Find and yield all nested projects (excluding this project) in reverse
# topological order, by performing a post-order traversal on the tree of
# projects. This function returns project jobs, which are projects with
# their immediate dependencies, to build them in the correct order.
def jobs(self):
stack = [*self]
while stack:
# Pop project from stack and get its dependencies
project = stack.pop()
dependencies = [*project]
# Add project dependencies to stack and yield job
stack.extend(dependencies)
yield ProjectJob(project, dependencies)
# Compute relative path between two projects
def path(self, that: Project):
# If both, the top-level and the current project have a site URL set,
# compute slug from the common path of both site URLs
if self.config.site_url and that.config.site_url:
source = self._path_from_config(that.config)
target = self._path_from_config(self.config)
# Edge case: the author has set a site URL that does not include a
# path, so the path of the project is equal to the top-level path.
# In this case, we need to fall back to the path computed from the
# slug - see https://t.ly/5vqMr
if target == source:
target = self._path_from_slug(self.slug)
# Otherwise, always compute the path from the slugs of both projects,
# as we want to support consolidation of unrelated projects
else:
source = self._path_from_slug(that.slug)
target = self._path_from_slug(self.slug)
# Compute path between projects, and add trailing slash
path = posixpath.relpath(target, source)
return posixpath.join(path, "")
# -------------------------------------------------------------------------
# Resolve project and plugin configuration
def _resolve(self, file: str, plugin: ProjectsConfig):
config = self._resolve_config(file)
plugin = self._resolve_plugin(config, plugin)
# Return project and plugin configuration
return config, plugin
# Resolve project configuration
def _resolve_config(self, file: str):
with open(file, encoding = "utf-8-sig") as f:
config: MkDocsConfig = MkDocsConfig(config_file_path = file)
config.load_file(f)
# Return project configuration
return config
# Resolve project plugin configuration
def _resolve_plugin(self, config: MkDocsConfig, plugin: ProjectsConfig):
# Make sure that every project has a plugin configuration set - we need
# to deep copy the configuration object, as it's mutated during parsing.
# We're using an internal method of the Plugins class to ensure that we
# always stick to the syntaxes allowed by MkDocs (list and dictionary).
plugins = Plugins._parse_configs(deepcopy(config.plugins))
for index, (key, settings) in enumerate(plugins):
if not re.match(r"^(material/)?projects$", key):
continue
# Forward these settings of the plugin configuration to the project,
# as we need to build nested projects consistently
for name in ["cache", "projects", "projects_root_dir", "hoisting"]:
settings[name] = plugin[name]
# Forward these settings only if they have not been set in the
# project configuration, as they might be overwritten by the author
for name in ["log", "log_level"]:
if not name in settings:
settings[name] = plugin[name]
# Initialize and expand the plugin configuration, and mutate the
# plugin collection to persist the patched configuration
plugin: ProjectsConfig = ProjectsConfig()
plugin.load_dict(settings)
if isinstance(config.plugins, list):
config.plugins[index] = { key: dict(plugin.items()) }
else:
config.plugins[key] = dict(plugin.items())
# Return project plugin configuration
return plugin
# If no plugin configuration was found, add the default configuration
# and call this function recursively to ensure that it's present
config.plugins.append("material/projects")
return self._resolve_plugin(config, plugin)
# -------------------------------------------------------------------------
# Compute path from given slug - split slug at dots, ignoring the first one,
# and join the segments to a path, prefixed with a dot. This is necessary
# to compute the common path correctly, so we can use the same logic for
# when the path is computed from the site URL (see below).
def _path_from_slug(self, slug: str):
_, *segments = slug.split(".")
return posixpath.join(".", *segments)
# Compute path from given project configuration - parse site URL and return
# canonicalized path. Paths always start with a dot and trailing slashes are
# always removed. This is necessary so that we can compute the common path
# correctly, since the site URL might or might not contain a trailing slash.
def _path_from_config(self, config: MkDocsConfig):
url = urlparse(config.site_url)
# Remove leading slash, if any
path = url.path
if path.startswith("/"):
path = path[1:]
# Return normalized path
path = posixpath.normpath(path) if path else path
return posixpath.join(".", path)
# -----------------------------------------------------------------------------
# Project job
class ProjectJob:
# Initialize project job
def __init__(self, project: Project, dependencies: list[Project]):
self.project = project
self.dependencies = dependencies
# -----------------------------------------------------------------------------
# Project link
class ProjectLink(Link):
# Indicate that the link points to a project
is_project = True

View File

@@ -39,6 +39,10 @@ pipeline = ("stemmer", "stopWordFilter", "trimmer")
# Classes
# -----------------------------------------------------------------------------
# Search field configuration
class SearchFieldConfig(Config):
boost = Type((int, float), default = 1.0)
# Search plugin configuration
class SearchConfig(Config):
enabled = Type(bool, default = True)
@@ -47,6 +51,7 @@ class SearchConfig(Config):
lang = Optional(LangOption())
separator = Optional(Type(str))
pipeline = Optional(ListOfItems(Choice(pipeline)))
fields = Type(dict, default = {})
# Settings for text segmentation (Chinese)
jieba_dict = Optional(Type(str))

View File

@@ -27,9 +27,10 @@ from backrefs import bre
from html import escape
from html.parser import HTMLParser
from mkdocs import utils
from mkdocs.config.config_options import SubConfig
from mkdocs.plugins import BasePlugin
from .config import SearchConfig
from .config import SearchConfig, SearchFieldConfig
try:
import jieba
@@ -81,6 +82,19 @@ class SearchPlugin(BasePlugin[SearchConfig]):
r"\s*,\s*", self._translate(config, "search.config.pipeline")
)))
# Validate field configuration
validator = SubConfig(SearchFieldConfig)
for config in self.config.fields.values():
validator.run_validation(config)
# Merge with default fields
if "title" not in self.config.fields:
self.config.fields["title"] = { "boost": 1e3 }
if "text" not in self.config.fields:
self.config.fields["text"] = { "boost": 1e0 }
if "tags" not in self.config.fields:
self.config.fields["tags"] = { "boost": 1e6 }
# Initialize search index
self.search_index = SearchIndex(**self.config)
@@ -230,7 +244,7 @@ class SearchIndex:
def generate_search_index(self, prev):
config = {
key: self.config[key]
for key in ["lang", "separator", "pipeline"]
for key in ["lang", "separator", "pipeline", "fields"]
}
# Hack: if we're running under dirty reload, the search index will only

View File

@@ -18,8 +18,11 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import os
from mkdocs.config.base import Config
from mkdocs.config.config_options import Deprecated, Type
from mkdocs.config.config_options import Deprecated, ListOfItems, Type
from mkdocs.config.defaults import _LogLevel
# -----------------------------------------------------------------------------
# Classes
@@ -28,21 +31,38 @@ from mkdocs.config.config_options import Deprecated, Type
# Social plugin configuration
class SocialConfig(Config):
enabled = Type(bool, default = True)
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
# Settings for caching
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/social")
# Settings for social cards
# Settings for logging
log = Type(bool, default = True)
log_level = _LogLevel(default = "warn")
# Settings for cards
cards = Type(bool, default = True)
cards_dir = Type(str, default = "assets/images/social")
cards_layout_dir = Type(str, default = "layouts")
cards_layout = Type(str, default = "default")
cards_layout_options = Type(dict, default = {})
cards_include = ListOfItems(Type(str), default = [])
cards_exclude = ListOfItems(Type(str), default = [])
# Settings for debugging
debug = Type(bool, default = False)
debug_on_build = Type(bool, default = False)
debug_grid = Type(bool, default = True)
debug_grid_step = Type(int, default = 32)
debug_color = Type(str, default = "grey")
# Deprecated settings
cards_color = Deprecated(
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,153 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import re
from mkdocs.config.base import Config
from mkdocs.config.config_options import (
Choice, DictOfItems, ListOfItems, SubConfig, Type
)
try:
from PIL.Image import Image as _Image
except ImportError:
pass
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
# Options for origin
Origin = (
"start top", "center top", "end top",
"start center", "center", "end center",
"start bottom", "center bottom", "end bottom",
"start", "end"
)
# Options for overflow
Overflow = (
"truncate",
"shrink"
)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Size
class Size(Config):
width = Type(int, default = 0)
height = Type(int, default = 0)
# Offset
class Offset(Config):
x = Type(int, default = 0)
y = Type(int, default = 0)
# # -----------------------------------------------------------------------------
# Background
class Background(Config):
color = Type(str, default = "")
image = Type(str, default = "")
# # -----------------------------------------------------------------------------
# Icon
class Icon(Config):
value = Type(str, default = "")
color = Type(str, default = "")
# # -----------------------------------------------------------------------------
# Line
class Line(Config):
amount = Type((int, float), default = 1)
height = Type((int, float), default = 1)
# Font
class Font(Config):
family = Type(str, default = "Roboto")
variant = Type(str, default = "")
style = Type(str, default = "Regular")
# Typography
class Typography(Config):
content = Type(str, default = "")
align = Choice(Origin, default = "start top")
overflow = Choice(Overflow, default = "truncate")
color = Type(str, default = "")
line = SubConfig(Line)
font = SubConfig(Font)
# -----------------------------------------------------------------------------
# Layer
class Layer(Config):
size = SubConfig(Size)
offset = SubConfig(Offset)
origin = Choice(Origin, default = "start top")
background = SubConfig(Background)
icon = SubConfig(Icon)
typography = SubConfig(Typography)
# -----------------------------------------------------------------------------
# Layout
class Layout(Config):
definitions = ListOfItems(Type(str), default = [])
tags = DictOfItems(Type(str), default = {})
size = SubConfig(Size)
layers = ListOfItems(SubConfig(Layer), default = [])
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Get layer or layout size as tuple
def get_size(layer: Layer | Layout):
return layer.size.width, layer.size.height
# Get layer offset as tuple
def get_offset(layer: Layer, image: _Image):
x, y = layer.offset.x, layer.offset.y
# Compute offset from origin - if an origin is given, compute the offset
# relative to the image and layer size to allow for flexible positioning
if layer.origin != "start top":
origin = re.split(r"\s+", layer.origin)
# Get layer size
w, h = get_size(layer)
# Compute origin on x-axis
if "start" in origin: pass
elif "end" in origin: x += (image.width - w) - 2 * x
elif "center" in origin: x += (image.width - w) >> 1
# Compute origin on y-axis
if "top" in origin: pass
elif "bottom" in origin: y += (image.height - h) - 2 * y
elif "center" in origin: y += (image.height - h) >> 1
# Return offset
return x, y

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Filter for coercing everthing that is falsy to an empty string
def x_filter(value: str | None):
return value or ""

View File

@@ -0,0 +1,244 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

View File

@@ -0,0 +1,234 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("accent") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set accent = palette.get("accent", "indigo") -%}
{%- set accent = accent.replace(" ", "-") -%}
{{ {
"red": "#ff1a47",
"pink": "#f50056",
"purple": "#df41fb",
"deep-purple": "#7c4dff",
"indigo": "#526cfe",
"blue": "#4287ff",
"light-blue": "#0091eb",
"cyan": "#00bad6",
"teal": "#00bda4",
"green": "#00c753",
"light-green": "#63de17",
"lime": "#b0eb00",
"yellow": "#ffd500",
"amber": "#ffaa00",
"orange": "#ff9100",
"deep-orange": "#ff6e42"
}[accent] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("accent") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set accent = palette.get("accent", "indigo") -%}
{%- set accent = accent.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff"
}[accent] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

View File

@@ -0,0 +1,244 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: white)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Text color (default: indigo)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Logo
- size: { width: 144, height: 144 }
offset: { x: 992, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 832, height: 42 }
offset: { x: 64, y: 64 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 832, height: 310 }
offset: { x: 62, y: 160 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 832, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

View File

@@ -0,0 +1,77 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image }}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image

View File

@@ -0,0 +1,255 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Definitions
definitions:
# Background image
- &background_image >-
{{ layout.background_image | x }}
# Background color (default: indigo)
- &background_color >-
{%- if layout.background_color -%}
{{ layout.background_color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ef5552",
"pink": "#e92063",
"purple": "#ab47bd",
"deep-purple": "#7e56c2",
"indigo": "#4051b5",
"blue": "#2094f3",
"light-blue": "#02a6f2",
"cyan": "#00bdd6",
"teal": "#009485",
"green": "#4cae4f",
"light-green": "#8bc34b",
"lime": "#cbdc38",
"yellow": "#ffec3d",
"amber": "#ffc105",
"orange": "#ffa724",
"deep-orange": "#ff6e42",
"brown": "#795649",
"grey": "#757575",
"blue-grey": "#546d78",
"black": "#000000",
"white": "#ffffff"
}[primary] or "#4051b5" }}
{%- endif -%}
# Text color (default: white)
- &color >-
{%- if layout.color -%}
{{ layout.color }}
{%- else -%}
{%- set palette = config.theme.palette or {} -%}
{%- if not palette is mapping -%}
{%- set list = palette | selectattr("primary") | list + palette -%}
{%- set palette = list | first -%}
{%- endif -%}
{%- set primary = palette.get("primary", "indigo") -%}
{%- set primary = primary.replace(" ", "-") -%}
{{ {
"red": "#ffffff",
"pink": "#ffffff",
"purple": "#ffffff",
"deep-purple": "#ffffff",
"indigo": "#ffffff",
"blue": "#ffffff",
"light-blue": "#ffffff",
"cyan": "#ffffff",
"teal": "#ffffff",
"green": "#ffffff",
"light-green": "#ffffff",
"lime": "#000000",
"yellow": "#000000",
"amber": "#000000",
"orange": "#000000",
"deep-orange": "#ffffff",
"brown": "#ffffff",
"grey": "#ffffff",
"blue-grey": "#ffffff",
"black": "#ffffff",
"white": "#000000"
}[primary] or "#ffffff" }}
{%- endif -%}
# Font family (default: Roboto)
- &font_family >-
{%- if layout.font_family -%}
{{ layout.font_family }}
{%- elif config.theme.font is mapping -%}
{{ config.theme.font.get("text", "Roboto") }}
{%- else -%}
Roboto
{%- endif -%}
# Font variant
- &font_variant >-
{%- if layout.font_variant -%}
{{ layout.font_variant }}
{%- endif -%}
# Site name
- &site_name >-
{{ config.site_name }}
# Page title
- &page_title >-
{%- if layout.title -%}
{{ layout.title }}
{%- else -%}
{{ page.meta.get("title", page.title) }}
{%- endif -%}
# Page title with site name
- &page_title_with_site_name >-
{%- if not page.is_homepage -%}
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
{%- else -%}
{{ config.site_name }}
{%- endif -%}
# Page description
- &page_description >-
{%- if layout.description -%}
{{ layout.description }}
{%- else -%}
{{ page.meta.get("description", config.site_description) | x }}
{%- endif -%}
# Page icon
- &page_icon >-
{{ page.meta.icon | x }}
# Logo
- &logo >-
{%- if layout.logo -%}
{{ layout.logo }}
{%- elif config.theme.logo -%}
{{ config.docs_dir }}/{{ config.theme.logo }}
{%- endif -%}
# Logo (icon)
- &logo_icon >-
{%- if not layout.logo and config.theme.icon -%}
{{ config.theme.icon.logo | x }}
{%- endif -%}
# Meta tags
tags:
# Open Graph
og:type: website
og:title: *page_title_with_site_name
og:description: *page_description
og:image: "{{ image.url }}"
og:image:type: "{{ image.type }}"
og:image:width: "{{ image.width }}"
og:image:height: "{{ image.height }}"
og:url: "{{ page.canonical_url }}"
# Twitter
twitter:card: summary_large_image
twitter:title: *page_title_with_site_name
twitter:description: *page_description
twitter:image: "{{ image.url }}"
# -----------------------------------------------------------------------------
# Specification
# -----------------------------------------------------------------------------
# Card size and layers
size: { width: 1200, height: 630 }
layers:
# Background
- background:
image: *background_image
color: *background_color
# Page icon
- size: { width: 630, height: 630 }
offset: { x: 800, y: 0 }
icon:
value: *page_icon
color: "#00000033"
# Logo
- size: { width: 64, height: 64 }
offset: { x: 64, y: 64 }
background:
image: *logo
icon:
value: *logo_icon
color: *color
# Site name
- size: { width: 768, height: 42 }
offset: { x: 160, y: 74 }
typography:
content: *site_name
color: *color
font:
family: *font_family
variant: *font_variant
style: Bold
# Page title
- size: { width: 864, height: 256 }
offset: { x: 62, y: 192 }
typography:
content: *page_title
align: start
color: *color
line:
amount: 3
height: 1.25
font:
family: *font_family
variant: *font_variant
style: Bold
# Page description
- size: { width: 864, height: 64 }
offset: { x: 64, y: 512 }
typography:
content: *page_description
align: start
color: *color
line:
amount: 2
height: 1.5
font:
family: *font_family
variant: *font_variant
style: Regular

View File

@@ -46,6 +46,8 @@ class TagsConfig(Config):
tags_slugify = Type(Callable, default = slugify(case = "lower"))
tags_slugify_separator = Type(str, default = "-")
tags_slugify_format = Type(str, default = "tag:{slug}")
tags_hierarchy = Type(bool, default = False)
tags_hierarchy_separator = Type(str, default = "/")
tags_sort_by = Type(Callable, default = tag_name)
tags_sort_reverse = Type(bool, default = False)
tags_name_property = Type(str, default = "tags")
@@ -61,13 +63,26 @@ class TagsConfig(Config):
listings_tags_sort_reverse = Type(bool, default = False)
listings_directive = Type(str, default = "material/tags")
listings_layout = Type(str, default = "default")
listings_toc = Type(bool, default = True)
# Settings for shadow tags
shadow = Type(bool, default = False)
shadow_on_serve = Type(bool, default = True)
shadow_tags = TagSet()
shadow_tags_prefix = Type(str, default = "")
shadow_tags_suffix = Type(str, default = "")
# Settings for export
export = Type(bool, default = True)
export_file = Type(str, default = "tags.json")
export_only = Type(bool, default = False)
# Deprecated settings
tags_compare = Deprecated(moved_to = "tags_sort_by")
tags_compare_reverse = Deprecated(moved_to = "tags_sort_reverse")
tags_pages_compare = Deprecated(moved_to = "listings_sort_by")
tags_pages_compare_reverse = Deprecated(moved_to = "listings_sort_reverse")
tags_file = Deprecated(
option_type = Type(str),
message = "This setting is not required anymore"
tags_file = Deprecated(option_type = Type(str))
tags_extra_files = Deprecated(
option_type = DictOfItems(ListOfItems(Type(str)), default = {})
)

View File

@@ -36,6 +36,7 @@ from .config import TagsConfig
from .renderer import Renderer
from .structure.listing.manager import ListingManager
from .structure.mapping.manager import MappingManager
from .structure.mapping.storage import MappingStorage
# -----------------------------------------------------------------------------
# Classes
@@ -44,6 +45,11 @@ from .structure.mapping.manager import MappingManager
class TagsPlugin(BasePlugin[TagsConfig]):
"""
A tags plugin.
This plugin collects tags from the front matter of pages, and builds a tag
structure from them. The tag structure can be used to render listings on
pages, or to just create a site-wide tags index and export all tags and
mappings to a JSON file for consumption in another project.
"""
supports_multiple_instances = True
@@ -123,6 +129,17 @@ class TagsPlugin(BasePlugin[TagsConfig]):
else:
config.markdown_extensions.append("attr_list")
# If the author only wants to extract and export mappings, we allow to
# disable the rendering of all tags and listings with a single setting
if self.config.export_only:
self.config.tags = False
self.config.listings = False
# By default, shadow tags 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.shadow_on_serve:
self.config.shadow = True
@event_priority(-50)
def on_page_markdown(
self, markdown: str, *, page: Page, config: MkDocsConfig, **kwargs
@@ -151,6 +168,10 @@ class TagsPlugin(BasePlugin[TagsConfig]):
if self.config.tags_file:
markdown = self._handle_deprecated_tags_file(page, markdown)
# Handle deprecation of `tags_extra_files` setting
if self.config.tags_extra_files:
markdown = self._handle_deprecated_tags_extra_files(page, markdown)
# Collect tags from page
try:
self.mappings.add(page, markdown)
@@ -186,6 +207,15 @@ class TagsPlugin(BasePlugin[TagsConfig]):
# Populate and render all listings
self.listings.populate_all(self.mappings, Renderer(env, config))
# Export mappings to file, if enabled
if self.config.export:
path = os.path.join(config.site_dir, self.config.export_file)
path = os.path.normpath(path)
# Serialize mappings and save to file
storage = MappingStorage(self.config)
storage.save(path, self.mappings)
def on_page_context(
self, context: TemplateContext, *, page: Page, **kwargs
) -> None:
@@ -243,6 +273,38 @@ class TagsPlugin(BasePlugin[TagsConfig]):
# Return markdown
return markdown
def _handle_deprecated_tags_extra_files(
self, page: Page, markdown: str
) -> str:
"""
Handle deprecation of `tags_extra_files` setting.
Arguments:
page: The page.
"""
directive = self.config.listings_directive
if page.file.src_uri not in self.config.tags_extra_files:
return markdown
# Compute tags to render on page
tags = self.config.tags_extra_files[page.file.src_uri]
if tags:
directive += f" {{ include: [{', '.join(tags)}] }}"
# Try to find the legacy tags marker and replace with directive
if "[TAGS]" in markdown:
markdown = markdown.replace(
"[TAGS]", f"<!-- {directive} -->"
)
# Try to find the directive and add it if not present
pattern = r"<!--\s+{directive}".format(directive = re.escape(directive))
if not re.search(pattern, markdown):
markdown += f"\n<!-- {directive} -->"
# Return markdown
return markdown
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------

View File

@@ -192,6 +192,11 @@ class Listing:
"""
Add mapping to listing.
Mappings are only added to listings, if the listing features tags that
are also featured in the mapping. The caller can decide whether hidden
tags should be rendered or not, e.g., automatically set by the plugin
when shadow tags are disabled.
Arguments:
mapping: The mapping.
hidden: Whether to add hidden tags.

View File

@@ -44,6 +44,14 @@ class ListingConfig(Config):
subsection of the documentation.
"""
shadow = Optional(Type(bool))
"""
Whether to include shadow tags.
This setting allows to override the global setting for shadow tags. If this
setting is not specified, the global `shadow` setting is used.
"""
layout = Optional(Type(str))
"""
The layout to use for rendering the listing.
@@ -52,6 +60,14 @@ class ListingConfig(Config):
setting is not specified, the global `listings_layout` setting is used.
"""
toc = Optional(Type(bool))
"""
Whether to populate the table of contents with anchor links to tags.
This setting allows to override the global setting for the layout. If this
setting is not specified, the global `listings_toc` setting is used.
"""
include = TagSet()
"""
Tags to include in the listing.

View File

@@ -243,9 +243,9 @@ class ListingManager:
page = listing.page
assert isinstance(page.content, str)
# Add mappings to listing
# Add mappings to listing, passing shadow tags configuration
for mapping in mappings:
listing.add(mapping)
listing.add(mapping, hidden = listing.config.shadow)
# Sort listings and tags - we can only do this after all mappings have
# been added to the listing, because the tags inside the mappings do
@@ -376,10 +376,18 @@ class ListingManager:
f"{e}"
)
# Inherit shadow tags configuration, unless explicitly set
if not isinstance(config.shadow, bool):
config.shadow = self.config.shadow
# Inherit layout configuration, unless explicitly set
if not isinstance(config.layout, str):
config.layout = self.config.listings_layout
# Inherit table of contents configuration, unless explicitly set
if not isinstance(config.toc, bool):
config.toc = self.config.listings_toc
# Return listing configuration
return config
@@ -389,17 +397,29 @@ class ListingManager:
"""
Slugify tag.
If the tag hierarchy setting is enabled, the tag is expanded into a
hierarchy of tags, all of which are then slugified and joined with the
configured separator. Otherwise, the tag is slugified directly. This is
necessary to keep the tag hierarchy in the slug.
Arguments:
tag: The tag.
Returns:
The slug.
"""
slugify = self.config.tags_slugify
tags = [tag.name]
# Compute tag hierarchy, if configured
hierarchy = self.config.tags_hierarchy_separator
if self.config.tags_hierarchy:
tags = tag.name.split(hierarchy)
# Slugify tag hierarchy and join with separator
separator = self.config.tags_slugify_separator
return self.config.tags_slugify_format.format(
slug = self.config.tags_slugify(
tag.name,
self.config.tags_slugify_separator
)
slug = hierarchy.join(slugify(name, separator) for name in tags)
)
# -------------------------------------------------------------------------

View File

@@ -81,7 +81,10 @@ def populate(listing: Listing, slugify: Slugify) -> dict[Tag, AnchorLink]:
# Filter top-level anchor links and insert them into the page
children = [anchors[tag] for tag in anchors if not tag.parent]
host.children[at:at + 1] = children
if listing.config.toc:
host.children[at:at + 1] = children
else:
host.children.pop(at)
# Return mapping of tags to anchor links
return anchors

View File

@@ -34,6 +34,11 @@ class ListingTree:
"""
A listing tree.
Listing trees are a tree structure that represent the hierarchy of tags
and mappings. Each tree node is a tag, and each tag can have multiple
mappings. Additionally, each tree can have subtrees, which are typically
called nested tags.
This is an internal data structure that is used to render listings. It is
also the immediate structure that is passed to the template.
"""

View File

@@ -23,6 +23,7 @@ from __future__ import annotations
from collections.abc import Iterator
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from material.plugins.tags.structure.tag.options import TagSet
from mkdocs.structure.pages import Page
@@ -125,7 +126,7 @@ class MappingManager:
# Retrieve and validate tags, and add to mapping
for tag in self.format.validate(page.meta[tags]):
mapping.tags.add(tag)
mapping.tags.add(self._configure(tag))
# Return mapping
return mapping
@@ -143,6 +144,90 @@ class MappingManager:
if page.url in self.data:
return self.data[page.url]
# -------------------------------------------------------------------------
def _configure(self, tag: Tag) -> Tag:
"""
Configure tag.
This method is called by the mapping manager to configure a tag for the
the tag structure. Depending on the configuration, the tag is expanded
into a hierarchy of tags, and can be marked as hidden if it is a shadow
tag, hiding it from mappings and listings when rendering.
Arguments:
tag: The tag.
Returns:
The configured tag.
"""
if self.config.tags_hierarchy:
return self._configure_hierarchy(tag)
else:
return self._configure_shadow(tag, tag.name)
def _configure_hierarchy(self, tag: Tag) -> Tag:
"""
Configure hierarchical tag.
Note that shadow tags that occur as part of a tag hierarchy propagate
their hidden state to all of their children.
Arguments:
tag: The tag.
Returns:
The configured tag.
"""
separator = self.config.tags_hierarchy_separator
root, *rest = tag.name.split(separator)
# Create tag root and hierarchy
tag = self._configure_shadow(Tag(root), root)
for name in rest:
tag = self._configure_shadow(Tag(
separator.join([tag.name, name]),
parent = tag, hidden = tag.hidden
), name)
# Return tag
return tag
def _configure_shadow(self, tag: Tag, name: str) -> Tag:
"""
Configure shadow tag.
Regardless of the configuration, tags are always marked as hidden if
they're classified as shadow tags, e.g., if their name matches the
configured shadow prefix or suffix, or if they're part of the list of
shadow tags. Whether they're displayed is decided before rendering.
The tag name must be passed separately, as it may be different from the
tag's name, e.g., when creating a tag hierarchy. In this case, the name
represents the part that was added to the tag, essentially the suffix.
The name is checked for shadow prefixes and suffixes.
Arguments:
tag: The tag.
name: The tag name.
Returns:
The configured tag.
"""
if not tag.hidden:
tag.hidden = tag in self.config.shadow_tags
# Check if tag matches shadow prefix, if defined
if not tag.hidden and self.config.shadow_tags_prefix:
tag.hidden = name.startswith(self.config.shadow_tags_prefix)
# Check if tag matches shadow suffix, if defined
if not tag.hidden and self.config.shadow_tags_suffix:
tag.hidden = name.endswith(self.config.shadow_tags_suffix)
# Return tag
return tag
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------

View File

@@ -31,6 +31,35 @@ from functools import total_ordering
class Tag:
"""
A tag.
Tags can be used to categorize pages and group them into a tag structure. A
tag is a simple string, which can be split into a hierarchy of tags by using
the character or string as defined in the `hierarchy_separator` setting in
`mkdocs.yml`. Each parent tag contains their child tags.
Example:
```yaml
tags:
- foo/bar
- foo/baz
- qux
```
The tag structure for the above example would look like this:
```
.
├─ foo
│ ├─ bar
│ └─ baz
└─ qux
```
Note that this class does not split the tag name into a hierarchy of tags
by itself, but rather provides a simple interface to iterate over the tag
and its parents. Splitting is left to the caller, in order to allow for
changing the separator in `mkdocs.yml`.
"""
def __init__(

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.config_options import Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Typeset plugin configuration
class TypesetConfig(Config):
enabled = Type(bool, default = True)

View File

@@ -0,0 +1,123 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import re
from mkdocs.plugins import BasePlugin
from .config import TypesetConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Typeset plugin
class TypesetPlugin(BasePlugin[TypesetConfig]):
# Initialize plugin
def on_config(self, config):
if not self.config.enabled:
return
# Initialize titles
self.title_map: dict[str, str] = {}
# Extract source of page title before it's lost
def on_pre_page(self, page, *, config, files):
if not self.config.enabled:
return
# Check if page title was set in configuration
if page.title:
path = page.file.src_uri
self.title_map[path] = "config"
# Extract typeset content for headlines
def on_page_content(self, html, *, page, config, files):
if not self.config.enabled:
return
# Check if page title was set in metadata
path = page.file.src_uri
if path not in self.title_map:
if "title" in page.meta:
self.title_map[path] = "meta"
# Flatten anchors and map to headlines
anchors = _flatten(page.toc.items)
for (level, id, title) in re.findall(
r"<h(\d)[^>]+id=\"([^\"]+)[^>]*>(.*?)</h\1>",
html, flags = re.I | re.M
):
if id not in anchors:
continue
# If the author uses `data-toc-label` to override a heading (which
# doesn't support adding of HTML tags), we can abort here, since
# the headline will be rendered as-is. It's more or less a hack, so
# we should check if we can improve it in the future.
label = re.escape(anchors[id].title)
if re.search(rf"data-toc-label=['\"]{label}", page.markdown):
continue
# Remove anchor links from headlines we need to do that, or we
# end up with anchor links inside anchor links, which is invalid
# HTML5. There are two cases we need to account for here:
#
# 1. If toc.anchorlink is enabled, the entire headline is wrapped
# in an anchor link, so we unpack its contents
#
# 2. If toc.permalink is enabled, an anchor link is appended to the
# contents of the headline, so we just remove it
#
# Albeit it doesn't make much sense, both options can be used at
# the same time, so we need to account for both cases. This problem
# was first reported in https://bit.ly/456AjUm
title = re.sub(r"^<a\s+[^>]+>(.*?)</a>", r"\1", title)
title = re.sub(r"<a\s+[^>]+>[^<]+?</a>$", "", title)
# Remove author-provided ids - see https://bit.ly/3ngiZea
title = re.sub(r"id=\"?[^\">]+\"?", "", title)
# Assign headline content to anchor
anchors[id].typeset = { "title": title }
if path not in self.title_map:
# Assign first top-level headline to page
if not hasattr(page, "typeset") and int(level) == 1:
page.typeset = anchors[id].typeset
page.title = re.sub(r"<[^>]+>", "", title)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Flatten a tree of anchors
def _flatten(items):
anchors = {}
for item in items:
anchors[item.id] = item
# Recursively expand children
if item.children:
anchors.update(_flatten(item.children))
# Return anchors
return anchors

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"version":3,"sources":["src/templates/assets/stylesheets/palette/_scheme.scss","../../../../src/templates/assets/stylesheets/palette.scss","src/templates/assets/stylesheets/palette/_accent.scss","src/templates/assets/stylesheets/palette/_primary.scss","src/templates/assets/stylesheets/utilities/_break.scss"],"names":[],"mappings":"AA2BA,cAGE,6BAME,sDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CACA,mDAAA,CACA,6DAAA,CACA,+DAAA,CACA,gEAAA,CAGA,mDAAA,CACA,gDAAA,CAGA,0BAAA,CACA,mCAAA,CAGA,iCAAA,CACA,kCAAA,CACA,mCAAA,CACA,mCAAA,CACA,kCAAA,CACA,iCAAA,CACA,+CAAA,CACA,6DAAA,CACA,gEAAA,CACA,4DAAA,CACA,4DAAA,CACA,6DAAA,CAGA,6CAAA,CAGA,+CAAA,CAGA,uDAAA,CACA,6DAAA,CACA,2DAAA,CAGA,iCAAA,CAGA,yDAAA,CACA,iEAAA,CAGA,mDAAA,CACA,mDAAA,CAGA,qDAAA,CACA,uDAAA,CAGA,8DAAA,CAKA,8DAAA,CAKA,0DAAA,CAvEA,iBCeF,CD6DE,kHAEE,YC3DJ,CDkFE,yDACE,4BChFJ,CD+EE,2DACE,4BC7EJ,CD4EE,gEACE,4BC1EJ,CDyEE,2DACE,4BCvEJ,CDsEE,yDACE,4BCpEJ,CDmEE,0DACE,4BCjEJ,CDgEE,gEACE,4BC9DJ,CD6DE,0DACE,4BC3DJ,CD0DE,2OACE,4BC/CJ,CDsDA,+FAGE,iCCpDF,CACF,CC/CE,2BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD2CN,CCrDE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDkDN,CC5DE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDyDN,CCnEE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDgEN,CC1EE,8BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDuEN,CCjFE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD8EN,CCxFE,kCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDqFN,CC/FE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD4FN,CCtGE,4BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDmGN,CC7GE,6BACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCD0GN,CCpHE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDiHN,CC3HE,4BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCD2HN,CClIE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDkIN,CCzIE,6BACE,yBAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDyIN,CChJE,8BACE,4BAAA,CACA,2CAAA,CAIE,8BAAA,CACA,qCDgJN,CCvJE,mCACE,4BAAA,CACA,2CAAA,CAOE,yBAAA,CACA,qCDoJN,CEzJE,4BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsJN,CEjKE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8JN,CEzKE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsKN,CEjLE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8KN,CEzLE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsLN,CEjME,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8LN,CEzME,mCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsMN,CEjNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8MN,CEzNE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsNN,CEjOE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8NN,CEzOE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsON,CEjPE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFiPN,CEzPE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFyPN,CEjQE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFiQN,CEzQE,+BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAIE,+BAAA,CACA,sCFyQN,CEjRE,oCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCF8QN,CEzRE,8BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCFsRN,CEjSE,6BACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BF0RN,CE1SE,kCACE,6BAAA,CACA,oCAAA,CACA,mCAAA,CAOE,0BAAA,CACA,sCAAA,CAKA,4BFmSN,CEpRE,sEACE,4BFuRJ,CExRE,+DACE,4BF2RJ,CE5RE,iEACE,4BF+RJ,CEhSE,gEACE,4BFmSJ,CEpSE,iEACE,4BFuSJ,CE9RA,8BACE,mDAAA,CACA,4DAAA,CACA,0DAAA,CACA,oDAAA,CACA,2DAAA,CAGA,4BF+RF,CE5RE,yCACE,+BF8RJ,CE3RI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCF+RN,CG3MI,mCD1EA,+CACE,8CFwRJ,CErRI,qDACE,8CFuRN,CElRE,iEACE,mCFoRJ,CACF,CGtNI,sCDvDA,uCACE,oCFgRJ,CACF,CEvQA,8BACE,kDAAA,CACA,4DAAA,CACA,wDAAA,CACA,oDAAA,CACA,6DAAA,CAGA,4BFwQF,CErQE,yCACE,+BFuQJ,CEpQI,kDAEE,0CAAA,CACA,sCAAA,CAFA,mCFwQN,CEjQE,yCACE,6CFmQJ,CG5NI,0CDhCA,8CACE,gDF+PJ,CACF,CGjOI,0CDvBA,iFACE,6CF2PJ,CACF,CGzPI,sCDKA,uCACE,6CFuPJ,CACF","file":"palette.css"}

View File

@@ -27,12 +27,17 @@
{% if page.next_page %}
<link rel="next" href="{{ page.next_page.url | url }}">
{% endif %}
{% if config.extra.alternate is iterable %}
{% for alt in config.extra.alternate %}
<link rel="alternate" href="{{ alt.link | url }}" hreflang="{{ alt.lang | d(lang.t('language')) }}">
{% endfor %}
{% endif %}
{% if "rss" in config.plugins %}
<link rel="alternate" type="application/rss+xml" title="{{ lang.t('rss.created') }}" href="{{ 'feed_rss_created.xml' | url }}">
<link rel="alternate" type="application/rss+xml" title="{{ lang.t('rss.updated') }}" href="{{ 'feed_rss_updated.xml' | url }}">
{% endif %}
<link rel="icon" href="{{ config.theme.favicon | url }}">
<meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.6.23">
<meta name="generator" content="mkdocs-{{ mkdocs_version }}, mkdocs-material-9.7.0">
{% endblock %}
{% block htmltitle %}
{% if page.meta and page.meta.title %}
@@ -44,10 +49,10 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.d8f969c1.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.06af60db.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.css' | url }}">
{% endif %}
{% include "partials/icons.html" %}
{% endblock %}
@@ -75,11 +80,6 @@
{% block analytics %}
{% include "partials/integrations/analytics.html" %}
{% endblock %}
{% if page.meta and page.meta.meta %}
{% for tag in page.meta.meta %}
<meta {% for key, value in tag | items %} {{ key }}="{{value}}" {% endfor %}>
{% endfor %}
{% endif %}
{% block extrahead %}{% endblock %}
</head>
{% set direction = config.theme.direction or lang.t("direction") %}
@@ -179,6 +179,9 @@
{% endblock %}
{% block container %}
<div class="md-content" data-md-component="content">
{% if "navigation.path" in features %}
{% include "partials/path.html" %}
{% endif %}
<article class="md-content__inner md-typeset">
{% block content %}
{% include "partials/content.html" %}
@@ -215,6 +218,7 @@
{% endif %}
{% block config %}
{% set _ = namespace() %}
{% set _.annotate = config.extra.annotate %}
{% set _.tags = config.extra.tags %}
{%- if config.extra.version -%}
{%- set mike = config.plugins.mike -%}
@@ -238,14 +242,15 @@
"search.result.term.missing": lang.t("search.result.term.missing"),
"select.version": lang.t("select.version")
},
"search": "assets/javascripts/workers/search.973d3a69.min.js" | url,
"search": "assets/javascripts/workers/search.js" | url,
"annotate": _.annotate or none,
"tags": _.tags or none,
"version": _.version or none
} | tojson -}}
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.f55a23d4.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.js' | url }}"></script>
{% for script in config.extra_javascript %}
{{ script | script_tag }}
{% endfor %}

View File

@@ -8,6 +8,7 @@
"action.view": "Quellcode der Seite anzeigen",
"announce.dismiss": "Nicht mehr anzeigen",
"blog.archive": "Archiv",
"blog.authors": "Autoren",
"blog.categories": "Kategorien",
"blog.categories.in": "in",
"blog.continue": "Weiterlesen",

View File

@@ -9,6 +9,7 @@
"action.view": "View source of this page",
"announce.dismiss": "Don't show this again",
"blog.archive": "Archive",
"blog.authors": "Authors",
"blog.categories": "Categories",
"blog.categories.in": "in",
"blog.continue": "Continue reading",

View File

@@ -10,18 +10,30 @@
<span class="{{ class }}"></span>
{% endif %}
{% endmacro %}
{% macro render_title(nav_item) %}
{% if nav_item.typeset %}
<span class="md-typeset">
{{ nav_item.typeset.title }}
</span>
{% else %}
{{ nav_item.title }}
{% endif %}
{% endmacro %}
{% macro render_content(nav_item, ref) %}
{% set ref = ref or nav_item %}
{% if nav_item.meta and nav_item.meta.icon %}
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
{% endif %}
<span class="md-ellipsis">
{{ ref.title }}
{{ render_title(ref) }}
{% if nav_item.meta and nav_item.meta.subtitle %}
<br>
<small>{{ nav_item.meta.subtitle }}</small>
{% endif %}
</span>
{% if nav_item.meta and nav_item.encrypted %}
{{ render_status(nav_item, "encrypted") }}
{% endif %}
{% if nav_item.meta and nav_item.meta.status %}
{{ render_status(nav_item, nav_item.meta.status) }}
{% endif %}
@@ -113,7 +125,7 @@
<nav class="md-nav" data-md-level="{{ level }}" aria-labelledby="{{ path }}_label" aria-expanded="{{ nav_item.active | tojson }}">
<label class="md-nav__title" for="{{ path }}">
<span class="md-nav__icon md-icon"></span>
{{ nav_item.title }}
{{ render_title(nav_item) }}
</label>
<ul class="md-nav__list" data-md-scrollfix>
{% for item in nav_item.children %}

View File

@@ -0,0 +1,29 @@
{#-
This file was automatically generated - do not edit
-#}
{% macro render_content(nav_item) %}
<span class="md-ellipsis">
{{ nav_item.title }}
</span>
{% endmacro %}
{% macro render(nav_item, ref) %}
{% set ref = ref or nav_item %}
{% if nav_item.children %}
{% set first = nav_item.children | first %}
{% if first.children %}
{{ render(first, ref) }}
{% else %}
<li class="md-path__item">
<a href="{{ first.url | url }}" class="md-path__link">
{{ render_content(ref) }}
</a>
</li>
{% endif %}
{% else %}
<li class="md-path__item">
<a href="{{ nav_item.url | url }}" class="md-path__link">
{{ render_content(ref) }}
</a>
</li>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,23 @@
{#-
This file was automatically generated - do not edit
-#}
{% import "partials/path-item.html" as item with context %}
{% if page.meta and page.meta.hide %}
{% set hidden = "hidden" if "path" in page.meta.hide %}
{% endif %}
{% set depth = page.ancestors | length %}
{% if nav.homepage %}
{% set depth = depth + 1 %}
{% endif %}
{% if depth > 1 %}
<nav class="md-path" aria-label="{{ lang.t('nav') }}" {{ hidden }}>
<ol class="md-path__list">
{% if nav.homepage %}
{{ item.render(nav.homepage) }}
{% endif %}
{% for nav_item in page.ancestors | reverse %}
{{ item.render(nav_item) }}
{% endfor %}
</ol>
</nav>
{% endif %}

View File

@@ -41,6 +41,9 @@
{% endif %}
</li>
{% endif %}
{% if post.config.pin %}
<span class="md-pin"></span>
{% endif %}
</ul>
{% if post.config.draft %}
<span class="md-draft">
@@ -58,5 +61,8 @@
</a>
</nav>
{% endif %}
{% if post.config.pin %}
<hr>
{% endif %}
</div>
</article>

View File

@@ -8,6 +8,9 @@
<nav class="md-tags" {{ hidden }}>
{% for tag in tags %}
{% set class = "md-tag" %}
{% if tag.hidden %}
{% set class = class ~ " md-tag-shadow" %}
{% endif %}
{% if config.extra.tags %}
{% set class = class ~ " md-tag-icon" %}
{% if tag.name in config.extra.tags %}

View File

@@ -4,7 +4,13 @@
<li class="md-nav__item">
<a href="{{ toc_item.url }}" class="md-nav__link">
<span class="md-ellipsis">
{{ toc_item.title }}
{% if toc_item.typeset %}
<span class="md-typeset">
{{ toc_item.typeset.title }}
</span>
{% else %}
{{ toc_item.title }}
{% endif %}
</span>
</a>
{% if toc_item.children %}

View File

@@ -1,6 +1,7 @@
{#-
This file was automatically generated - do not edit
-#}
+
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
{% set icon = config.theme.icon.top or "material/arrow-up" %}
{% include ".icons/" ~ icon ~ ".svg" %}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "mkdocs-material",
"version": "9.6.23",
"version": "9.7.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "mkdocs-material",
"version": "9.6.23",
"version": "9.7.0",
"license": "MIT",
"dependencies": {
"clipboard": "^2.0.11",

View File

@@ -1,6 +1,6 @@
{
"name": "mkdocs-material",
"version": "9.6.23",
"version": "9.7.0",
"description": "Documentation that simply works",
"keywords": [
"mkdocs",

View File

@@ -74,6 +74,8 @@ Changelog = "https://squidfunk.github.io/mkdocs-material/changelog/"
Issues = "https://github.com/squidfunk/mkdocs-material/issues"
[project.entry-points."mkdocs.plugins"]
# The commented-out plugins are deprecated, as they turned out to be dead ends.
# If you fork Material for MkDocs, just uncomment these lines to re-enable them.
"material/blog" = "material.plugins.blog.plugin:BlogPlugin"
"material/group" = "material.plugins.group.plugin:GroupPlugin"
"material/info" = "material.plugins.info.plugin:InfoPlugin"
@@ -81,9 +83,11 @@ Issues = "https://github.com/squidfunk/mkdocs-material/issues"
"material/offline" = "material.plugins.offline.plugin:OfflinePlugin"
"material/optimize" = "material.plugins.optimize.plugin:OptimizePlugin"
"material/privacy" = "material.plugins.privacy.plugin:PrivacyPlugin"
# "material/projects" = "material.plugins.projects.plugin:ProjectsPlugin"
"material/search" = "material.plugins.search.plugin:SearchPlugin"
"material/social" = "material.plugins.social.plugin:SocialPlugin"
"material/tags" = "material.plugins.tags.plugin:TagsPlugin"
# "material/typeset" = "material.plugins.typeset.plugin:TypesetPlugin"
[project.entry-points."mkdocs.themes"]
material = "material.templates"

View File

@@ -19,17 +19,17 @@
# IN THE SOFTWARE.
# Requirements for core
jinja2~=3.1
markdown~=3.2
mkdocs~=1.6
mkdocs-material-extensions~=1.3
pygments~=2.16
pymdown-extensions~=10.2
jinja2>=3.1
markdown>=3.2
mkdocs>=1.6
mkdocs-material-extensions>=1.3
pygments>=2.16
pymdown-extensions>=10.2
# Requirements for plugins
babel~=2.10
colorama~=0.4
paginate~=0.5
backrefs~=5.7.post1
requests~=2.26
babel>=2.10
colorama>=0.4
paginate>=0.5
backrefs>=5.7.post1
requests>=2.26

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,320 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import multiprocessing
import os
import posixpath
from collections.abc import Callable
from concurrent.futures import Future, as_completed
from concurrent.futures.process import ProcessPoolExecutor
from logging import Logger
from material.plugins.projects.config import ProjectsConfig
from material.plugins.projects.structure import Project, ProjectJob
from mkdocs.commands.build import build
from mkdocs.config.base import ConfigErrors, ConfigWarnings
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import Abort
from mkdocs.livereload import LiveReloadServer
from urllib.parse import urlparse
from .log import (
get_log_for,
get_log_formatter,
get_log_handler,
get_log_level_for
)
from .watcher import ProjectsWatcher
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects builder
class ProjectsBuilder:
# Set of projects
projects: set[Project] = set()
# Projects watcher
watcher: ProjectsWatcher | None = None
# Initialize projects builder - compared to our other concurrent plugins,
# this plugin is forced to use a process pool in order to guarantee proper
# isolation, as MkDocs itself is not thread-safe. We also need to recreate
# the process pool on every build, or CTRL-C is broken
def __init__(self, config: MkDocsConfig, plugin: ProjectsConfig):
# Initialize root project
self.root = Project(config.config_file_path, plugin)
self.root.config = config
# Initialize process pool
self.pool = ProcessPoolExecutor
self.pool_jobs: dict[str, Future] = {}
# Build projects
def build(self, serve: bool = False, dirty: bool = False):
self.pool = ProcessPoolExecutor(
self.root.plugin.concurrency,
mp_context = multiprocessing.get_context("spawn")
)
# Determine projects in topological order and prepare for building
built: list[str] = []
queue = [*self.root.jobs()]
for job in queue:
_setup(job.project, self.root, serve)
if serve:
self._link(job.project)
# Schedule projects for building
for job in reversed(queue):
if self._schedule(job, serve, dirty):
queue.remove(job)
# Build loop - iteratively build more projects while there are still
# projects to be built, sticking to the topological order.
while len(built) < len(self.pool_jobs):
for future in as_completed(self.pool_jobs.values()):
slug, errors, warnings = future.result()
if slug in built:
continue
# Mark project as built
built.append(slug)
# Schedule projects for building
for job in reversed(queue):
if self._schedule(job, serve, dirty):
queue.remove(job)
# Print errors and warnings
for project in self.projects:
if project.slug == slug:
_print(get_log_for(project), errors, warnings)
break
# Shutdown process pool
self.pool.shutdown()
if self.watcher:
# Update watched paths
for project in self.projects:
if project.slug not in built:
self.pool_jobs.pop(project.slug, None)
self.watcher.unwatch(project)
else:
self.watcher.watch(project, self.taint)
# Taint a project to schedule it for building
def taint(self, project: Project):
self.pool_jobs.pop(project.slug, None)
# Watch and serve projects
def serve(self, server: LiveReloadServer, is_dirty: bool = False):
self.watcher = ProjectsWatcher(server)
self.watcher.watch(self.root, self.taint)
for project in self.projects:
self.watcher.watch(project, self.taint)
# -------------------------------------------------------------------------
# Create symlink for project if we're serving the site
def _link(self, project: Project):
# Compute path for slug from current project - normalize path,
# as paths computed from slugs or site URLs use forward slashes
path = project.path(self.root)
path = os.path.normpath(path)
# Create symbolic link, if we haven't already
path = os.path.join(self.root.config.site_dir, path)
if not os.path.islink(path):
# Ensure link target exists
target = os.path.realpath(os.path.dirname(path))
if not os.path.exists(target):
os.makedirs(target, exist_ok = True)
# Create symbolic link
os.makedirs(os.path.dirname(path), exist_ok = True)
os.symlink(project.config.site_dir, path)
# Schedule project for building - spawn concurrent job to build the project
# and add a future to the jobs dictionary to link build results to projects
def _schedule(self, job: ProjectJob, serve: bool, dirty: bool):
self.projects.add(job.project)
# Exit early, if project doesn't need to be re built
if job.project.slug in self.pool_jobs:
return True
# Check if dependencies have been built already, and if so, remove
# them from the list of dependencies. If a dependency has failed to
# build, we'll raise an exception, which will be caught by the main
# process, and the entire build will be aborted.
for dependency in [*job.dependencies]:
future = self.pool_jobs[dependency.slug]
if future.running():
continue
# If the dependency has failed to build, we'll raise an exception
# to abort the entire build, as we can't build the project itself
# without the dependency. This will be caught by the main process.
# Otherwise, we'll remove the dependency from the list.
if future.exception():
raise future.exception()
elif future.done():
job.dependencies.remove(dependency)
# If all dependencies of the project have been built, we can build
# the project itself by spawning a concurrent job
if not job.dependencies:
self.pool_jobs[job.project.slug] = self.pool.submit(
_build, job.project, serve, dirty,
get_log_level_for(job.project)
)
# Return whether the project has been scheduled
return not job.dependencies
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Setup project by preparing it for building
def _setup(project: Project, root: Project, serve: bool):
assert project.slug != "."
# Retrieve configuration of top-level project and transform project
transform = root.plugin.projects_config_transform
if isinstance(transform, Callable):
transform(project, root)
# If the top-level project defines a site URL, we need to make sure that the
# site URL of the project is set as well, setting it to the path we derive
# from the slug. This allows to define the URL independent of the entire
# project's directory structure. If the top-level project doesn't define a
# site URL, it might be the case that the author is building a consolidated
# project of several nested projects that are independent, but which should
# be bundled together for distribution. As this is a case that is quite
# common, we're not raising a warning or error.
path = project.path(root)
if root.config.site_url:
# If the project doesn't have a site URL, compute it from the site URL
# of the top-level project and the path derived from the slug
if not project.config.site_url:
project.config.site_url = posixpath.join(
root.config.site_url,
path
)
# If we're serving the site, replace the project's host name with the
# dev server address, so we can serve nested projects as well
if serve:
url = urlparse(project.config.site_url)
url = url._replace(
scheme = "http",
netloc = str(root.config.dev_addr)
)
# Update site URL with dev server address
project.config.site_url = url.geturl()
# If we're building the site, the project's output must be written to the
# site directory of the top-level project, so we can serve it from there
if not serve:
project.config.site_dir = os.path.join(
root.config.site_dir,
os.path.normpath(path)
)
# If we're serving the site, we must fall back to symbolic links, as MkDocs
# will empty the entire site directory every time it performs a build
else:
project.config.site_dir = os.path.join(
os.path.dirname(project.config.config_file_path),
project.config.site_dir
)
# Build project - note that regardless of whether MkDocs was started in build
# or serve mode, projects must always be built, as they're served by the root
def _build(project: Project, serve: bool, dirty: bool, level = logging.WARN):
config = project.config
# Change working directory to project root - this is necessary, or relative
# paths used in extensions and plugins will be resolved incorrectly
os.chdir(os.path.dirname(config.config_file_path))
# Validate configuration
errors, warnings = config.validate()
if not errors:
# Retrieve and configure MkDocs' logger
log = logging.getLogger("mkdocs")
log.setLevel(level)
# Hack: there seems to be an inconsistency between operating systems,
# and it's yet unclear where this is coming from - on macOS, the MkDocs
# default logger has no designated handler registered, but on Linux it
# does. If there's no handler, we need to create one. If there is, we
# must only set the formatter, as otherwise we'll end up with the same
# message printed on two log handlers - see https://t.ly/q7UEq
handler = get_log_handler(project)
if not log.hasHandlers():
log.addHandler(handler)
else:
for handler in log.handlers:
handler.setFormatter(get_log_formatter(project))
# Build project and dispatch startup and shutdown plugin events - note
# that we must pass the correct command to the event handler, but run
# the build command anyway, because otherwise some plugins will not
# run in serve mode.
command = "serve" if serve else "build"
config.plugins.run_event("startup", command = command, dirty = dirty)
try:
build(config, dirty = dirty)
finally:
config.plugins.run_event("shutdown")
log.removeHandler(handler)
# Return slug, errors and warnings
return project.slug, errors, warnings
# Print errors and warnings resulting from building a project
def _print(log: Logger, errors: ConfigErrors, warnings: ConfigWarnings):
# Print warnings
for value, message in warnings:
log.warning(f"Config value '{value}': {message}")
# Print errors
for value, message in errors:
log.error(f"Config value '{value}': {message}")
# Abort if there were errors
if errors:
raise Abort(f"Aborted with {len(errors)} configuration errors")

View File

@@ -0,0 +1,100 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
from click import style
from logging import Filter
from material.plugins.projects.structure import Project
from mkdocs.__main__ import ColorFormatter
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Dirty build warning filter
class ProjectsFilter(Filter):
# Filter log messages
def filter(self, record):
message = record.getMessage()
return not message.startswith("A 'dirty' build")
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Retrieve logger for project
def get_log_for(project: Project):
log = logging.getLogger("".join(["mkdocs.material.projects", project.slug]))
# Ensure logger does not propagate messags to parent logger, or messages
# will be printed multiple times, and attach handler with color formatter
log.propagate = False
if not log.hasHandlers():
log.addHandler(get_log_handler(project))
log.setLevel(get_log_level_for(project))
# Return logger
return log
# Retrieve log level for project
def get_log_level_for(project: Project):
level = logging.INFO
# Determine log level as set in MkDocs - if the build is started with the
# `--quiet` flag, the log level is set to `ERROR` to suppress all messages,
# except for errors. If it's started with `--verbose`, MkDocs sets the log
# level to `DEBUG`, the most verbose of all log levels.
log = logging.getLogger("mkdocs")
for handler in log.handlers:
level = handler.level
break
# Determine if MkDocs was invoked with the `--quiet` flag and the log level
# as configured in the plugin configuration. When `--quiet` is set, or the
# projects plugin configuration disables logging, ignore the configured log
# level and set it to `ERROR` to suppress all messages.
quiet = level == logging.ERROR
level = project.plugin.log_level.upper()
if quiet or not project.plugin.log:
level = logging.ERROR
# Retun log level
return level
# -----------------------------------------------------------------------------
# Retrieve log handler for project
def get_log_handler(project: Project):
handler = logging.StreamHandler()
handler.setFormatter(get_log_formatter(project))
# Add filter to suppress dirty build warning, or we'll get as many of those
# as projects are built - one warning is surely enough, KTHXBYE
handler.addFilter(ProjectsFilter())
return handler
# Retrieve log formatter for project
def get_log_formatter(project: Project):
prefix = style(f"project://{project.slug}", underline = True)
return ColorFormatter(f"[{prefix}] %(message)s")

View File

@@ -0,0 +1,98 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import os
from collections.abc import Callable
from material.plugins.projects.structure import Project
from mkdocs.livereload import LiveReloadServer
from .handler import ProjectChanged, ProjectAddedOrRemoved
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects watcher
class ProjectsWatcher:
# Set of watched paths
watched: set[str] = set()
# Initialize projects watcher
def __init__(self, server: LiveReloadServer):
self.server = server
# Watch project and invoke function on change
def watch(self, project: Project, fn: Callable):
self._on_project_changed(project, fn)
self._on_project_added_or_removed(project, fn)
# Stop watching project
def unwatch(self, project: Project):
# Traverse all watched paths
root = os.path.dirname(project.config.config_file_path)
for path in [*self.watched]:
# Remove path from watched paths
if path.startswith(root):
self.server.unwatch(path)
self.watched.remove(path)
# -------------------------------------------------------------------------
# Register event handler for project changes
def _on_project_changed(self, project: Project, fn: Callable):
# Resolve project root and docs directory
root = os.path.dirname(project.config.config_file_path)
docs = os.path.join(root, project.config.docs_dir)
# Collect all paths to watch
paths = set([docs, project.config.config_file_path])
for path in project.config.watch:
paths.add(os.path.join(root, path))
# Register event handler for unwatched paths
handler = ProjectChanged(project, fn)
for path in paths - self.watched:
self.server.watch(path)
# Add path and its descendents to watched paths
self.server.observer.schedule(handler, path, recursive = True)
self.watched.add(path)
# Register event handler for project additions and removals
def _on_project_added_or_removed(self, project: Project, fn: Callable):
# Resolve project root and path to projects directory
root = os.path.dirname(project.config.config_file_path)
path = os.path.join(root, project.plugin.projects_dir)
# Register event handler for unwatched paths
handler = ProjectAddedOrRemoved(project, fn)
if path not in self.watched and os.path.isdir(path):
# Add path to watched paths
self.server.observer.schedule(handler, path)
self.watched.add(path)

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import os
from collections.abc import Callable
from material.plugins.projects.structure import Project
from watchdog.events import FileSystemEvent, FileSystemEventHandler
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Project changed
class ProjectChanged(FileSystemEventHandler):
# Initialize event handler
def __init__(self, project: Project, handler: Callable):
self.project = project
self.handler = handler
# Handle file event
def on_any_event(self, event: FileSystemEvent):
self._handle(event)
# -------------------------------------------------------------------------
# Invoke file event handler
def _handle(self, event: FileSystemEvent):
config = self.project.config
# Resolve path to docs directory
base = os.path.dirname(config.config_file_path)
docs = os.path.join(base, config.docs_dir)
# Resolve project root and path to changed file
root = os.path.relpath(base)
path = os.path.relpath(event.src_path, root)
# Check if mkdocs.yml or docs directory was deleted
if event.src_path in [docs, config.config_file_path]:
if event.event_type == "deleted":
return
# Invoke handler and print message that we're scheduling a build
log.info(f"Schedule build due to '{path}' in '{root}'")
self.handler(self.project)
# -----------------------------------------------------------------------------
# Project added or removed
class ProjectAddedOrRemoved(FileSystemEventHandler):
# Initialize event handler
def __init__(self, project: Project, handler: Callable):
self.project = project
self.handler = handler
# Handle file creation event
def on_created(self, event: FileSystemEvent):
self._handle(event)
# Handle file deletion event
def on_deleted(self, event: FileSystemEvent):
self._handle(event)
# ------------------------------------------------------------------------
# Invoke file event handler
def _handle(self, event: FileSystemEvent):
config = self.project.config
# Touch mkdocs.yml to trigger rebuild
if os.path.isfile(config.config_file_path):
os.utime(config.config_file_path, None)
# Invoke handler
self.handler(self.project)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs")

View File

@@ -0,0 +1,64 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import os
from collections.abc import Callable
from mkdocs.config.config_options import Choice, Optional, Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Options
# -----------------------------------------------------------------------------
# Options for log level
LogLevel = (
"error",
"warn",
"info",
"debug"
)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects plugin configuration
class ProjectsConfig(Config):
enabled = Type(bool, default = True)
concurrency = Type(int, default = max(1, os.cpu_count() - 1))
# Settings for caching
cache = Type(bool, default = True)
cache_dir = Type(str, default = ".cache/plugin/projects")
# Settings for logging
log = Type(bool, default = True)
log_level = Choice(LogLevel, default = "info")
# Settings for projects
projects = Type(bool, default = True)
projects_dir = Type(str, default = "projects")
projects_config_files = Type(str, default = "*/mkdocs.yml")
projects_config_transform = Optional(Type(Callable))
projects_root_dir = Optional(Type(str))
# Settings for hoisting
hoisting = Type(bool, default = True)

View File

@@ -0,0 +1,292 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import json
import os
import posixpath
from jinja2 import pass_context
from jinja2.runtime import Context
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs import utils
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure import StructureItem
from mkdocs.structure.files import Files
from mkdocs.structure.nav import Link, Section
from mkdocs.utils import get_theme_dir
from urllib.parse import ParseResult as URL, urlparse
from .builder import ProjectsBuilder
from .config import ProjectsConfig
from .structure import Project, ProjectLink
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Projects plugin
class ProjectsPlugin(BasePlugin[ProjectsConfig]):
# Projects builder
builder: ProjectsBuilder = None
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
self.is_dirty = False
# Hack: Since we're building in topological order, we cannot let MkDocs
# clean the directory, because it means that nested projects are always
# deleted before a project is built. We also don't need to restore this
# functionality, because it's only used once in the process.
utils.clean_directory = lambda _: _
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
self.is_dirty = dirty
# Resolve projects compared to our other concurrent plugins, this plugin
# is forced to use a process pool in order to guarantee proper isolation, as
# MkDocs itself is not thread-safe. Additionally, all project configurations
# are resolved and written to the cache (if enabled), as it's sufficient to
# resolve them once on the top-level before projects are built. We might
# need adjacent project configurations for interlinking projects.
def on_config(self, config):
if not self.config.enabled:
return
# Skip if projects should not be built - we can only exit here if we're
# at the top-level, but not when building a nested project
root = self.config.projects_root_dir is None
if root and not self.config.projects:
return
# Set projects root directory to the top-level project
if not self.config.projects_root_dir:
self.config.projects_root_dir = os.path.dirname(
config.config_file_path
)
# Initialize manifest
self.manifest: dict[str, str] = {}
self.manifest_file = os.path.join(
self.config.projects_root_dir,
self.config.cache_dir,
"manifest.json"
)
# Load manifest if it exists and the cache should be used
if os.path.isfile(self.manifest_file):
try:
with open(self.manifest_file) as f:
self.manifest = json.load(f)
except:
pass
# Building the top-level project, we must resolve and load all project
# configurations, as we need all information upfront to build them in
# the correct order, and to resolve links between projects. Furthermore,
# the author might influence a project's path by setting the site URL.
if root:
if not self.builder:
self.builder = ProjectsBuilder(config, self.config)
# @todo: detach project resolution from build
self.manifest = { ".": os.path.relpath(config.config_file_path) }
for job in self.builder.root.jobs():
path = os.path.relpath(job.project.config.config_file_path)
self.manifest[job.project.slug] = path
# Save manifest, a we need it in nested projects
os.makedirs(os.path.dirname(self.manifest_file), exist_ok = True)
with open(self.manifest_file, "w") as f:
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
# Schedule projects for building - the general case is that all projects
# can be considered independent of each other, so we build them in parallel
def on_pre_build(self, config):
if not self.config.enabled:
return
# Skip if projects should not be built or we're not at the top-level
if not self.config.projects or not self.builder:
return
# Build projects
self.builder.build(self.is_serve, self.is_dirty)
# Patch environment to allow for hoisting of media files provided by the
# theme itself, which will also work for other themes, not only this one
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Skip if projects should not be built or we're at the top-level
if not self.config.projects or self.builder:
return
# If hoisting is enabled and we're building a project, remove all media
# files that are provided by the theme and hoist them to the top
if self.config.hoisting:
theme = get_theme_dir(config.theme.name)
hoist = Files([])
# Retrieve top-level project and check if the current project uses
# the same theme as the top-level project - if not, don't hoist
root = Project("mkdocs.yml", self.config)
if config.theme.name != root.config.theme["name"]:
return
# Remove all media files that are provided by the theme
for file in files.media_files():
if file.abs_src_path.startswith(theme):
files.remove(file)
hoist.append(file)
# Resolve source and target project
source: Project | None = None
target: Project | None = None
for ref, file in self.manifest.items():
base = os.path.join(self.config.projects_root_dir, file)
if file == os.path.relpath(
config.config_file_path, self.config.projects_root_dir
):
source = Project(base, self.config, ref)
if "." == ref:
target = Project(base, self.config, ref)
# Compute path for slug from source and target project
path = target.path(source)
# Fetch URL template filter from environment - the filter might
# be overridden by other plugins, so we must retrieve and wrap it
url_filter = env.filters["url"]
# Patch URL template filter to add support for correctly resolving
# media files that were hoisted to the top-level project
@pass_context
def url_filter_with_hoisting(context: Context, url: str | None):
if url and hoist.get_file_from_path(url):
return posixpath.join(path, url_filter(context, url))
else:
return url_filter(context, url)
# Register custom template filters
env.filters["url"] = url_filter_with_hoisting
# Adjust project navigation in page (run latest) - as always, allow
# other plugins to alter the navigation before we process it here
@event_priority(-100)
def on_page_context(self, context, *, page, config, nav):
if not self.config.enabled:
return
# Skip if projects should not be built
if not self.config.projects:
return
# Replace project URLs in navigation
self._replace(nav.items, config)
# Adjust project navigation in template (run latest) - as always, allow
# other plugins to alter the navigation before we process it here
@event_priority(-100)
def on_template_context(self, context, *, template_name, config):
if not self.config.enabled:
return
# Skip if projects should not be built
if not self.config.projects:
return
# Replace project URLs in navigation
self._replace(context["nav"].items, config)
# Serve projects
def on_serve(self, server, *, config, builder):
if self.config.enabled:
self.builder.serve(server, self.is_dirty)
# -------------------------------------------------------------------------
# Replace project links in the given list of navigation items
def _replace(self, items: list[StructureItem], config: MkDocsConfig):
for index, item in enumerate(items):
# Handle section
if isinstance(item, Section):
self._replace(item.children, config)
# Handle link
if isinstance(item, Link):
url = urlparse(item.url)
if url.scheme == "project":
project, url = self._resolve_project_url(url, config)
# Append file name if directory URLs are disabled
if not project.config.use_directory_urls:
url += "index.html"
# Replace link with project link
items[index] = ProjectLink(
item.title or project.config.site_name,
url
)
# Resolve project URL and slug
def _resolve_project_url(self, url: URL, config: MkDocsConfig):
# Abort if the project URL contains a path, as we first need to collect
# use cases for when, how and whether we need and want to support this
if url.path != "":
raise PluginError(
f"Couldn't resolve project URL: paths currently not supported\n"
f"Please only use 'project://{url.hostname}'"
)
# Compute slug from host name and convert to dot notation
slug = url.hostname
slug = slug if slug.startswith(".") else f".{slug}"
# Resolve source and target project
source: Project | None = None
target: Project | None = None
for ref, file in self.manifest.items():
base = os.path.join(self.config.projects_root_dir, file)
if file == os.path.relpath(
config.config_file_path, self.config.projects_root_dir
):
source = Project(base, self.config, ref)
if slug == ref:
target = Project(base, self.config, ref)
# Abort if slug doesn't match a known project
if not target:
raise PluginError(f"Couldn't find project '{slug}'")
# Return project slug and path
return target, target.path(source)

View File

@@ -0,0 +1,241 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import os
import posixpath
import re
from copy import deepcopy
from glob import iglob
from material.plugins.projects.config import ProjectsConfig
from mkdocs.structure.nav import Link
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.config.config_options import Plugins
from urllib.parse import urlparse
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Project
class Project:
# Initialize project - note that the configuration of the projects plugin
# of the enclosing project is necessary to resolve nested projects
def __init__(self, file: str, plugin: ProjectsConfig, slug = "."):
self.config, self.plugin = self._resolve(file, plugin)
# The slug should not be changed after initialization, as it's used for
# correct resolution of projects and nested projects
self.slug = slug
# Find and yield nested projects of the current project - the project's
# slug is prepended to the computed slug for a simple resolution of nested
# projects, allowing authors to use the project:// protocol for linking to
# projects from the top-level project or nested and adjacent projects
def __iter__(self):
seen: list[str] = []
# Compute project root and base directory
root = os.path.dirname(self.config.config_file_path)
base = os.path.join(root, self.plugin.projects_dir)
# Find and yield all projects - note that we need to filter for nested
# projects at this point, as we're only interested in the projects on
# the next level, not in projects inside projects as they are resolved
# recursively to preserve topological ordering. This is also why we must
# sort the list of projects by path, ordering shorted paths first which
# ensures that nested projects are resolved before their parents.
glob = os.path.join(base, self.plugin.projects_config_files)
glob = iglob(os.path.normpath(glob), recursive = True)
for file in sorted(glob, key = os.path.dirname):
path = os.path.join(os.path.dirname(file), "")
if any(path.startswith(_) for _ in seen):
continue
else:
seen.append(path)
# Extract the first level of the project's directory relative to
# the projects directory as the computed slug of the project. This
# allows authors to build projects whose mkdocs.yml files are not
# located at the project root, e.g., when using git submodules.
slug = os.path.relpath(file, base)
slug, *_ = slug.split(os.path.sep)
# Normalize slug to an internal dot notation which we convert to
# file system or URL paths when necessary. Each slug starts with
# a dot to denote that it is resolved from the top-level project,
# which also allows for resolving slugs in nested projects.
root = self.slug.rstrip(".")
slug = f"{root}.{slug}"
# Create and yield project
yield Project(file, self.plugin, slug)
# Compute project hash
def __hash__(self):
return hash(self.slug)
# Find and yield all nested projects (excluding this project) in reverse
# topological order, by performing a post-order traversal on the tree of
# projects. This function returns project jobs, which are projects with
# their immediate dependencies, to build them in the correct order.
def jobs(self):
stack = [*self]
while stack:
# Pop project from stack and get its dependencies
project = stack.pop()
dependencies = [*project]
# Add project dependencies to stack and yield job
stack.extend(dependencies)
yield ProjectJob(project, dependencies)
# Compute relative path between two projects
def path(self, that: Project):
# If both, the top-level and the current project have a site URL set,
# compute slug from the common path of both site URLs
if self.config.site_url and that.config.site_url:
source = self._path_from_config(that.config)
target = self._path_from_config(self.config)
# Edge case: the author has set a site URL that does not include a
# path, so the path of the project is equal to the top-level path.
# In this case, we need to fall back to the path computed from the
# slug - see https://t.ly/5vqMr
if target == source:
target = self._path_from_slug(self.slug)
# Otherwise, always compute the path from the slugs of both projects,
# as we want to support consolidation of unrelated projects
else:
source = self._path_from_slug(that.slug)
target = self._path_from_slug(self.slug)
# Compute path between projects, and add trailing slash
path = posixpath.relpath(target, source)
return posixpath.join(path, "")
# -------------------------------------------------------------------------
# Resolve project and plugin configuration
def _resolve(self, file: str, plugin: ProjectsConfig):
config = self._resolve_config(file)
plugin = self._resolve_plugin(config, plugin)
# Return project and plugin configuration
return config, plugin
# Resolve project configuration
def _resolve_config(self, file: str):
with open(file, encoding = "utf-8-sig") as f:
config: MkDocsConfig = MkDocsConfig(config_file_path = file)
config.load_file(f)
# Return project configuration
return config
# Resolve project plugin configuration
def _resolve_plugin(self, config: MkDocsConfig, plugin: ProjectsConfig):
# Make sure that every project has a plugin configuration set - we need
# to deep copy the configuration object, as it's mutated during parsing.
# We're using an internal method of the Plugins class to ensure that we
# always stick to the syntaxes allowed by MkDocs (list and dictionary).
plugins = Plugins._parse_configs(deepcopy(config.plugins))
for index, (key, settings) in enumerate(plugins):
if not re.match(r"^(material/)?projects$", key):
continue
# Forward these settings of the plugin configuration to the project,
# as we need to build nested projects consistently
for name in ["cache", "projects", "projects_root_dir", "hoisting"]:
settings[name] = plugin[name]
# Forward these settings only if they have not been set in the
# project configuration, as they might be overwritten by the author
for name in ["log", "log_level"]:
if not name in settings:
settings[name] = plugin[name]
# Initialize and expand the plugin configuration, and mutate the
# plugin collection to persist the patched configuration
plugin: ProjectsConfig = ProjectsConfig()
plugin.load_dict(settings)
if isinstance(config.plugins, list):
config.plugins[index] = { key: dict(plugin.items()) }
else:
config.plugins[key] = dict(plugin.items())
# Return project plugin configuration
return plugin
# If no plugin configuration was found, add the default configuration
# and call this function recursively to ensure that it's present
config.plugins.append("material/projects")
return self._resolve_plugin(config, plugin)
# -------------------------------------------------------------------------
# Compute path from given slug - split slug at dots, ignoring the first one,
# and join the segments to a path, prefixed with a dot. This is necessary
# to compute the common path correctly, so we can use the same logic for
# when the path is computed from the site URL (see below).
def _path_from_slug(self, slug: str):
_, *segments = slug.split(".")
return posixpath.join(".", *segments)
# Compute path from given project configuration - parse site URL and return
# canonicalized path. Paths always start with a dot and trailing slashes are
# always removed. This is necessary so that we can compute the common path
# correctly, since the site URL might or might not contain a trailing slash.
def _path_from_config(self, config: MkDocsConfig):
url = urlparse(config.site_url)
# Remove leading slash, if any
path = url.path
if path.startswith("/"):
path = path[1:]
# Return normalized path
path = posixpath.normpath(path) if path else path
return posixpath.join(".", path)
# -----------------------------------------------------------------------------
# Project job
class ProjectJob:
# Initialize project job
def __init__(self, project: Project, dependencies: list[Project]):
self.project = project
self.dependencies = dependencies
# -----------------------------------------------------------------------------
# Project link
class ProjectLink(Link):
# Indicate that the link points to a project
is_project = True

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.config_options import Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Typeset plugin configuration
class TypesetConfig(Config):
enabled = Type(bool, default = True)

View File

@@ -0,0 +1,123 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import re
from mkdocs.plugins import BasePlugin
from .config import TypesetConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Typeset plugin
class TypesetPlugin(BasePlugin[TypesetConfig]):
# Initialize plugin
def on_config(self, config):
if not self.config.enabled:
return
# Initialize titles
self.title_map: dict[str, str] = {}
# Extract source of page title before it's lost
def on_pre_page(self, page, *, config, files):
if not self.config.enabled:
return
# Check if page title was set in configuration
if page.title:
path = page.file.src_uri
self.title_map[path] = "config"
# Extract typeset content for headlines
def on_page_content(self, html, *, page, config, files):
if not self.config.enabled:
return
# Check if page title was set in metadata
path = page.file.src_uri
if path not in self.title_map:
if "title" in page.meta:
self.title_map[path] = "meta"
# Flatten anchors and map to headlines
anchors = _flatten(page.toc.items)
for (level, id, title) in re.findall(
r"<h(\d)[^>]+id=\"([^\"]+)[^>]*>(.*?)</h\1>",
html, flags = re.I | re.M
):
if id not in anchors:
continue
# If the author uses `data-toc-label` to override a heading (which
# doesn't support adding of HTML tags), we can abort here, since
# the headline will be rendered as-is. It's more or less a hack, so
# we should check if we can improve it in the future.
label = re.escape(anchors[id].title)
if re.search(rf"data-toc-label=['\"]{label}", page.markdown):
continue
# Remove anchor links from headlines we need to do that, or we
# end up with anchor links inside anchor links, which is invalid
# HTML5. There are two cases we need to account for here:
#
# 1. If toc.anchorlink is enabled, the entire headline is wrapped
# in an anchor link, so we unpack its contents
#
# 2. If toc.permalink is enabled, an anchor link is appended to the
# contents of the headline, so we just remove it
#
# Albeit it doesn't make much sense, both options can be used at
# the same time, so we need to account for both cases. This problem
# was first reported in https://bit.ly/456AjUm
title = re.sub(r"^<a\s+[^>]+>(.*?)</a>", r"\1", title)
title = re.sub(r"<a\s+[^>]+>[^<]+?</a>$", "", title)
# Remove author-provided ids - see https://bit.ly/3ngiZea
title = re.sub(r"id=\"?[^\">]+\"?", "", title)
# Assign headline content to anchor
anchors[id].typeset = { "title": title }
if path not in self.title_map:
# Assign first top-level headline to page
if not hasattr(page, "typeset") and int(level) == 1:
page.typeset = anchors[id].typeset
page.title = re.sub(r"<[^>]+>", "", title)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Flatten a tree of anchors
def _flatten(items):
anchors = {}
for item in items:
anchors[item.id] = item
# Recursively expand children
if item.children:
anchors.update(_flatten(item.children))
# Return anchors
return anchors

View File

@@ -21,31 +21,22 @@
////
// ----------------------------------------------------------------------------
// Rules
// Variables: breakpoints
// ----------------------------------------------------------------------------
// Content area
.md-content {
// Content wrapper
&__inner {
padding: px2rem(104px) 0;
margin-bottom: 0;
// Remove unnecessary spacing
&::before {
display: none;
}
}
// Section header
header {
display: block;
transition: opacity 750ms;
// Section header is hidden
.js &[hidden] {
opacity: 0;
}
}
}
// Device-specific breakpoints
$break-devices: (
mobile: (
portrait: px2em(220px) px2em(479.75px),
landscape: px2em(480px) px2em(719.75px)
),
tablet: (
portrait: px2em(720px) px2em(959.75px),
landscape: px2em(960px) px2em(1219.75px)
),
screen: (
small: px2em(1220px) px2em(1599.75px),
medium: px2em(1600px) px2em(1999.75px),
large: px2em(2000px)
)
);

View File

@@ -1,323 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Keyframes
// ----------------------------------------------------------------------------
// Pumping heart animation
@keyframes heart {
0%,
40%,
80%,
100% {
transform: scale(1);
}
20%,
60% {
transform: scale(1.15);
}
}
// New animation
@keyframes new {
0%,
100% {
transform: scale(1) rotate(0deg);
}
50% {
transform: scale(1.15) rotate(10deg);
}
}
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Scoped in typesetted content to match specificity of regular content
.md-typeset {
// Twitter icon
.twitter {
color: #eeeeee;
}
// Mastodon icon - it's not the exact brand color, because that doesn't work
// well on dark backgrounds, so we lightened it up a bit.
.mastodon {
color: #897ff8;
}
// Bluesky icon
.bluesky {
color: #0285ff;
}
// Insiders video
.mdx-video {
width: auto;
// Insiders video container
&__inner {
position: relative;
width: 100%;
height: 0;
padding-bottom: 56.138%;
}
// Insiders video iframe
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
border: none;
}
}
// Pumping heart
.mdx-heart {
animation: heart 1000ms infinite;
}
// New pulse
.mdx-pulse {
animation: new 2000ms infinite;
// Actual icon
svg {
fill: var(--md-accent-fg-color);
}
}
// Insiders color (for links, etc.)
.mdx-insiders {
color: $clr-pink-500;
}
// BETA #####################################################################
// Badge
.mdx-badge {
font-size: 0.85em;
// Badge with heart
&--heart {
--md-typeset-a-color: hsla(#{hex2hsl($clr-pink-500)}, 1);
--md-accent-fg-color: hsla(#{hex2hsl($clr-pink-a200)}, 1);
--md-accent-fg-color--transparent: hsla(#{hex2hsl($clr-pink-500)}, 0.1);
color: $clr-pink-500;
// Animate icon
.twemoji {
animation: heart 1000ms infinite;
}
}
// Badge moved to the right
&--right {
float: right;
margin-left: 0.35em;
}
// Badge icon
&__icon {
padding: px2rem(4px);
background: var(--md-accent-fg-color--transparent);
border-start-start-radius: px2rem(2px);
border-end-start-radius: px2rem(2px);
// If icon is alone, round corners
&:last-child {
border-radius: px2rem(2px);
}
}
// Badge text
&__text {
padding: px2rem(4px) px2rem(6px);
border-start-end-radius: px2rem(2px);
border-end-end-radius: px2rem(2px);
box-shadow: 0 0 0 1px inset var(--md-accent-fg-color--transparent);
}
}
// BETA #####################################################################
// Switch buttons
.mdx-switch button {
cursor: pointer;
transition: opacity 250ms;
// Button on focus/hover
&:is(:focus, :hover) {
opacity: 0.75;
}
// Code block
> code {
display: block;
color: var(--md-primary-bg-color);
background-color: var(--md-primary-fg-color);
}
}
// Two-column layout
.mdx-columns {
// Column
ol,
ul {
columns: 2;
// [mobile portrait -]: Reset columns on mobile
@include break-to-device(mobile portrait) {
columns: initial;
}
}
// Column item
li {
break-inside: avoid;
}
}
// Language list
.mdx-flags {
margin: 2em auto;
// Language list
ol {
list-style: none;
// Language list item
li {
margin-bottom: 1em;
}
}
// Language item
&__item {
display: flex;
gap: px2rem(12px);
}
// Language content
&__content {
display: flex;
flex: 1;
flex-direction: column;
// Language name
span {
display: inline-flex;
align-items: baseline;
justify-content: space-between;
}
// Language link
> span:nth-child(2) {
font-size: 80%;
}
// Language code
code {
float: right;
}
}
}
// Social card
.mdx-social {
position: relative;
height: min(#{px2rem(540px)}, 80vw);
// Social card image on hover
&:hover .mdx-social__image {
background-color: rgba(228, 228, 228, 0.05);
}
// Social card layer
&__layer {
position: absolute;
margin-top: px2rem(80px);
transition: 250ms cubic-bezier(0.7, 0, 0.3, 1);
transform-style: preserve-3d;
// Social card layer on hover
&:hover {
// Social card label
.mdx-social__label {
opacity: 1;
}
// Social card image
.mdx-social__image {
background-color: rgba(127, 127, 127, 0.99);
}
// Hide top layers
~ .mdx-social__layer {
opacity: 0;
}
}
}
// Social card image
&__image {
box-shadow:
px2rem(-5px) px2rem(5px) px2rem(10px)
rgba(0, 0, 0, 0.05);
transition: all 250ms;
transform: rotate(-40deg) skew(15deg, 15deg) scale(0.7);
// Actual image
img {
display: block;
}
}
// Social card label
&__label {
position: absolute;
display: block;
padding: px2rem(4px) px2rem(8px);
color: var(--md-default-bg-color);
background-color: var(--md-default-fg-color--light);
opacity: 0;
transition: all 250ms;
}
// Transform on hover
@for $i from 6 through 0 {
&:hover .mdx-social__layer:nth-child(#{$i}) {
transform: translateY(#{($i - 3) * -10}px);
}
}
}
}

View File

@@ -1,123 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Landing page container
.mdx-container {
padding-top: px2rem(20px);
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(0, 0%, 100%, 1)' /></svg>") no-repeat bottom,
linear-gradient(
to bottom,
var(--md-primary-fg-color),
hsla(280, 67%, 55%, 1) 99%,
var(--md-default-bg-color) 99%
);
// Adjust background for slate theme
[data-md-color-scheme="slate"] & {
background:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(230, 15%, 14%, 1)' /></svg>") no-repeat bottom,
linear-gradient(
to bottom,
var(--md-primary-fg-color),
hsla(230, 15%, 25%, 1) 99%,
var(--md-default-bg-color) 99%
);
}
}
// Landing page hero
.mdx-hero {
margin: 0 px2rem(16px);
color: var(--md-primary-bg-color);
// Hero headline
h1 {
margin-bottom: px2rem(20px);
font-weight: 700;
color: currentcolor;
// [mobile portrait -]: Larger hero headline
@include break-to-device(mobile portrait) {
font-size: px2rem(28px);
}
}
// Hero content
&__content {
padding-bottom: px2rem(120px);
}
// [tablet landscape +]: Columnar display
@include break-from-device(tablet landscape) {
display: flex;
align-items: stretch;
// Adjust spacing and set dimensions
&__content {
max-width: px2rem(380px);
padding-bottom: 14vw;
margin-top: px2rem(70px);
}
// Hero image
&__image {
order: 1;
width: px2rem(760px);
transform: translateX(#{px2rem(80px)});
}
}
// [screen +]: Columnar display and adjusted spacing
@include break-from-device(screen) {
// Hero image
&__image {
transform: translateX(#{px2rem(160px)});
}
}
// Button
.md-button {
margin-top: px2rem(10px);
margin-right: px2rem(10px);
color: var(--md-primary-bg-color);
// Button on focus/hover
&:is(:focus, :hover) {
color: var(--md-accent-bg-color);
background-color: var(--md-accent-fg-color);
border-color: var(--md-accent-fg-color);
}
// Primary button
&--primary {
color: hsla(280, 37%, 48%, 1);
background-color: var(--md-primary-bg-color);
border-color: var(--md-primary-bg-color);
}
}
}

View File

@@ -1,168 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Scoped in typesetted content to match specificity of regular content
.md-typeset {
// Icon search
.mdx-iconsearch {
position: relative;
background-color: var(--md-default-bg-color);
border-radius: px2rem(2px);
box-shadow: var(--md-shadow-z1);
transition: box-shadow 125ms;
// Icon search on focus/hover
&:is(:focus-within, :hover) {
box-shadow: var(--md-shadow-z2);
}
// Icon search input
.md-input {
background: var(--md-default-bg-color);
box-shadow: none;
// Slate theme, i.e. dark mode
[data-md-color-scheme="slate"] & {
background: var(--md-code-bg-color);
}
}
}
// Icon search result
.mdx-iconsearch-result {
max-height: 50vh;
overflow-y: auto;
// Hack: promote to own layer to reduce jitter
backface-visibility: hidden;
touch-action: pan-y;
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
scrollbar-width: thin;
// Icon search result inside tooltip
.md-tooltip & {
max-height: px2rem(205px);
}
// Webkit scrollbar
&::-webkit-scrollbar {
width: px2rem(4px);
height: px2rem(4px);
}
// Webkit scrollbar thumb
&::-webkit-scrollbar-thumb {
background-color: var(--md-default-fg-color--lighter);
// Webkit scrollbar thumb on hover
&:hover {
background-color: var(--md-accent-fg-color);
}
}
// Icon search result metadata
&__meta {
position: absolute;
top: px2rem(8px);
right: px2rem(12px);
font-size: px2rem(12.8px);
color: var(--md-default-fg-color--lighter);
// [mobile portrait -]: Hide meta
@include break-to-device(mobile portrait) {
display: none;
}
}
// Icon search result select
&__select {
position: absolute;
top: px2rem(8px);
right: px2rem(12px);
padding-block: 0.15em;
font-size: px2rem(12.8px);
color: var(--md-default-fg-color--light);
background-color: var(--md-default-fg-color--lightest);
border: none;
border-radius: px2rem(2px);
transition: color 125ms, background-color 125ms;
// Focused or hovered
&:focus,
&:hover {
color: var(--md-accent-bg-color);
background-color: var(--md-accent-fg-color);
outline: none;
}
// Adjust spacing
+ .mdx-iconsearch-result__meta {
right: px2rem(82px);
}
}
// Icon search result list
&__list {
padding: 0;
margin: 0;
// Hack: necessary because of increased specificity due to the PostCSS
// plugin which prefixes this with `[dir=...]` selectors.
margin-inline-start: 0;
list-style: none;
}
// Icon search result item
&__item {
padding: px2rem(4px) px2rem(12px);
margin: 0;
// Hack: necessary because of increased specificity due to the PostCSS
// plugin which prefixes this with `[dir=...]` selectors.
margin-inline-start: 0;
border-bottom: px2rem(1px) solid var(--md-default-fg-color--lightest);
// Omit border on last child
&:last-child {
border-bottom: none;
}
// Item content
> * {
margin-right: px2rem(12px);
}
// Set icon dimensions to fit
img {
width: px2rem(18px);
height: px2rem(18px);
// Slate theme, i.e. dark mode
[data-md-color-scheme="slate"] &[src*="squidfunk"] {
filter: invert(1); /* stylelint-disable-line */
}
}
}
}
}

View File

@@ -1,128 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Scoped in typesetted content to match specificity of regular content
.md-typeset {
// Premium sponsors
.mdx-premium {
// Paragraphs
p {
margin: 2em 0;
text-align: center;
}
// Premium sponsor image
img {
height: px2rem(65px);
}
// Premium sponsor list
p:last-child {
display: flex;
flex-wrap: wrap;
justify-content: center;
// Premium sponsor link
> a {
display: block;
flex-shrink: 0;
}
}
}
// Sponsorship
.mdx-sponsorship {
// Sponsorship list
&__list {
margin: 2em 0;
// Clearfix, because we can't use overflow: auto
&::after {
display: block;
clear: both;
content: "";
}
}
// Sponsorship item
&__item {
display: block;
float: inline-start;
width: px2rem(32px);
height: px2rem(32px);
margin: px2rem(4px);
overflow: hidden;
border-radius: 100%;
transition:
color 125ms,
transform 125ms;
transform: scale(1);
// Sponsor item on focus/hover
&:is(:focus, :hover) {
transform: scale(1.1);
// Sponsor avatar
img {
filter: grayscale(0%);
}
}
// Private sponsor
&--private {
font-size: px2rem(12px);
font-weight: 700;
line-height: px2rem(32px);
color: var(--md-default-fg-color--lighter);
text-align: center;
background: var(--md-default-fg-color--lightest);
}
// Sponsor avatar
img {
display: block;
width: 100%;
height: auto;
filter: grayscale(100%) opacity(75%);
transition: filter 125ms;
}
}
}
// Sponsorship button
.mdx-sponsorship-button {
font-weight: 400;
}
// Sponsorship count and total
.mdx-sponsorship-count,
.mdx-sponsorship-total {
font-weight: 700;
}
}

View File

@@ -1,139 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Parallax variables
:root {
--md-parallax-perspective: #{px2rem(50px)};
}
// ----------------------------------------------------------------------------
// Parallax
.mdx-parallax {
width: 100vw;
height: 100vh;
margin-top: px2rem(-48px);
overflow: hidden auto;
overscroll-behavior-y: none;
perspective: var(--md-parallax-perspective);
scroll-behavior: smooth;
// Parallax group
&__group {
position: relative;
display: block;
color: var(--md-typeset-color);
background-color: var(--md-default-bg-color);
transform-style: preserve-3d;
// First parallax group which contains the hero
&:first-child {
height: 140vh;
background-color: transparent;
// Hack: setting `contain` reduces pressure on the GPU, as it mitigates
// a bug in WebKit where the scale is not accounted for when calculating
// element size, as noted in https://bit.ly/3LRC25r. Unfortunately, we
// can't use the technique laid out in this StackOverflow answer, because
// using the bottom as perspective origin totally messes up positioning,
// but setting `contain` seems to solve the issue at hand.
contain: strict;
// Of course, how could it be otherwise, Safari doesn't properly support
// this property, so we must disable it via JavaScript. Browsers, eh?
.safari & {
contain: none;
}
// This is a hack for Firefox to avoid the results of an unfixable error
// with the parallax effect in combination with `contain: strict`.
.ff-hack & {
contain: initial !important; // stylelint-disable-line
}
// Hack: we can't use `vw` and `vh` in division, but we have to ensure a
// correct aspect ratio for the wrapper. Don't mind the magic numbers.
@for $i from 0 through 6 {
@media (min-width: #{125 + 12.5 * $i}vh) {
height: #{120 + 5 * $i}vw;
}
}
}
// Last parallax group contains footer
&:last-child {
background-color: var(--md-default-bg-color);
}
}
// Parallax layer
&__layer {
position: absolute;
top: 0;
z-index: calc(10 - var(--md-parallax-depth, 0));
width: 100vw;
height: max(120vh, 100vw);
pointer-events: none;
transform:
translateZ(
calc(
var(--md-parallax-perspective) *
var(--md-parallax-depth) * -1
)
)
scale(
calc(
var(--md-parallax-depth) + 1
)
);
transform-origin: 50vw 50vh;
}
// Parallax layer: image - we use `object-fit` and `object-position` so we
// can use responsive imagery, since CSS `srcset` and `sizes` support is meh.
&__image {
position: absolute;
z-index: -1;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: var(--md-image-position, 50%);
}
// Parallax layer: gradient
&__blend {
top: initial;
bottom: 0;
height: min(100vh, 100vw);
background-image:
linear-gradient(
to bottom,
transparent,
var(--md-default-bg-color)
);
}
}

View File

@@ -1,144 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Keyframes
// ----------------------------------------------------------------------------
// Animate scroll-down arrow
@keyframes bounce {
0% {
transform: translateY(0) translateZ(0);
}
50% {
transform: translateY(-16px) translateZ(0);
}
}
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Hero
.mdx-hero {
display: block;
height: inherit;
// Hero is hidden
.js &[hidden] > * {
opacity: 0;
transition:
transform 0ms 100ms,
opacity 100ms;
transform: translateY(16px);
}
// Hero scroll wrapper
&__scrollwrap {
position: sticky;
top: 0;
z-index: 9;
height: 100vh;
// Hack: add bottom margin, so the element stays in place, even though the
// bottom of the container is reached, as it disappears behind the gradient
margin-bottom: -100vh;
}
// Hero wrapper
&__inner {
position: absolute;
bottom: px2rem(64px);
display: block;
width: 100%;
transition:
transform 400ms cubic-bezier(0.1, 0.7, 0.1, 1),
opacity 250ms;
// [mobile -]: Adjust spacing
@include break-to-device(mobile) {
bottom: px2rem(128px);
}
}
// Hero teaser
&__teaser {
max-width: px2rem(540px);
margin: 0 px2rem(16px);
color: var(--md-primary-bg-color);
// Hack: promote to own layer to reduce jitter
backface-visibility: hidden;
// Hero headline
h1 {
margin-bottom: 0;
font-weight: 700;
color: inherit;
}
// Hack: set text shadow to improve readability of white text against the
// images but skip the buttons as text shadow is not very Materialy
:not(.md-button) {
text-shadow: 0 0 px2rem(4px) rgba(33, 29, 45, 0.8);
}
}
// Hero attribution link
& &__attribution {
position: absolute;
right: px2rem(16px);
bottom: px2rem(-48px);
padding: px2rem(2px) px2rem(8px);
font-size: px2rem(10px);
color: var(--md-default-fg-color);
background-color: var(--md-default-bg-color--light);
border-radius: px2rem(2px);
transition:
color 125ms,
background-color 125ms;
// Link on focus/hover
&:is(:focus, :hover) {
color: var(--md-accent-bg-color);
background-color: var(--md-accent-fg-color);
}
}
// Hero more indicator
&__more {
position: absolute;
bottom: px2rem(-48px);
left: 50%;
display: block;
margin-left: px2rem(-12px);
text-align: center;
pointer-events: none;
animation: bounce 2000ms infinite cubic-bezier(0.4, 0, 0.2, 1);
// More indicator icon
svg {
width: px2rem(24px);
height: px2rem(24px);
fill: rgb(255, 255, 255);
}
}
}

View File

@@ -1,119 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// More than just a static site
.mdx-spotlight {
margin: 2em 0;
// Spotlight feature
&__feature {
display: flex;
flex: 1 0 48%;
flex-flow: row nowrap;
gap: px2rem(64px);
margin: 0;
margin-bottom: px2rem(64px);
// [tablet portrait -]: Adjust layout and spacing
@include break-to-device(tablet portrait) {
flex-direction: column;
gap: 0;
}
// [tablet landscape +]: Reverse direction for every other item
@include break-from-device(tablet landscape) {
// Reverse direction for every other item
&:nth-child(2n + 1) {
flex-direction: row-reverse;
}
}
// Adjust spacing on last child
&:last-child {
margin-bottom: 1em;
}
// Spotlight feature image link
> a {
display: block;
flex-shrink: 0;
transition: transform 500ms cubic-bezier(0.075, 0.85, 0.175, 1);
// [tablet portrait -]: Adjust spacing
@include break-to-device(tablet portrait) {
margin-inline: auto;
}
// Image link on hover
&:hover {
transform: scale(1.025);
}
}
// Spotlight feature image
a > img {
display: block;
width: px2rem(500px);
max-width: 100%;
height: auto;
border-radius: px2rem(4px);
box-shadow: var(--md-shadow-z2);
transition:
transform 750ms 125ms cubic-bezier(0.075, 0.85, 0.175, 1),
opacity 750ms 125ms;
}
// Spotlight feature description
figcaption {
margin-top: px2rem(16px);
transition:
transform 750ms 125ms cubic-bezier(0.075, 0.85, 0.175, 1),
opacity 750ms 125ms;
}
// Spotlight feature is hidden
.js &[hidden] {
// Spotlight feature image link
> a > img {
opacity: 0;
transform: translateY(px2rem(32px));
}
// Spotlight feature description
> figcaption {
opacity: 0;
transform: translateX(px2rem(32px));
}
// Reverse direction for every other feature
&:nth-child(2n) > figcaption {
transform: translateX(px2rem(-32px));
}
}
}
}

View File

@@ -1,104 +0,0 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// What our users say
.mdx-users {
display: flex;
gap: px2rem(64px);
margin: px2rem(48px) 0;
// [tablet portrait -]: Adjust layout
@include break-to-device(tablet portrait) {
flex-direction: column;
}
// Testminonial
&__testimonial {
display: flex;
flex: 1;
flex-direction: column;
gap: px2rem(24px);
margin: 0;
text-align: center;
// Delay transitions by a small amount
@for $i from 1 through 3 {
&:nth-child(#{$i}) {
transition-delay: 125ms + 75ms * $i;
}
}
// Testimonial image
img {
width: px2rem(200px);
height: auto;
margin-inline: auto;
border-radius: px2rem(100px);
transition:
transform 750ms cubic-bezier(0.075, 0.85, 0.175, 1),
opacity 750ms;
transition-delay: inherit;
}
// Testimonial content
figcaption {
display: block;
transition:
transform 750ms cubic-bezier(0.075, 0.85, 0.175, 1),
opacity 750ms;
transition-delay: inherit;
}
// Horizontal rule
hr {
width: px2rem(100px);
margin-inline: auto;
}
// Testimonial quote
cite {
display: block;
hyphens: auto;
text-align: justify;
}
// Testimonial is hidden
.js &[hidden] {
// Testimonial image
img {
opacity: 0;
transform: scale(0.75);
}
// Testimonial content
figcaption {
opacity: 0;
transform: translateY(px2rem(32px));
}
}
}
}

View File

@@ -0,0 +1,91 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Dependencies
// ----------------------------------------------------------------------------
@import "material-color";
@import "material-shadows";
// ----------------------------------------------------------------------------
// Local imports
// ----------------------------------------------------------------------------
@import "utilities/break";
@import "utilities/convert";
@import "config";
@import "main/resets";
@import "main/colors";
@import "main/icons";
@import "main/typeset";
@import "main/components/author";
@import "main/components/banner";
@import "main/components/base";
@import "main/components/clipboard";
@import "main/components/code";
@import "main/components/consent";
@import "main/components/content";
@import "main/components/dialog";
@import "main/components/feedback";
@import "main/components/footer";
@import "main/components/form";
@import "main/components/header";
@import "main/components/meta";
@import "main/components/nav";
@import "main/components/pagination";
@import "main/components/path";
@import "main/components/post";
@import "main/components/progress";
@import "main/components/search";
@import "main/components/select";
@import "main/components/sidebar";
@import "main/components/source";
@import "main/components/status";
@import "main/components/tabs";
@import "main/components/tag";
@import "main/components/tooltip";
@import "main/components/tooltip2";
@import "main/components/top";
@import "main/components/version";
@import "main/extensions/markdown/admonition";
@import "main/extensions/markdown/footnotes";
@import "main/extensions/markdown/toc";
@import "main/extensions/pymdownx/arithmatex";
@import "main/extensions/pymdownx/critic";
@import "main/extensions/pymdownx/details";
@import "main/extensions/pymdownx/emoji";
@import "main/extensions/pymdownx/highlight";
@import "main/extensions/pymdownx/keys";
@import "main/extensions/pymdownx/tabbed";
@import "main/extensions/pymdownx/tasklist";
@import "main/integrations/giscus";
@import "main/integrations/mermaid";
@import "main/modifiers/grid";
@import "main/modifiers/inline";

View File

@@ -0,0 +1,157 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Color variables
:root {
@extend %root;
// Primary color shades
--md-primary-fg-color: hsla(#{hex2hsl($clr-indigo-500)}, 1);
--md-primary-fg-color--light: hsla(#{hex2hsl($clr-indigo-400)}, 1);
--md-primary-fg-color--dark: hsla(#{hex2hsl($clr-indigo-700)}, 1);
--md-primary-bg-color: hsla(0, 0%, 100%, 1);
--md-primary-bg-color--light: hsla(0, 0%, 100%, 0.7);
// Accent color shades
--md-accent-fg-color: hsla(#{hex2hsl($clr-indigo-a200)}, 1);
--md-accent-fg-color--transparent: hsla(#{hex2hsl($clr-indigo-a200)}, 0.1);
--md-accent-bg-color: hsla(0, 0%, 100%, 1);
--md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7);
}
// ----------------------------------------------------------------------------
// Allow to explicitly use color schemes in nested content
[data-md-color-scheme="default"] {
@extend %root;
// Indicate that the site is rendered with a light color scheme
color-scheme: light;
// Hide images for dark mode
img[src$="#only-dark"],
img[src$="#gh-dark-mode-only"] {
display: none;
}
}
// ----------------------------------------------------------------------------
// Placeholders
// ----------------------------------------------------------------------------
// Default theme, i.e. light mode
%root {
// Color hue in the range [0,360] - change this variable to alter the tone
// of the theme, e.g. to make it more redish or greenish
--md-hue: 225deg;
// Default color shades
--md-default-fg-color: hsla(0, 0%, 0%, 0.87);
--md-default-fg-color--light: hsla(0, 0%, 0%, 0.54);
--md-default-fg-color--lighter: hsla(0, 0%, 0%, 0.32);
--md-default-fg-color--lightest: hsla(0, 0%, 0%, 0.07);
--md-default-bg-color: hsla(0, 0%, 100%, 1);
--md-default-bg-color--light: hsla(0, 0%, 100%, 0.7);
--md-default-bg-color--lighter: hsla(0, 0%, 100%, 0.3);
--md-default-bg-color--lightest: hsla(0, 0%, 100%, 0.12);
// Code color shades
--md-code-fg-color: hsla(200, 18%, 26%, 1);
--md-code-bg-color: hsla(200, 0%, 96%, 1);
--md-code-bg-color--light: hsla(200, 0%, 96%, 0.7);
--md-code-bg-color--lighter: hsla(200, 0%, 96%, 0.3);
// Code highlighting color shades
--md-code-hl-color: hsla(#{hex2hsl($clr-blue-a200)}, 1);
--md-code-hl-color--light: hsla(#{hex2hsl($clr-blue-a200)}, 0.1);
// Code highlighting syntax color shades
--md-code-hl-number-color: hsla(0, 67%, 50%, 1);
--md-code-hl-special-color: hsla(340, 83%, 47%, 1);
--md-code-hl-function-color: hsla(291, 45%, 50%, 1);
--md-code-hl-constant-color: hsla(250, 63%, 60%, 1);
--md-code-hl-keyword-color: hsla(219, 54%, 51%, 1);
--md-code-hl-string-color: hsla(150, 63%, 30%, 1);
--md-code-hl-name-color: var(--md-code-fg-color);
--md-code-hl-operator-color: var(--md-default-fg-color--light);
--md-code-hl-punctuation-color: var(--md-default-fg-color--light);
--md-code-hl-comment-color: var(--md-default-fg-color--light);
--md-code-hl-generic-color: var(--md-default-fg-color--light);
--md-code-hl-variable-color: var(--md-default-fg-color--light);
// Typeset color shades
--md-typeset-color: var(--md-default-fg-color);
// Typeset `a` color shades
--md-typeset-a-color: var(--md-primary-fg-color);
// Typeset `del` and `ins` color shades
--md-typeset-del-color: hsla(6, 90%, 60%, 0.15);
--md-typeset-ins-color: hsla(150, 90%, 44%, 0.15);
// Typeset `kbd` color shades
--md-typeset-kbd-color: hsla(0, 0%, 98%, 1);
--md-typeset-kbd-accent-color: hsla(0, 100%, 100%, 1);
--md-typeset-kbd-border-color: hsla(0, 0%, 72%, 1);
// Typeset `mark` color shades
--md-typeset-mark-color: hsla(#{hex2hsl($clr-yellow-a200)}, 0.5);
// Typeset `table` color shades
--md-typeset-table-color: hsla(0, 0%, 0%, 0.12);
--md-typeset-table-color--light: hsla(0, 0%, 0%, 0.035);
// Admonition color shades
--md-admonition-fg-color: var(--md-default-fg-color);
--md-admonition-bg-color: var(--md-default-bg-color);
// Warning color shades
--md-warning-fg-color: hsla(0, 0%, 0%, 0.87);
--md-warning-bg-color: hsla(60, 100%, 80%, 1);
// Footer color shades
--md-footer-fg-color: hsla(0, 0%, 100%, 1);
--md-footer-fg-color--light: hsla(0, 0%, 100%, 0.7);
--md-footer-fg-color--lighter: hsla(0, 0%, 100%, 0.45);
--md-footer-bg-color: hsla(0, 0%, 0%, 0.87);
--md-footer-bg-color--dark: hsla(0, 0%, 0%, 0.32);
// Shadow depth 1
--md-shadow-z1:
0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.05),
0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.1);
// Shadow depth 2
--md-shadow-z2:
0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.1),
0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.25);
// Shadow depth 3
--md-shadow-z3:
0 #{px2rem(4px)} #{px2rem(10px)} hsla(0, 0%, 0%, 0.2),
0 0 #{px2rem(1px)} hsla(0, 0%, 0%, 0.35);
}

View File

@@ -24,9 +24,14 @@
// Rules
// ----------------------------------------------------------------------------
// Navigation tabs
[data-md-color-primary] .md-tabs {
position: absolute;
top: px2rem(48px);
background-color: transparent;
// Icon
.md-icon {
// SVG defaults
svg {
display: block;
width: px2rem(24px);
height: px2rem(24px);
fill: currentcolor;
}
}

View File

@@ -0,0 +1,118 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Enforce correct box model and prevent adjustments of font size after
// orientation changes in IE and iOS
html {
box-sizing: border-box;
text-size-adjust: none;
}
// All elements shall inherit the document default
*,
*::before,
*::after {
box-sizing: inherit;
// [reduced motion]: Disable all transitions
@media (prefers-reduced-motion) {
transition: none !important; // stylelint-disable-line
}
}
// Remove margin in all browsers
body {
margin: 0;
}
// Reset tap outlines on iOS and Android
a,
button,
label,
input {
-webkit-tap-highlight-color: transparent;
}
// Reset link styles
a {
color: inherit;
text-decoration: none;
}
// Normalize horizontal separator styles
hr {
box-sizing: content-box;
display: block;
height: px2rem(1px);
padding: 0;
overflow: visible;
border: 0;
}
// Normalize font-size in all browsers
small {
font-size: 80%;
}
// Prevent subscript and superscript from affecting line-height
sub,
sup {
line-height: 1em;
}
// Remove border on image
img {
border-style: none;
}
// Reset table styles
table {
border-spacing: 0;
border-collapse: separate;
}
// Reset table cell styles
td,
th {
font-weight: 400;
vertical-align: top;
}
// Reset button styles
button {
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
background: transparent;
border: 0;
}
// Reset input styles
input {
border: 0;
outline: none;
}

View File

@@ -0,0 +1,623 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules: font definitions
// ----------------------------------------------------------------------------
// Enable font-smoothing in Webkit and FF
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
// Font with fallback for body copy
--md-text-font-family:
var(--md-text-font, _),
-apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
// Font with fallback for code
--md-code-font-family:
var(--md-code-font, _),
SFMono-Regular, Consolas, Menlo, monospace;
}
// Define default fonts
body,
input,
aside {
font-family: var(--md-text-font-family);
font-feature-settings: "kern", "liga";
color: var(--md-typeset-color);
}
// Define monospaced fonts
code,
pre,
kbd {
font-family: var(--md-code-font-family);
font-feature-settings: "kern";
}
// ----------------------------------------------------------------------------
// Rules: typesetted content
// ----------------------------------------------------------------------------
// General variables
:root {
--md-typeset-table-sort-icon: svg-load("material/sort.svg");
--md-typeset-table-sort-icon--asc: svg-load("material/sort-ascending.svg");
--md-typeset-table-sort-icon--desc: svg-load("material/sort-descending.svg");
}
// ----------------------------------------------------------------------------
// Content that is typeset - if possible, all margins, paddings and font sizes
// should be set in ems, so nested blocks (e.g. admonitions) render correctly.
.md-typeset {
font-size: px2rem(16px);
line-height: 1.6;
overflow-wrap: break-word;
color-adjust: exact;
// [print]: We'll use a smaller `font-size` for printing, so code examples
// don't break too early, and `16px` looks too big anyway.
@media print {
font-size: px2rem(13.6px);
}
// Default spacing
ul,
ol,
dl,
figure,
blockquote,
pre {
margin-block: 1em;
}
// Headline on level 1
h1 {
margin: 0 0 px2em(40px, 32px);
font-size: px2em(32px);
font-weight: 300;
line-height: 1.3;
color: var(--md-default-fg-color--light);
letter-spacing: -0.01em;
}
// Headline on level 2
h2 {
margin: px2em(40px, 25px) 0 px2em(16px, 25px);
font-size: px2em(25px);
font-weight: 300;
line-height: 1.4;
letter-spacing: -0.01em;
}
// Headline on level 3
h3 {
margin: px2em(32px, 20px) 0 px2em(16px, 20px);
font-size: px2em(20px);
font-weight: 400;
line-height: 1.5;
letter-spacing: -0.01em;
}
// Headline on level 3 following level 2
h2 + h3 {
margin-top: px2em(16px, 20px);
}
// Headline on level 4
h4 {
margin: px2em(16px) 0;
font-weight: 700;
letter-spacing: -0.01em;
}
// Headline on level 5-6
h5,
h6 {
margin: px2em(16px, 12.8px) 0;
font-size: px2em(12.8px);
font-weight: 700;
color: var(--md-default-fg-color--light);
letter-spacing: -0.01em;
}
// Headline on level 5
h5 {
text-transform: uppercase;
// Don't uppercase code blocks
code {
text-transform: none;
}
}
// Horizontal separator
hr {
display: flow-root;
margin: 1.5em 0;
border-bottom: px2rem(1px) solid var(--md-default-fg-color--lightest);
}
// Text link
a {
color: var(--md-typeset-a-color);
word-break: break-word;
// Also enable color transition on pseudo elements
&,
&::before {
transition: color 125ms;
}
// Text link on focus/hover
&:is(:focus, :hover) {
color: var(--md-accent-fg-color);
// Inline code block
code {
color: var(--md-accent-fg-color);
background-color: var(--md-accent-fg-color--transparent);
}
}
// Inline code block
code {
color: var(--md-typeset-a-color);
}
// Show outline for keyboard devices
&.focus-visible {
outline-color: var(--md-accent-fg-color);
outline-offset: px2rem(4px);
}
}
// Code block
code,
pre,
kbd {
font-variant-ligatures: none;
color: var(--md-code-fg-color);
direction: ltr;
transition: background-color 125ms;
// [print]: Wrap text and hide scollbars
@media print {
white-space: pre-wrap;
}
}
// Inline code block
code {
padding: 0 px2em(4px, 13.6px);
font-size: px2em(13.6px);
word-break: break-word;
background-color: var(--md-code-bg-color);
border-radius: px2rem(2px);
box-decoration-break: clone;
transition:
color 125ms,
background-color 125ms;
// Hide outline for pointer devices
&:not(.focus-visible) {
outline: none;
-webkit-tap-highlight-color: transparent;
}
}
// Unformatted content
pre {
position: relative;
display: flow-root;
line-height: 1.4;
// Code block
> code {
display: block;
padding: px2em(10.5px, 13.6px) px2em(16px, 13.6px);
margin: 0;
overflow: auto;
word-break: normal;
touch-action: auto;
scrollbar-color: var(--md-default-fg-color--lighter) transparent;
scrollbar-width: thin;
outline-color: var(--md-accent-fg-color);
box-shadow: none;
box-decoration-break: slice;
// Code block on hover
&:hover {
scrollbar-color: var(--md-accent-fg-color) transparent;
}
// Webkit scrollbar
&::-webkit-scrollbar {
width: px2rem(4px);
height: px2rem(4px);
}
// Webkit scrollbar thumb
&::-webkit-scrollbar-thumb {
background-color: var(--md-default-fg-color--lighter);
// Webkit scrollbar thumb on hover
&:hover {
background-color: var(--md-accent-fg-color);
}
}
}
}
// Keyboard key
kbd {
display: inline-block;
padding: 0 px2em(8px, 12px);
font-size: px2em(12px);
color: var(--md-default-fg-color);
word-break: break-word;
vertical-align: text-top;
background-color: var(--md-typeset-kbd-color);
border-radius: px2rem(2px);
box-shadow:
0 px2rem(2px) 0 px2rem(1px) var(--md-typeset-kbd-border-color),
0 px2rem(2px) 0 var(--md-typeset-kbd-border-color),
0 px2rem(-2px) px2rem(4px) var(--md-typeset-kbd-accent-color) inset;
}
// Text highlighting marker
mark {
color: inherit;
word-break: break-word;
background-color: var(--md-typeset-mark-color);
box-decoration-break: clone;
}
// Abbreviation
abbr {
text-decoration: none;
cursor: help;
border-bottom: px2rem(1px) dotted var(--md-default-fg-color--light);
}
// Instant previews
[data-preview] {
border-bottom: px2rem(1px) dotted var(--md-default-fg-color--light);
}
// Small text
small {
opacity: 0.75;
}
// Superscript and subscript
sup,
sub {
margin-inline-start: px2em(1px, 12.8px);
}
// Blockquotes, possibly nested
blockquote {
padding-inline-start: px2rem(12px);
margin-inline: 0;
color: var(--md-default-fg-color--light);
border-inline-start: px2rem(4px) solid var(--md-default-fg-color--lighter);
}
// Unordered list
ul {
list-style-type: disc;
// Hack: allow to override `list-style-type` via `type`, without breaking
// compatibility for explicitly setting it in CSS - see https://t.ly/izJyH
&[type] {
list-style-type: revert-layer;
}
}
// Unordered and ordered list
ul,
ol {
padding: 0;
margin-inline-start: px2em(10px);
// Adjust display mode if not hidden
&:not([hidden]) {
display: flow-root;
}
// 2nd layer nested ordered list
ol {
list-style-type: lower-alpha;
// 3rd layer nested ordered list
ol {
list-style-type: lower-roman;
// 4th layer nested ordered list
ol {
list-style-type: upper-alpha;
// 5th layer nested ordered list
ol {
list-style-type: upper-roman;
}
}
}
}
// Hack: allow to override `list-style-type` via `type`, without breaking
// compatibility for explicitly setting it in CSS - see https://t.ly/izJyH
&[type] {
list-style-type: revert-layer;
}
// List element
li {
margin-inline-start: px2em(20px);
margin-bottom: 0.5em;
// Adjust spacing
p,
blockquote {
margin: 0.5em 0;
}
// Adjust spacing on last child
&:last-child {
margin-bottom: 0;
}
// Nested list
:is(ul, ol) {
margin-block: 0.5em;
margin-inline-start: px2em(10px);
}
}
}
// Definition list
dd {
margin-block: 1em 1.5em;
margin-inline-start: px2em(30px);
}
// Image or video
img,
svg,
video {
max-width: 100%;
height: auto;
}
// Image
img {
// Adjust spacing when left-aligned
&[align="left"] {
margin: 1em;
margin-left: 0;
}
// Adjust spacing when right-aligned
&[align="right"] {
margin: 1em;
margin-right: 0;
}
// Adjust spacing when sole children
&[align]:only-child {
margin-top: 0;
}
}
// Figure
figure {
display: flow-root;
width: fit-content;
max-width: 100%;
margin: 1em auto;
text-align: center;
// Figure images
img {
display: block;
margin: 0 auto;
}
}
// Figure caption
figcaption {
max-width: px2rem(480px);
margin: 1em auto;
font-style: italic;
}
// Limit width to container
iframe {
max-width: 100%;
}
// Data table
table:not([class]) {
display: inline-block;
max-width: 100%;
overflow: auto;
font-size: px2rem(12.8px);
touch-action: auto;
background-color: var(--md-default-bg-color);
border: px2rem(1px) solid var(--md-typeset-table-color);
border-radius: px2rem(2px);
// [print]: Reset display mode so table header wraps when printing
@media print {
display: table;
}
// Due to margin collapse because of the necessary inline-block hack, we
// cannot increase the bottom margin on the table, so we just increase the
// top margin on the following element
+ * {
margin-top: 1.5em;
}
// Elements in table heading and cell
:is(th, td) > * {
// Adjust spacing on first child
&:first-child {
margin-top: 0;
}
// Adjust spacing on last child
&:last-child {
margin-bottom: 0;
}
}
// Table heading and cell
:is(th, td):not([align]) {
text-align: left;
// Adjust for right-to-left languages
[dir="rtl"] & {
text-align: right;
}
}
// Table heading
th {
min-width: px2rem(100px);
padding: px2em(12px, 12.8px) px2em(16px, 12.8px);
font-weight: 700;
vertical-align: top;
}
// Table cell
td {
padding: px2em(12px, 12.8px) px2em(16px, 12.8px);
vertical-align: top;
border-top: px2rem(1px) solid var(--md-typeset-table-color);
}
// Table body row
tbody tr {
transition: background-color 125ms;
// Table row on hover
&:hover {
background-color: var(--md-typeset-table-color--light);
box-shadow: 0 px2rem(1px) 0 var(--md-default-bg-color) inset;
}
}
// Text link in table
a {
word-break: normal;
}
}
// Sortable table
table th[role="columnheader"] {
cursor: pointer;
// Sort icon
&::after {
display: inline-block;
width: 1.2em;
height: 1.2em;
margin-inline-start: 0.5em;
vertical-align: text-bottom;
content: "";
mask-image: var(--md-typeset-table-sort-icon);
mask-repeat: no-repeat;
mask-size: contain;
transition: background-color 125ms;
}
// Show sort icon on hover
&:hover::after {
background-color: var(--md-default-fg-color--lighter);
}
// Sort ascending icon
&[aria-sort="ascending"]::after {
background-color: var(--md-default-fg-color--light);
mask-image: var(--md-typeset-table-sort-icon--asc);
}
// Sort descending icon
&[aria-sort="descending"]::after {
background-color: var(--md-default-fg-color--light);
mask-image: var(--md-typeset-table-sort-icon--desc);
}
}
// Data table scroll wrapper
&__scrollwrap {
margin: 1em px2rem(-16px);
overflow-x: auto;
touch-action: auto;
}
// Data table wrapper
&__table {
display: inline-block;
padding: 0 px2rem(16px);
margin-bottom: 0.5em;
// [print]: Reset display mode so table header wraps when printing
@media print {
display: block;
}
// Data table
html & table {
display: table;
width: 100%;
margin: 0;
overflow: hidden;
}
}
}
// ----------------------------------------------------------------------------
// Rules: top-level
// ----------------------------------------------------------------------------
// [mobile -]: Align with body copy
@include break-to-device(mobile) {
// Top-level unformatted content
.md-content__inner > pre {
margin: 1em px2rem(-16px);
// Code block
code {
border-radius: 0;
}
}
}

View File

@@ -0,0 +1,89 @@
////
/// Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
///
/// Permission is hereby granted, free of charge, to any person obtaining a
/// copy of this software and associated documentation files (the "Software"),
/// to deal in the Software without restriction, including without limitation
/// the rights to use, copy, modify, merge, publish, distribute, sublicense,
/// and/or sell copies of the Software, and to permit persons to whom the
/// Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in
/// all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL
/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
/// DEALINGS
////
// ----------------------------------------------------------------------------
// Rules
// ----------------------------------------------------------------------------
// Scoped in typesetted content to match specificity of regular content
.md-typeset {
// Author, i.e., GitHub user
.md-author {
position: relative;
display: block;
flex-shrink: 0;
width: px2rem(32px);
height: px2rem(32px);
overflow: hidden;
border-radius: 100%;
transition:
color 125ms,
transform 125ms;
// Author image
img {
display: block;
}
// More authors
&--more {
font-size: px2rem(12px);
font-weight: 700;
line-height: px2rem(32px);
color: var(--md-default-fg-color--lighter);
text-align: center;
background: var(--md-default-fg-color--lightest);
}
// Enlarge image
&--long {
width: px2rem(48px);
height: px2rem(48px);
}
}
// Author link
a.md-author {
transform: scale(1);
// Author image
img {
filter: grayscale(100%) opacity(75%);
// Hack: also apply this here, in order to mitigate browser glitches in
// Chrome and Edge when hovering the avatar - see https://t.ly/Q3ECC
border-radius: 100%;
transition: filter 125ms;
}
// Author on focus/hover
&:is(:focus, :hover) {
z-index: 1;
transform: scale(1.1);
// Author image
img {
filter: grayscale(0%);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More