Files

293 lines
12 KiB
Python
Raw Permalink Normal View History

# 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)