# Copyright (c) 2016-2025 Martin Donath # 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)