mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2026-08-01 18:28:48 -04:00
Prepare 9.7.0 release
This commit is contained in:
19
src/plugins/projects/__init__.py
Normal file
19
src/plugins/projects/__init__.py
Normal 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.
|
||||
320
src/plugins/projects/builder/__init__.py
Normal file
320
src/plugins/projects/builder/__init__.py
Normal 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")
|
||||
100
src/plugins/projects/builder/log.py
Normal file
100
src/plugins/projects/builder/log.py
Normal 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")
|
||||
98
src/plugins/projects/builder/watcher/__init__.py
Normal file
98
src/plugins/projects/builder/watcher/__init__.py
Normal 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)
|
||||
105
src/plugins/projects/builder/watcher/handler.py
Normal file
105
src/plugins/projects/builder/watcher/handler.py
Normal 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")
|
||||
64
src/plugins/projects/config.py
Normal file
64
src/plugins/projects/config.py
Normal 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)
|
||||
292
src/plugins/projects/plugin.py
Normal file
292
src/plugins/projects/plugin.py
Normal 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)
|
||||
241
src/plugins/projects/structure/__init__.py
Normal file
241
src/plugins/projects/structure/__init__.py
Normal 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
|
||||
19
src/plugins/typeset/__init__.py
Normal file
19
src/plugins/typeset/__init__.py
Normal 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.
|
||||
30
src/plugins/typeset/config.py
Normal file
30
src/plugins/typeset/config.py
Normal 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)
|
||||
123
src/plugins/typeset/plugin.py
Normal file
123
src/plugins/typeset/plugin.py
Normal 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
|
||||
Reference in New Issue
Block a user