Files
mkdocs-material/material/plugins/projects/plugin.py
2025-11-11 09:44:22 +01:00

293 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)