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

@@ -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