test bkp
This commit is contained in:
1 parent
6ccab0347b
commit
954a2fd6d6
18684 files changed
+601012
-190
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# 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 colorama import Fore, Style
|
||||
from os import getenv
|
||||
from sys import stderr
|
||||
|
||||
# Silence this warning with NO_MKDOCS_2_WARNING=1
|
||||
if not getenv("NO_MKDOCS_2_WARNING"):
|
||||
print(
|
||||
"\n"
|
||||
f"{Fore.RED} │ ⚠ WARNING – MkDocs 2.0 is incompatible with Material for MkDocs{Style.RESET_ALL}\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL}\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} MkDocs 1.x is unmaintained. We recommend switching to Zensical, our\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} new static site generator, as soon as possible. We're providing an\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} analysis of the situation in this article:\n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} \n"
|
||||
f"{Fore.RED} │ {Style.RESET_ALL} \033[4mhttps://squidfunk.github.io/mkdocs-material/blog/2026/02/18/mkdocs-2.0/{Style.RESET_ALL}\n"
|
||||
f"{Style.RESET_ALL}",
|
||||
file=stderr
|
||||
)
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
# 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 .structure import View
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Sort views by name
|
||||
def view_name(view: View):
|
||||
return view.name
|
||||
|
||||
# Sort views by post count
|
||||
def view_post_count(view: View):
|
||||
return len(view.posts)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
# 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.base import Config
|
||||
from mkdocs.config.config_options import DictOfItems, Optional, SubConfig, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Author
|
||||
class Author(Config):
|
||||
name = Type(str)
|
||||
description = Type(str)
|
||||
avatar = Type(str)
|
||||
slug = Optional(Type(str))
|
||||
url = Optional(Type(str))
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Authors
|
||||
class Authors(Config):
|
||||
authors = DictOfItems(SubConfig(Author), default = {})
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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 collections.abc import Callable
|
||||
from mkdocs.config.config_options import Choice, Deprecated, Optional, Type
|
||||
from mkdocs.config.base import Config
|
||||
from pymdownx.slugs import slugify
|
||||
|
||||
from . import view_name
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Blog plugin configuration
|
||||
class BlogConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for blog
|
||||
blog_dir = Type(str, default = "blog")
|
||||
blog_toc = Type(bool, default = False)
|
||||
|
||||
# Settings for posts
|
||||
post_dir = Type(str, default = "{blog}/posts")
|
||||
post_date_format = Type(str, default = "long")
|
||||
post_url_date_format = Type(str, default = "yyyy/MM/dd")
|
||||
post_url_format = Type(str, default = "{date}/{slug}")
|
||||
post_url_max_categories = Type(int, default = 1)
|
||||
post_slugify = Type(Callable, default = slugify(case = "lower"))
|
||||
post_slugify_separator = Type(str, default = "-")
|
||||
post_excerpt = Choice(["optional", "required"], default = "optional")
|
||||
post_excerpt_max_authors = Type(int, default = 1)
|
||||
post_excerpt_max_categories = Type(int, default = 5)
|
||||
post_excerpt_separator = Type(str, default = "<!-- more -->")
|
||||
post_readtime = Type(bool, default = True)
|
||||
post_readtime_words_per_minute = Type(int, default = 265)
|
||||
|
||||
# Settings for archive
|
||||
archive = Type(bool, default = True)
|
||||
archive_name = Type(str, default = "blog.archive")
|
||||
archive_date_format = Type(str, default = "yyyy")
|
||||
archive_url_date_format = Type(str, default = "yyyy")
|
||||
archive_url_format = Type(str, default = "archive/{date}")
|
||||
archive_pagination = Optional(Type(bool))
|
||||
archive_pagination_per_page = Optional(Type(int))
|
||||
archive_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for categories
|
||||
categories = Type(bool, default = True)
|
||||
categories_name = Type(str, default = "blog.categories")
|
||||
categories_url_format = Type(str, default = "category/{slug}")
|
||||
categories_slugify = Type(Callable, default = slugify(case = "lower"))
|
||||
categories_slugify_separator = Type(str, default = "-")
|
||||
categories_sort_by = Type(Callable, default = view_name)
|
||||
categories_sort_reverse = Type(bool, default = False)
|
||||
categories_allowed = Type(list, default = [])
|
||||
categories_pagination = Optional(Type(bool))
|
||||
categories_pagination_per_page = Optional(Type(int))
|
||||
categories_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for authors
|
||||
authors = Type(bool, default = True)
|
||||
authors_file = Type(str, default = "{blog}/.authors.yml")
|
||||
authors_profiles = Type(bool, default = False)
|
||||
authors_profiles_name = Type(str, default = "blog.authors")
|
||||
authors_profiles_url_format = Type(str, default = "author/{slug}")
|
||||
authors_profiles_pagination = Optional(Type(bool))
|
||||
authors_profiles_pagination_per_page = Optional(Type(int))
|
||||
authors_profiles_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for pagination
|
||||
pagination = Type(bool, default = True)
|
||||
pagination_per_page = Type(int, default = 10)
|
||||
pagination_url_format = Type(str, default = "page/{page}")
|
||||
pagination_format = Type(str, default = "~2~")
|
||||
pagination_if_single_page = Type(bool, default = False)
|
||||
pagination_keep_content = Type(bool, default = False)
|
||||
|
||||
# Settings for drafts
|
||||
draft = Type(bool, default = False)
|
||||
draft_on_serve = Type(bool, default = True)
|
||||
draft_if_future_date = Type(bool, default = False)
|
||||
|
||||
# Deprecated settings
|
||||
pagination_template = Deprecated(moved_to = "pagination_format")
|
||||
File diff suppressed because it is too large.
Load diff
+51
@@ -0,0 +1,51 @@
|
||||
# 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 math import ceil
|
||||
|
||||
from .parser import ReadtimeParser
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Compute readtime - we first used the original readtime library, but the list
|
||||
# of dependencies it brings with it increased the size of the Docker image by
|
||||
# 20 MB (packed), which is an increase of 50%. For this reason, we adapt the
|
||||
# original readtime algorithm to our needs - see https://t.ly/fPZ7L
|
||||
def readtime(html: str, words_per_minute: int):
|
||||
parser = ReadtimeParser()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
|
||||
# Extract words from text and compute readtime in seconds
|
||||
words = len(re.split(r"\W+", "".join(parser.text)))
|
||||
seconds = ceil(words / words_per_minute * 60)
|
||||
|
||||
# Account for additional images
|
||||
delta = 12
|
||||
for _ in range(parser.images):
|
||||
seconds += delta
|
||||
if delta > 3: delta -= 1
|
||||
|
||||
# Return readtime in minutes
|
||||
return ceil(seconds / 60)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
# 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 html.parser import HTMLParser
|
||||
|
||||
# TODO: Refactor the `void` set into a common module and import it from there
|
||||
# and not from the search plugin.
|
||||
from material.plugins.search.plugin import void
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Readtime parser
|
||||
class ReadtimeParser(HTMLParser):
|
||||
|
||||
# Initialize parser
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs = True)
|
||||
|
||||
# Tags to skip
|
||||
self.skip = set([
|
||||
"object", # Objects
|
||||
"script", # Scripts
|
||||
"style", # Styles
|
||||
"svg" # SVGs
|
||||
])
|
||||
|
||||
# Current context
|
||||
self.context = []
|
||||
|
||||
# Keep track of text and images
|
||||
self.text = []
|
||||
self.images = 0
|
||||
|
||||
# Called at the start of every HTML tag
|
||||
def handle_starttag(self, tag, attrs):
|
||||
# Collect images
|
||||
if tag == "img":
|
||||
self.images += 1
|
||||
|
||||
# Ignore self-closing tags
|
||||
if tag not in void:
|
||||
# Add tag to context
|
||||
self.context.append(tag)
|
||||
|
||||
# Called for the text contents of each tag
|
||||
def handle_data(self, data):
|
||||
# Collect text if not inside skip context
|
||||
if not self.skip.intersection(self.context):
|
||||
self.text.append(data)
|
||||
|
||||
# Called at the end of every HTML tag
|
||||
def handle_endtag(self, tag):
|
||||
if self.context and self.context[-1] == tag:
|
||||
# Remove tag from context
|
||||
self.context.pop()
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# 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
|
||||
import yaml
|
||||
|
||||
from copy import copy
|
||||
from markdown import Markdown
|
||||
from material.plugins.blog.author import Author
|
||||
from material.plugins.meta.plugin import MetaPlugin
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.files import File, Files
|
||||
from mkdocs.structure.nav import Link, Section
|
||||
from mkdocs.structure.pages import Page, _RelativePathTreeprocessor
|
||||
from mkdocs.structure.toc import get_toc
|
||||
from mkdocs.utils.meta import YAML_RE
|
||||
from re import Match
|
||||
from yaml import SafeLoader
|
||||
|
||||
from .config import PostConfig
|
||||
from .markdown import ExcerptTreeprocessor
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post
|
||||
class Post(Page):
|
||||
|
||||
# Initialize post - posts are never listed in the navigation, which is why
|
||||
# they will never include a title that was manually set, so we can omit it
|
||||
def __init__(self, file: File, config: MkDocsConfig):
|
||||
super().__init__(None, file, config)
|
||||
|
||||
# Resolve path relative to docs directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
|
||||
# Read contents and metadata immediately
|
||||
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
self.markdown = f.read()
|
||||
|
||||
# Sadly, MkDocs swallows any exceptions that occur during parsing.
|
||||
# Since we want to provide the best possible user experience, we
|
||||
# need to catch errors early and display them nicely. We decided to
|
||||
# drop support for MkDocs' MultiMarkdown syntax, because it is not
|
||||
# correctly implemented anyway. When using MultiMarkdown syntax, all
|
||||
# date formats are returned as strings and list are not properly
|
||||
# supported. Thus, we just use the relevants parts of `get_data`.
|
||||
match: Match = YAML_RE.match(self.markdown)
|
||||
if not match:
|
||||
raise PluginError(
|
||||
f"Error reading metadata of post '{path}' in '{docs}':\n"
|
||||
f"Expected metadata to be defined but found nothing"
|
||||
)
|
||||
|
||||
# Extract metadata and parse as YAML
|
||||
try:
|
||||
self.meta = yaml.load(match.group(1), SafeLoader) or {}
|
||||
self.markdown = self.markdown[match.end():].lstrip("\n")
|
||||
|
||||
# The post's metadata could not be parsed because of a syntax error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading metadata of post '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Hack: if the meta plugin is registered, we need to move the call
|
||||
# to `on_page_markdown` here, because we need to merge the metadata
|
||||
# of the post with the metadata of any meta files prior to creating
|
||||
# the post configuration. To our current knowledge, it's the only
|
||||
# way to allow posts to receive metadata from meta files, because
|
||||
# posts must be loaded prior to constructing the navigation in
|
||||
# `on_files` but the meta plugin first runs in `on_page_markdown`.
|
||||
plugin: MetaPlugin = config.plugins.get("material/meta")
|
||||
if plugin:
|
||||
plugin.on_page_markdown(
|
||||
self.markdown, page = self, config = config, files = None
|
||||
)
|
||||
|
||||
# Initialize post configuration, but remove all keys that this plugin
|
||||
# doesn't care about, or they will be reported as invalid configuration
|
||||
self.config: PostConfig = PostConfig(file.abs_src_path)
|
||||
self.config.load_dict({
|
||||
key: self.meta[key] for key in (
|
||||
set(self.meta.keys()) &
|
||||
set(self.config.keys())
|
||||
)
|
||||
})
|
||||
|
||||
# Validate configuration and throw if errors occurred
|
||||
errors, warnings = self.config.validate()
|
||||
for _, w in warnings:
|
||||
log.warning(w)
|
||||
for k, e in errors:
|
||||
raise PluginError(
|
||||
f"Error reading metadata '{k}' of post '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Excerpts are subsets of posts that are used in pages like archive and
|
||||
# category views. They are not rendered as standalone pages, but are
|
||||
# rendered in the context of a view. Each post has a dedicated excerpt
|
||||
# instance which is reused when rendering views.
|
||||
self.excerpt: Excerpt = None
|
||||
|
||||
# Initialize authors and actegories
|
||||
self.authors: list[Author] = []
|
||||
self.categories: list[Category] = []
|
||||
|
||||
# Ensure template is set or use default
|
||||
self.meta.setdefault("template", "blog-post.html")
|
||||
|
||||
# Ensure template hides navigation
|
||||
self.meta["hide"] = self.meta.get("hide", [])
|
||||
if "navigation" not in self.meta["hide"]:
|
||||
self.meta["hide"].append("navigation")
|
||||
|
||||
# The contents and metadata were already read in the constructor (and not
|
||||
# in `read_source` as for pages), so this function must be set to a no-op
|
||||
def read_source(self, config: MkDocsConfig):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Excerpt
|
||||
class Excerpt(Page):
|
||||
|
||||
# Initialize an excerpt for the given post - we create the Markdown parser
|
||||
# when intitializing the excerpt in order to improve rendering performance
|
||||
# for excerpts, as they are reused across several different views, because
|
||||
# posts might be referenced from multiple different locations
|
||||
def __init__(self, post: Post, config: MkDocsConfig, files: Files):
|
||||
self.file = copy(post.file)
|
||||
self.post = post
|
||||
|
||||
# Set canonical URL, or we can't print excerpts when debugging the
|
||||
# blog plugin, as the `abs_url` property would be missing
|
||||
self._set_canonical_url(config.site_url)
|
||||
|
||||
# Initialize configuration and metadata
|
||||
self.config = post.config
|
||||
self.meta = post.meta
|
||||
|
||||
# Initialize authors and categories - note that views usually contain
|
||||
# subsets of those lists, which is why we need to manage them here
|
||||
self.authors: list[Author] = []
|
||||
self.categories: list[Category] = []
|
||||
|
||||
# Initialize content after separator - allow template authors to render
|
||||
# posts inline or to provide a link to the post's page
|
||||
self.more = None
|
||||
|
||||
# Initialize parser - note that we need to patch the configuration,
|
||||
# more specifically the table of contents extension
|
||||
config = _patch(config)
|
||||
self.md = Markdown(
|
||||
extensions = config.markdown_extensions,
|
||||
extension_configs = config.mdx_configs,
|
||||
)
|
||||
|
||||
# Register excerpt tree processor - this processor resolves anchors to
|
||||
# posts from within views, so they point to the correct location
|
||||
self.md.treeprocessors.register(
|
||||
ExcerptTreeprocessor(post),
|
||||
"excerpt",
|
||||
0
|
||||
)
|
||||
|
||||
# Register relative path tree processor - this processor resolves links
|
||||
# to other pages and assets, and is used by MkDocs itself
|
||||
self.md.treeprocessors.register(
|
||||
_RelativePathTreeprocessor(self.file, files, config),
|
||||
"relpath",
|
||||
1
|
||||
)
|
||||
|
||||
# Render an excerpt of the post on the given page - note that this is not
|
||||
# thread-safe because excerpts are shared across views, as it cuts down on
|
||||
# the cost of initialization. However, if in the future, we decide to render
|
||||
# posts and views concurrently, we must change this behavior.
|
||||
def render(self, page: Page, separator: str):
|
||||
self.file.url = page.url
|
||||
|
||||
# Retrieve excerpt tree processor and set page as base
|
||||
at = self.md.treeprocessors.get_index_for_name("excerpt")
|
||||
processor: ExcerptTreeprocessor = self.md.treeprocessors[at]
|
||||
processor.base = page
|
||||
|
||||
# Ensure that the excerpt includes a title in its content, since the
|
||||
# title is linked to the post when rendering - see https://t.ly/5Gg2F
|
||||
self.markdown = self.post.markdown
|
||||
if not self.post._title_from_render:
|
||||
self.markdown = "\n\n".join([f"# {self.post.title}", self.markdown])
|
||||
|
||||
# Convert Markdown to HTML and extract excerpt
|
||||
self.content = self.md.convert(self.markdown)
|
||||
self.content, *more = self.content.split(separator, 1)
|
||||
if more:
|
||||
self.more = more[0]
|
||||
|
||||
# Extract table of contents and reset post URL - if we wouldn't reset
|
||||
# the excerpt URL, linking to the excerpt from the view would not work
|
||||
self.toc = get_toc(getattr(self.md, "toc_tokens", []))
|
||||
self.file.url = self.post.url
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# View
|
||||
class View(Page):
|
||||
|
||||
# Parent view
|
||||
parent: View | Section
|
||||
|
||||
# Initialize view
|
||||
def __init__(self, name: str | None, file: File, config: MkDocsConfig):
|
||||
super().__init__(None, file, config)
|
||||
|
||||
# Initialize name of the view - note that views never pass a title to
|
||||
# the parent constructor, so the author can always override the title
|
||||
# that is used for rendering. However, for some purposes, like for
|
||||
# example sorting, we need something to compare.
|
||||
self.name = name
|
||||
|
||||
# Initialize posts and views
|
||||
self.posts: list[Post] = []
|
||||
self.views: list[View] = []
|
||||
|
||||
# Initialize pages for pagination
|
||||
self.pages: list[View] = []
|
||||
|
||||
# Set necessary metadata
|
||||
def read_source(self, config: MkDocsConfig):
|
||||
super().read_source(config)
|
||||
|
||||
# Ensure template is set or use default
|
||||
self.meta.setdefault("template", "blog.html")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Archive view
|
||||
class Archive(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Category view
|
||||
class Category(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Profile view
|
||||
class Profile(View):
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Reference
|
||||
class Reference(Link):
|
||||
|
||||
# Initialize reference - this is essentially a crossover of pages and links,
|
||||
# as it inherits the metadata of the page and allows for anchors
|
||||
def __init__(self, title: str, url: str):
|
||||
super().__init__(title, url)
|
||||
self.meta = {}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Patch configuration
|
||||
def _patch(config: MkDocsConfig):
|
||||
config = copy(config)
|
||||
|
||||
# Copy parts of configuration that needs to be patched
|
||||
config.validation = copy(config.validation)
|
||||
config.validation.links = copy(config.validation.links)
|
||||
config.markdown_extensions = copy(config.markdown_extensions)
|
||||
config.mdx_configs = copy(config.mdx_configs)
|
||||
|
||||
# Make sure that the author did not add another instance of the table of
|
||||
# contents extension to the configuration, as this leads to weird behavior
|
||||
if "markdown.extensions.toc" in config.markdown_extensions:
|
||||
config.markdown_extensions.remove("markdown.extensions.toc")
|
||||
|
||||
# In order to render excerpts for posts, we need to make sure that the
|
||||
# table of contents extension is appropriately configured
|
||||
config.mdx_configs["toc"] = {
|
||||
**config.mdx_configs.get("toc", {}),
|
||||
**{
|
||||
"anchorlink": True, # Render headline as clickable
|
||||
"baselevel": 2, # Render h1 as h2 and so forth
|
||||
"permalink": False, # Remove permalinks
|
||||
"toc_depth": 2 # Remove everything below h2
|
||||
}
|
||||
}
|
||||
|
||||
# Additionally, we disable link validation when rendering excerpts, because
|
||||
# invalid links have already been reported when rendering the page
|
||||
links = config.validation.links
|
||||
links.not_found = logging.DEBUG
|
||||
links.absolute_links = logging.DEBUG
|
||||
links.unrecognized_links = logging.DEBUG
|
||||
|
||||
# Return patched configuration
|
||||
return config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.blog")
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
# 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.base import Config
|
||||
from mkdocs.config.config_options import Optional, Type
|
||||
|
||||
from .options import PostDate, PostLinks, UniqueListOfItems
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post configuration
|
||||
class PostConfig(Config):
|
||||
authors = UniqueListOfItems(Type(str), default = [])
|
||||
categories = UniqueListOfItems(Type(str), default = [])
|
||||
date = PostDate()
|
||||
draft = Optional(Type(bool))
|
||||
pin = Type(bool, default = False)
|
||||
links = Optional(PostLinks())
|
||||
readtime = Optional(Type(int))
|
||||
slug = Optional(Type(str))
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# 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
|
||||
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.utils import get_relative_url
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Excerpt tree processor
|
||||
class ExcerptTreeprocessor(Treeprocessor):
|
||||
|
||||
# Initialize excerpt tree processor
|
||||
def __init__(self, page: Page, base: Page | None = None):
|
||||
self.page = page
|
||||
self.base = base
|
||||
|
||||
# Transform HTML after Markdown processing
|
||||
def run(self, root: Element):
|
||||
assert self.base
|
||||
main = True
|
||||
|
||||
# We're only interested in anchors, which is why we continue when the
|
||||
# link does not start with an anchor tag
|
||||
for el in root.iter("a"):
|
||||
anchor = el.get("href")
|
||||
if not anchor.startswith("#"):
|
||||
continue
|
||||
|
||||
# The main headline should link to the post page, not to a specific
|
||||
# anchor, which is why we remove the anchor in that case
|
||||
path = get_relative_url(self.page.url, self.base.url)
|
||||
if main:
|
||||
el.set("href", path)
|
||||
else:
|
||||
el.set("href", path + anchor)
|
||||
|
||||
# Main headline has been seen
|
||||
main = False
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# 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 datetime import date, datetime, time, timezone
|
||||
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
|
||||
from mkdocs.config.config_options import ListOfItems, T
|
||||
from mkdocs.structure.files import Files
|
||||
from mkdocs.structure.nav import (
|
||||
Navigation, _add_parent_links, _data_to_navigation
|
||||
)
|
||||
from typing import Dict
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Date dictionary
|
||||
class DateDict(Dict[str, datetime]):
|
||||
|
||||
# Initialize date dictionary
|
||||
def __init__(self, data: dict):
|
||||
super().__init__(data)
|
||||
|
||||
# Ensure presence of `date.created`
|
||||
self.created: datetime = data["created"]
|
||||
|
||||
# Allow attribute access
|
||||
def __getattr__(self, name: str):
|
||||
if name in self:
|
||||
return self[name]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post date option
|
||||
class PostDate(BaseConfigOption[DateDict]):
|
||||
|
||||
# Initialize post dates
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Normalize the supported types for post dates to datetime
|
||||
def pre_validation(self, config: Config, key_name: str):
|
||||
|
||||
# If the date points to a scalar value, convert it to a dictionary, as
|
||||
# we want to allow the author to specify custom and arbitrary dates for
|
||||
# posts. Currently, only the `created` date is mandatory, because it's
|
||||
# needed to sort posts for views.
|
||||
if not isinstance(config[key_name], dict):
|
||||
config[key_name] = { "created": config[key_name] }
|
||||
|
||||
# Convert all date values to datetime
|
||||
for key, value in config[key_name].items():
|
||||
|
||||
# Handle datetime - since datetime is a subclass of date, we need
|
||||
# to check it first, or we lose the time - see https://t.ly/-KG9N
|
||||
if isinstance(value, datetime):
|
||||
# Set timezone to UTC if not set
|
||||
if value.tzinfo is None:
|
||||
config[key_name][key] = value.replace(tzinfo=timezone.utc)
|
||||
continue;
|
||||
|
||||
|
||||
# Handle date - we set 00:00:00 as the default time, if the author
|
||||
# only supplied a date, and convert it to datetime in UTC
|
||||
if isinstance(value, date):
|
||||
config[key_name][key] = datetime.combine(value, time()).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Initialize date dictionary
|
||||
config[key_name] = DateDict(config[key_name])
|
||||
|
||||
# Ensure each date value is of type datetime
|
||||
def run_validation(self, value: DateDict):
|
||||
for key in value:
|
||||
if not isinstance(value[key], datetime):
|
||||
raise ValidationError(
|
||||
f"Expected type: {date} or {datetime} "
|
||||
f"but received: {type(value[key])}"
|
||||
)
|
||||
|
||||
# Ensure presence of `date.created`
|
||||
if not value.created:
|
||||
raise ValidationError(
|
||||
"Expected 'created' date when using dictionary syntax"
|
||||
)
|
||||
|
||||
# Return date dictionary
|
||||
return value
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post links option
|
||||
class PostLinks(BaseConfigOption[Navigation]):
|
||||
|
||||
# Create navigation from structured items - we don't need to provide a
|
||||
# configuration object to the function, because it will not be used
|
||||
def run_validation(self, value: object):
|
||||
items = _data_to_navigation(value, Files([]), None)
|
||||
_add_parent_links(items)
|
||||
|
||||
# Return navigation
|
||||
return Navigation(items, [])
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Unique list of items
|
||||
class UniqueListOfItems(ListOfItems[T]):
|
||||
|
||||
# Ensure that each item is unique
|
||||
def run_validation(self, value: object):
|
||||
data = super().run_validation(value)
|
||||
return list(dict.fromkeys(data))
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Group plugin configuration
|
||||
class GroupConfig(Config):
|
||||
enabled = Type(bool, default = False)
|
||||
plugins = Type((list, dict))
|
||||
@@ -0,0 +1,161 @@
|
||||
# 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 collections.abc import Callable
|
||||
from mkdocs.config.config_options import Plugins
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
|
||||
from .config import GroupConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Group plugin
|
||||
class GroupPlugin(BasePlugin[GroupConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize object attributes
|
||||
self.is_serve = False
|
||||
self.is_dirty = False
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
self.is_dirty = dirty
|
||||
|
||||
# If the group is enabled, conditionally load plugins - at first, this might
|
||||
# sound easier than it actually is, as we need to jump through some hoops to
|
||||
# ensure correct ordering among plugins. We're effectively initializing the
|
||||
# plugins that are part of the group after all MkDocs finished initializing
|
||||
# all other plugins, so we need to patch the order of the methods. Moreover,
|
||||
# we must use MkDocs existing plugin collection, or we might have collisions
|
||||
# with other plugins that are not part of the group. As so often, this is a
|
||||
# little hacky, but has huge potential making plugin configuration easier.
|
||||
# There's one little caveat: the `__init__` and `on_startup` methods of the
|
||||
# plugins that are part of the group are called after all other plugins, so
|
||||
# the `event_priority` decorator for `on_startup` methods is effectively
|
||||
# useless. However, the `on_startup` method is only intended to set up the
|
||||
# plugin and doesn't receive anything else than the invoked command and
|
||||
# whether we're running a dirty build, so there should be no problems.
|
||||
@event_priority(150)
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Retrieve plugin collection from configuration
|
||||
option: Plugins = dict(config._schema)["plugins"]
|
||||
assert isinstance(option, Plugins)
|
||||
|
||||
# Load all plugins in group
|
||||
self.plugins: dict[str, BasePlugin] = {}
|
||||
try:
|
||||
for name, plugin in self._load(option):
|
||||
self.plugins[name] = plugin
|
||||
|
||||
# The plugin could not be loaded, likely because it's not installed or
|
||||
# misconfigured, so we raise a plugin error for a nicer error message
|
||||
except Exception as e:
|
||||
raise PluginError(str(e))
|
||||
|
||||
# Patch order of plugin methods
|
||||
for events in option.plugins.events.values():
|
||||
self._patch(events, config)
|
||||
|
||||
# Invoke `on_startup` event for plugins in group
|
||||
command = "serve" if self.is_serve else "build"
|
||||
for method in option.plugins.events["startup"]:
|
||||
plugin = self._get_plugin(method)
|
||||
|
||||
# Ensure that we have a method bound to a plugin (and not a hook)
|
||||
if plugin and plugin in self.plugins.values():
|
||||
method(command = command, dirty = self.is_dirty)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Retrieve plugin instance for bound method or nothing
|
||||
def _get_plugin(self, method: Callable):
|
||||
return getattr(method, "__self__", None)
|
||||
|
||||
# Retrieve priority of plugin method
|
||||
def _get_priority(self, method: Callable):
|
||||
return getattr(method, "mkdocs_priority", 0)
|
||||
|
||||
# Retrieve position of plugin
|
||||
def _get_position(self, plugin: BasePlugin, config: MkDocsConfig) -> int:
|
||||
for at, (_, candidate) in enumerate(config.plugins.items()):
|
||||
if plugin == candidate:
|
||||
return at
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Load plugins that are part of the group
|
||||
def _load(self, option: Plugins):
|
||||
for name, data in option._parse_configs(self.config.plugins):
|
||||
yield option.load_plugin_with_namespace(name, data)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Patch order of plugin methods - all other plugin methods are already in
|
||||
# the right order, so we only need to check those that are part of the group
|
||||
# and bubble them up into the right location. Some plugin methods may define
|
||||
# priorities, so we need to make sure to order correctly within those.
|
||||
def _patch(self, methods: list[Callable], config: MkDocsConfig):
|
||||
position = self._get_position(self, config)
|
||||
for at in reversed(range(1, len(methods))):
|
||||
tail = methods[at - 1]
|
||||
head = methods[at]
|
||||
|
||||
# Skip if the plugin is not part of the group
|
||||
plugin = self._get_plugin(head)
|
||||
if not plugin or plugin not in self.plugins.values():
|
||||
continue
|
||||
|
||||
# Skip if the previous method has a higher priority than the current
|
||||
# one, because we know we can't swap them anyway
|
||||
if self._get_priority(tail) > self._get_priority(head):
|
||||
continue
|
||||
|
||||
# Ensure that we have a method bound to a plugin (and not a hook)
|
||||
plugin = self._get_plugin(tail)
|
||||
if not plugin:
|
||||
continue
|
||||
|
||||
# Both methods have the same priority, so we check if the ordering
|
||||
# of both methods is violated, and if it is, swap them
|
||||
if (position < self._get_position(plugin, config)):
|
||||
methods[at], methods[at - 1] = tail, head
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.group")
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,35 @@
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Info plugin configuration
|
||||
class InfoConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
enabled_on_serve = Type(bool, default = False)
|
||||
|
||||
# Settings for archive
|
||||
archive = Type(bool, default = True)
|
||||
archive_stop_on_violation = Type(bool, default = True)
|
||||
@@ -0,0 +1,27 @@
|
||||
def get_exclusion_patterns():
|
||||
"""
|
||||
Regex patterns, which will be compared against directory and file names
|
||||
case-sensitively. https://docs.python.org/3/library/re.html#re.search is the
|
||||
matching function and scans the whole string to find any pattern match. Used
|
||||
with the https://pypi.org/project/regex/ module.
|
||||
|
||||
Additional remarks for pattern creation:
|
||||
- The compared paths will be always in POSIX format.
|
||||
- Each directory path will have a / at the end to allow to distinguish them
|
||||
from files.
|
||||
- Patterns for dynamic or custom paths like Virtual Environments (venv) or
|
||||
build site directories are created during plugin runtime.
|
||||
"""
|
||||
return [
|
||||
r"/__pycache__/", # Python cache directory
|
||||
|
||||
r"/\.DS_Store$", # macOS
|
||||
|
||||
r"/[^/]+\.zip$", # Generated files and folders
|
||||
|
||||
r"/[^/]*\.cache($|/)", # .cache files and folders
|
||||
|
||||
r"/\.vscode/", # Common autogenerated IDE directories
|
||||
r"/\.vs/",
|
||||
r"/\.idea/",
|
||||
]
|
||||
@@ -0,0 +1,563 @@
|
||||
# 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 getpass
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import requests
|
||||
import site
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
from colorama import Fore, Style
|
||||
from importlib.metadata import distributions, version
|
||||
from io import BytesIO
|
||||
from markdown.extensions.toc import slugify
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.utils.yaml import get_yaml_loader
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
|
||||
from .config import InfoConfig
|
||||
from .patterns import get_exclusion_patterns
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Info plugin
|
||||
class InfoPlugin(BasePlugin[InfoConfig]):
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
|
||||
# Initialize empty members
|
||||
self.exclusion_patterns = []
|
||||
self.excluded_entries = []
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
|
||||
# Create a self-contained example (run earliest) - determine all files that
|
||||
# are visible to MkDocs and are used to build the site, create an archive
|
||||
# that contains all of them, and print a summary of the archive contents.
|
||||
# The author must attach this archive to the bug report.
|
||||
@event_priority(100)
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# By default, the plugin is disabled when the documentation is served,
|
||||
# but not when it is built. This should nicely align with the expected
|
||||
# user experience when creating reproductions.
|
||||
if not self.config.enabled_on_serve and self.is_serve:
|
||||
return
|
||||
|
||||
# Resolve latest version
|
||||
url = "https://github.com/squidfunk/mkdocs-material/releases/latest"
|
||||
res = requests.get(url, allow_redirects = False)
|
||||
|
||||
# Check if we're running the latest version
|
||||
_, current = res.headers.get("location").rsplit("/", 1)
|
||||
present = version("mkdocs-material")
|
||||
if not present.startswith(current):
|
||||
log.error("Please upgrade to the latest version.")
|
||||
self._help_on_versions_and_exit(present, current)
|
||||
|
||||
# Exit if archive creation is disabled
|
||||
if not self.config.archive:
|
||||
sys.exit(1)
|
||||
|
||||
# Print message that we're creating a bug report
|
||||
log.info("Started archive creation for bug report")
|
||||
|
||||
# Check that there are no overrides in place - we need to use a little
|
||||
# hack to detect whether the custom_dir setting was used without parsing
|
||||
# mkdocs.yml again - we check at which position the directory provided
|
||||
# by the theme resides, and if it's not the first one, abort.
|
||||
if config.theme.custom_dir:
|
||||
log.error("Please remove 'custom_dir' setting.")
|
||||
self._help_on_customizations_and_exit()
|
||||
|
||||
# Check that there are no hooks in place - hooks can alter the behavior
|
||||
# of MkDocs in unpredictable ways, which is why they must be considered
|
||||
# being customizations. Thus, we can't offer support for debugging and
|
||||
# must abort here.
|
||||
if config.hooks:
|
||||
log.error("Please remove 'hooks' setting.")
|
||||
self._help_on_customizations_and_exit()
|
||||
|
||||
# Assure all paths that will be validated are absolute. Convert possible
|
||||
# relative config_file_path to absolute. Its absolute directory path is
|
||||
# being later used to resolve other paths.
|
||||
config.config_file_path = _convert_to_abs(config.config_file_path)
|
||||
config_file_parent = os.path.dirname(config.config_file_path)
|
||||
|
||||
# Convert relative custom_dir path to absolute. The Theme.custom_dir
|
||||
# property cannot be set, therefore a helper variable is used.
|
||||
if config.theme.custom_dir:
|
||||
abs_custom_dir = _convert_to_abs(
|
||||
config.theme.custom_dir,
|
||||
abs_prefix = config_file_parent
|
||||
)
|
||||
else:
|
||||
abs_custom_dir = ""
|
||||
|
||||
# Extract the absolute path to projects plugin's directory to explicitly
|
||||
# support path validation and dynamic exclusion for the plugin
|
||||
projects_plugin = config.plugins.get("material/projects")
|
||||
if projects_plugin:
|
||||
abs_projects_dir = _convert_to_abs(
|
||||
projects_plugin.config.projects_dir,
|
||||
abs_prefix = config_file_parent
|
||||
)
|
||||
else:
|
||||
abs_projects_dir = ""
|
||||
|
||||
# MkDocs removes the INHERIT configuration key during load, and doesn't
|
||||
# expose the information in any way, as the parent configuration is
|
||||
# merged into one. To validate that the INHERIT config file will be
|
||||
# included in the ZIP file the current config file must be loaded again
|
||||
# without parsing. Each file can have their own INHERIT key, so a list
|
||||
# of configurations is supported. The INHERIT path is converted during
|
||||
# load to absolute.
|
||||
loaded_configs = _load_yaml(config.config_file_path)
|
||||
if not isinstance(loaded_configs, list):
|
||||
loaded_configs = [loaded_configs]
|
||||
|
||||
# It can happen that the drive letter case is inconsistent on Windows.
|
||||
# Therefore, assure first character to be uppercase for the following
|
||||
# checks. See: https://t.ly/9t1SU
|
||||
site_prefixes = list(map(capitalize, site.PREFIXES))
|
||||
cwd = capitalize(os.getcwd())
|
||||
|
||||
# We need to make sure the user put every file in the current working
|
||||
# directory. To assure the reproduction inside the ZIP file can be run,
|
||||
# validate that the MkDocs paths are children of the current root.
|
||||
paths_to_validate = list(map(capitalize, [
|
||||
config.config_file_path,
|
||||
config.docs_dir,
|
||||
abs_custom_dir,
|
||||
abs_projects_dir,
|
||||
*[cfg.get("INHERIT", "") for cfg in loaded_configs]
|
||||
]))
|
||||
|
||||
# Convert relative hook paths to absolute path
|
||||
for hook in config.hooks:
|
||||
path = _convert_to_abs(hook, abs_prefix = config_file_parent)
|
||||
paths_to_validate.append(path)
|
||||
|
||||
# Remove valid paths from the list
|
||||
for path in list(paths_to_validate):
|
||||
if not path or path.startswith(cwd):
|
||||
paths_to_validate.remove(path)
|
||||
|
||||
# Report the invalid paths to the user
|
||||
if paths_to_validate:
|
||||
log.error("One or more paths aren't children of root")
|
||||
self._help_on_not_in_cwd(paths_to_validate)
|
||||
|
||||
# Create in-memory archive and prompt author for a short descriptive
|
||||
# name for the archive, which is also used as the directory name. Note
|
||||
# that the name is slugified for better readability and stripped of any
|
||||
# file extension that the author might have entered.
|
||||
archive = BytesIO()
|
||||
example = input("\nPlease name your bug report (2-4 words): ")
|
||||
example, _ = os.path.splitext(example)
|
||||
example = "-".join([present, slugify(example, "-")])
|
||||
|
||||
# Get local copy of the exclusion patterns
|
||||
self.exclusion_patterns = get_exclusion_patterns()
|
||||
self.excluded_entries = []
|
||||
|
||||
# Exclude the site_dir at project root
|
||||
if capitalize(config.site_dir).startswith(cwd):
|
||||
self.exclusion_patterns.append(_resolve_pattern(config.site_dir))
|
||||
|
||||
# Exclude the Virtual Environment directory. site.getsitepackages() has
|
||||
# inconsistent results across operating systems, and relies on the
|
||||
# PREFIXES that will contain the absolute path to the activated venv.
|
||||
for path in site_prefixes:
|
||||
if path.startswith(cwd):
|
||||
self.exclusion_patterns.append(_resolve_pattern(path))
|
||||
|
||||
# Guess other Virtual Environment paths in case we forget to activate
|
||||
# them or in case we have multiple. Making sure which venv is activated
|
||||
# is not necessary, as it is an optional step in the guidelines.
|
||||
for abs_root, dirnames, filenames in os.walk(os.getcwd()):
|
||||
for filename in filenames:
|
||||
if filename.lower() != "pyvenv.cfg":
|
||||
continue
|
||||
|
||||
path = capitalize(abs_root)
|
||||
|
||||
if path not in site_prefixes:
|
||||
print(f"Possible inactive venv: {path}")
|
||||
self.exclusion_patterns.append(_resolve_pattern(path))
|
||||
|
||||
# Exclude site_dir for projects
|
||||
if projects_plugin:
|
||||
for path in glob.iglob(
|
||||
pathname = projects_plugin.config.projects_config_files,
|
||||
root_dir = abs_projects_dir,
|
||||
recursive = True
|
||||
):
|
||||
current_config_file = os.path.join(abs_projects_dir, path)
|
||||
project_config = _get_project_config(current_config_file)
|
||||
pattern = _resolve_pattern(project_config.site_dir)
|
||||
self.exclusion_patterns.append(pattern)
|
||||
|
||||
# Track dotpath inclusion to inform about it later
|
||||
contains_dotpath: bool = False
|
||||
|
||||
# Create self-contained example from project
|
||||
files: list[str] = []
|
||||
with ZipFile(archive, "a", ZIP_DEFLATED, False) as f:
|
||||
for abs_root, dirnames, filenames in os.walk(os.getcwd()):
|
||||
# Set and print progress indicator
|
||||
indicator = f"Processing: {abs_root}"
|
||||
print(indicator, end="\r", flush=True)
|
||||
|
||||
# Prune the folders in-place to prevent their processing
|
||||
for name in list(dirnames):
|
||||
# Resolve the absolute directory path
|
||||
path = os.path.join(abs_root, name)
|
||||
|
||||
# Exclude the directory and all subdirectories
|
||||
if self._is_excluded(path):
|
||||
dirnames.remove(name)
|
||||
continue
|
||||
|
||||
# Warn about .dotdirectories
|
||||
if _is_dotpath(path, log_warning = True):
|
||||
contains_dotpath = True
|
||||
|
||||
# Write files to the in-memory archive
|
||||
for name in filenames:
|
||||
# Resolve the absolute file path
|
||||
path = os.path.join(abs_root, name)
|
||||
|
||||
# Exclude the file
|
||||
if self._is_excluded(path):
|
||||
continue
|
||||
|
||||
# Warn about .dotfiles
|
||||
if _is_dotpath(path, log_warning = True):
|
||||
contains_dotpath = True
|
||||
|
||||
# Resolve the relative path to create a matching structure
|
||||
path = os.path.relpath(path, os.path.curdir)
|
||||
f.write(path, os.path.join(example, path))
|
||||
|
||||
# Clear the line for the next indicator
|
||||
print(" " * len(indicator), end="\r", flush=True)
|
||||
|
||||
# Add information on installed packages
|
||||
f.writestr(
|
||||
os.path.join(example, "requirements.lock.txt"),
|
||||
"\n".join(sorted([
|
||||
"==".join([package.name, package.version])
|
||||
for package in distributions()
|
||||
]))
|
||||
)
|
||||
|
||||
# Try to get login to replace it with USERNAME placeholder
|
||||
try:
|
||||
username = getpass.getuser()
|
||||
except Exception:
|
||||
username = "USERNAME"
|
||||
|
||||
# Add information on platform
|
||||
f.writestr(
|
||||
os.path.join(example, "platform.json"),
|
||||
json.dumps(
|
||||
{
|
||||
"system": platform.platform(),
|
||||
"architecture": platform.architecture(),
|
||||
"python": platform.python_version(),
|
||||
"cwd": os.getcwd(),
|
||||
"command": " ".join([
|
||||
sys.argv[0].rsplit(os.sep, 1)[-1],
|
||||
*sys.argv[1:]
|
||||
]),
|
||||
"env:$PYTHONPATH": os.getenv("PYTHONPATH", ""),
|
||||
"env:$VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", ""),
|
||||
"sys.path": sys.path,
|
||||
"excluded_entries": self.excluded_entries
|
||||
},
|
||||
default = str,
|
||||
indent = 2
|
||||
).replace(username, "USERNAME")
|
||||
)
|
||||
|
||||
# Retrieve list of processed files
|
||||
for a in f.filelist:
|
||||
# Highlight .dotpaths in a more explicit manner
|
||||
color = (Fore.LIGHTYELLOW_EX if "/." in a.filename
|
||||
else Fore.LIGHTBLACK_EX)
|
||||
files.append("".join([
|
||||
color, a.filename, " ",
|
||||
_size(a.compress_size)
|
||||
]))
|
||||
|
||||
# Finally, write archive to disk
|
||||
buffer = archive.getbuffer()
|
||||
with open(f"{example}.zip", "wb") as f:
|
||||
f.write(archive.getvalue())
|
||||
|
||||
# Print summary
|
||||
log.info("Archive successfully created:")
|
||||
print(Style.NORMAL)
|
||||
|
||||
# Print archive file names
|
||||
files.sort()
|
||||
for file in files:
|
||||
print(f" {file}")
|
||||
|
||||
# Print archive name
|
||||
print(Style.RESET_ALL)
|
||||
print("".join([
|
||||
" ", f.name, " ",
|
||||
_size(buffer.nbytes, 10)
|
||||
]))
|
||||
|
||||
# Print warning when file size is excessively large
|
||||
print(Style.RESET_ALL)
|
||||
if buffer.nbytes > 1000000:
|
||||
log.warning("Archive exceeds recommended maximum size of 1 MB")
|
||||
|
||||
# Print warning when file contains hidden .dotpaths
|
||||
if contains_dotpath:
|
||||
log.warning(
|
||||
"Archive contains dotpaths, which could contain sensitive "
|
||||
"information.\nPlease review them at the bottom of the list "
|
||||
"and share only necessary data to reproduce the issue."
|
||||
)
|
||||
|
||||
# Aaaaaand done
|
||||
sys.exit(1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Print help on versions and exit
|
||||
def _help_on_versions_and_exit(self, have, need):
|
||||
print(Fore.RED)
|
||||
print(" When reporting issues, please first upgrade to the latest")
|
||||
print(" version of Material for MkDocs, as the problem might already")
|
||||
print(" be fixed in the latest version. This helps reduce duplicate")
|
||||
print(" efforts and saves us maintainers time.")
|
||||
print(Style.NORMAL)
|
||||
print(f" Please update from {have} to {need}.")
|
||||
print(Style.RESET_ALL)
|
||||
print(f" pip install --upgrade --force-reinstall mkdocs-material")
|
||||
print(Style.NORMAL)
|
||||
|
||||
# Exit, unless explicitly told not to
|
||||
if self.config.archive_stop_on_violation:
|
||||
sys.exit(1)
|
||||
|
||||
# Print help on customizations and exit
|
||||
def _help_on_customizations_and_exit(self):
|
||||
print(Fore.RED)
|
||||
print(" When reporting issues, you must remove all customizations")
|
||||
print(" and check if the problem persists. If not, the problem is")
|
||||
print(" caused by your overrides. Please understand that we can't")
|
||||
print(" help you debug your customizations. Please remove:")
|
||||
print(Style.NORMAL)
|
||||
print(" - theme.custom_dir")
|
||||
print(" - hooks")
|
||||
print(Fore.YELLOW)
|
||||
print(" Additionally, please remove all third-party JavaScript or")
|
||||
print(" CSS not explicitly mentioned in our documentation:")
|
||||
print(Style.NORMAL)
|
||||
print(" - extra_css")
|
||||
print(" - extra_javascript")
|
||||
print(Fore.YELLOW)
|
||||
print(" If you're using customizations from the theme's documentation")
|
||||
print(" and you want to report a bug specific to those customizations")
|
||||
print(" then set the 'archive_stop_on_violation: false' option in the")
|
||||
print(" info plugin config.")
|
||||
print(Style.RESET_ALL)
|
||||
|
||||
# Exit, unless explicitly told not to
|
||||
if self.config.archive_stop_on_violation:
|
||||
sys.exit(1)
|
||||
|
||||
# Print help on not in current working directory and exit
|
||||
def _help_on_not_in_cwd(self, outside_root):
|
||||
print(Fore.RED)
|
||||
print(" The current working (root) directory:\n")
|
||||
print(f" {os.getcwd()}\n")
|
||||
print(" is not a parent of the following paths:")
|
||||
print(Style.NORMAL)
|
||||
for path in outside_root:
|
||||
print(f" {path}")
|
||||
print("\n To assure that all project files are found please adjust")
|
||||
print(" your config or file structure and put everything within the")
|
||||
print(" root directory of the project.")
|
||||
print("\n Please also make sure `mkdocs build` is run in the actual")
|
||||
print(" root directory of the project.")
|
||||
print(Style.RESET_ALL)
|
||||
|
||||
# Exit, unless explicitly told not to
|
||||
if self.config.archive_stop_on_violation:
|
||||
sys.exit(1)
|
||||
|
||||
# Check if path is excluded and should be omitted from the zip. Use pattern
|
||||
# matching for files and folders, and lookahead specific files in folders to
|
||||
# skip them. Side effect: Save excluded paths to save them in the zip file.
|
||||
def _is_excluded(self, abspath: str) -> bool:
|
||||
|
||||
# Resolve the path into POSIX format to match the patterns
|
||||
pattern_path = _resolve_pattern(abspath, return_path = True)
|
||||
for pattern in self.exclusion_patterns:
|
||||
if re.search(pattern, pattern_path):
|
||||
log.debug(f"Excluded pattern '{pattern}': {abspath}")
|
||||
self.excluded_entries.append(f"{pattern} - {pattern_path}")
|
||||
return True
|
||||
|
||||
# File exclusion should be limited to pattern matching
|
||||
if os.path.isfile(abspath):
|
||||
return False
|
||||
|
||||
# Projects, which don't use the projects plugin for multi-language
|
||||
# support could have separate build folders for each config file or
|
||||
# language. Therefore, we exclude them with the assumption a site_dir
|
||||
# contains the sitemap file. Example of such a setup: https://t.ly/DLQcy
|
||||
sitemap_gz = os.path.join(abspath, "sitemap.xml.gz")
|
||||
if os.path.exists(sitemap_gz):
|
||||
log.debug(f"Excluded site_dir: {abspath}")
|
||||
self.excluded_entries.append(f"sitemap.xml.gz - {pattern_path}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Print human-readable size
|
||||
def _size(value, factor = 1):
|
||||
color = Fore.GREEN
|
||||
if value > 100000 * factor: color = Fore.RED
|
||||
elif value > 25000 * factor: color = Fore.YELLOW
|
||||
for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
|
||||
if abs(value) < 1000.0:
|
||||
return f"{color}{value:3.1f} {unit}"
|
||||
value /= 1000.0
|
||||
|
||||
# Get the absolute path with set prefix. To validate if a file is inside the
|
||||
# current working directory it needs to be absolute, so that it is possible to
|
||||
# check the prefix.
|
||||
def _convert_to_abs(path: str, abs_prefix: str = None) -> str:
|
||||
if os.path.isabs(path): return path
|
||||
if abs_prefix is None: abs_prefix = os.getcwd()
|
||||
return os.path.normpath(os.path.join(abs_prefix, path))
|
||||
|
||||
# Get the loaded config, or a list with all loaded configs. MkDocs removes the
|
||||
# INHERIT configuration key during load, and doesn't expose the information in
|
||||
# any way, as the parent configuration is merged into one. The INHERIT path is
|
||||
# needed for validation. This custom YAML loader replicates MkDocs' loading
|
||||
# logic. Side effect: It converts the INHERIT path to absolute.
|
||||
def _load_yaml(abs_src_path: str):
|
||||
|
||||
with open(abs_src_path, encoding ="utf-8-sig") as file:
|
||||
source = file.read()
|
||||
|
||||
try:
|
||||
result = yaml.load(source, Loader = get_yaml_loader()) or {}
|
||||
except yaml.YAMLError:
|
||||
result = {}
|
||||
|
||||
if "INHERIT" in result:
|
||||
relpath = result.get('INHERIT')
|
||||
parent_path = os.path.dirname(abs_src_path)
|
||||
abspath = _convert_to_abs(relpath, abs_prefix = parent_path)
|
||||
if os.path.exists(abspath):
|
||||
result["INHERIT"] = abspath
|
||||
log.debug(f"Loading inherited configuration file: {abspath}")
|
||||
parent = _load_yaml(abspath)
|
||||
if isinstance(parent, list):
|
||||
result = [result, *parent]
|
||||
elif isinstance(parent, dict):
|
||||
result = [result, parent]
|
||||
|
||||
return result
|
||||
|
||||
# Get a normalized POSIX path for the pattern matching with removed current
|
||||
# working directory prefix. Directory paths end with a '/' to allow more control
|
||||
# in the pattern creation for files and directories. The patterns are matched
|
||||
# using the search function, so they are prefixed with ^ for specificity.
|
||||
def _resolve_pattern(abspath: str, return_path: bool = False):
|
||||
path = capitalize(abspath).replace(capitalize(os.getcwd()), "", 1)
|
||||
path = path.replace(os.sep, "/").rstrip("/")
|
||||
|
||||
if not path:
|
||||
return "/"
|
||||
|
||||
# Check abspath, as the file needs to exist
|
||||
if not os.path.isfile(abspath):
|
||||
path = path + "/"
|
||||
|
||||
return path if return_path else f"^{path}"
|
||||
|
||||
# Get project configuration with resolved absolute paths for validation
|
||||
def _get_project_config(project_config_file: str):
|
||||
with open(project_config_file, encoding="utf-8-sig") as file:
|
||||
config = MkDocsConfig(config_file_path = project_config_file)
|
||||
config.load_file(file)
|
||||
|
||||
# MkDocs transforms site_dir to absolute path during validation
|
||||
config.validate()
|
||||
|
||||
return config
|
||||
|
||||
# Check if the path is a .dotpath. A warning can also be issued when the param
|
||||
# is set. The function also returns a boolean to track results outside it.
|
||||
def _is_dotpath(path: str, log_warning: bool = False) -> bool:
|
||||
posix_path = _resolve_pattern(path, return_path = True)
|
||||
name = posix_path.rstrip("/").rsplit("/", 1)[-1]
|
||||
if name.startswith("."):
|
||||
if log_warning:
|
||||
log.warning(f"The following .dotpath will be included: {path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
# It can happen that the drive letter case is inconsistent on Windows.
|
||||
# Capitalize the first character keeping the rest the same for comparison.
|
||||
# See: https://t.ly/9t1SU
|
||||
def capitalize(path: str):
|
||||
return path[0].upper() + path[1:] if path else path
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.info")
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin configuration
|
||||
class MetaConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for meta files
|
||||
meta_file = Type(str, default = ".meta.yml")
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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 logging
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from mergedeep import Strategy, merge
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.files import InclusionLevel
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from yaml import SafeLoader, load
|
||||
|
||||
from .config import MetaConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin
|
||||
class MetaPlugin(BasePlugin[MetaConfig]):
|
||||
|
||||
# Construct metadata mapping
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize mapping
|
||||
self.meta = {}
|
||||
|
||||
# Resolve and load meta files in docs directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
for file in files:
|
||||
name = posixpath.basename(file.src_uri)
|
||||
if not name == self.config.meta_file:
|
||||
continue
|
||||
|
||||
# Exclude meta file from site directory - explicitly excluding the
|
||||
# meta file allows the author to use a file name without '.' prefix
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Open file and parse as YAML
|
||||
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
path = file.src_path
|
||||
try:
|
||||
self.meta[path] = load(f, SafeLoader)
|
||||
|
||||
# The meta file could not be loaded because of a syntax error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Set metadata for page, if applicable (run earlier)
|
||||
@event_priority(50)
|
||||
def on_page_markdown(self, markdown, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Start with a clean state, as we first need to apply all meta files
|
||||
# that are relevant to the current page, and then merge the page meta
|
||||
# on top of that to ensure that the page meta always takes precedence
|
||||
# over meta files - see https://t.ly/kvCRn
|
||||
meta = {}
|
||||
|
||||
# Merge matching meta files in level-order
|
||||
strategy = Strategy.TYPESAFE_ADDITIVE
|
||||
for path, defaults in self.meta.items():
|
||||
if not page.file.src_path.startswith(os.path.dirname(path)):
|
||||
continue
|
||||
|
||||
# Skip if meta file was already merged - this happens in case of
|
||||
# blog posts, as they need to be merged when posts are constructed,
|
||||
# which is why we need to keep track of which meta files are applied
|
||||
# to what pages using the `__extends` key.
|
||||
page.meta.setdefault("__extends", [])
|
||||
if path in page.meta["__extends"]:
|
||||
continue
|
||||
|
||||
# Try to merge metadata
|
||||
try:
|
||||
merge(meta, defaults, strategy = strategy)
|
||||
page.meta["__extends"].append(path)
|
||||
|
||||
# Merging the metadata with the given strategy resulted in an error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
raise PluginError(
|
||||
f"Error merging meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Ensure page metadata is merged last, so the author can override any
|
||||
# defaults from the meta files, or even remove them entirely
|
||||
page.meta = merge(meta, page.meta, strategy = strategy)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.meta")
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Offline plugin configuration
|
||||
class OfflineConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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 mkdocs.plugins import BasePlugin, event_priority
|
||||
|
||||
from .config import OfflineConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Offline plugin
|
||||
class OfflinePlugin(BasePlugin[OfflineConfig]):
|
||||
|
||||
# Set configuration for offline build
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Ensure correct resolution of links when viewing the site from the
|
||||
# file system by disabling directory URLs
|
||||
config.use_directory_urls = False
|
||||
|
||||
# Append iframe-worker to polyfills/shims
|
||||
config.extra["polyfills"] = config.extra.get("polyfills", [])
|
||||
if not any("iframe-worker" in url for url in config.extra["polyfills"]):
|
||||
script = "https://unpkg.com/iframe-worker/shim"
|
||||
config.extra["polyfills"].append(script)
|
||||
|
||||
# Add support for offline search (run latest) - the search index is copied
|
||||
# and inlined into a script, so that it can be used without a server
|
||||
@event_priority(-100)
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Ensure presence of search index
|
||||
path = os.path.join(config.site_dir, "search")
|
||||
file = os.path.join(path, "search_index.json")
|
||||
if not os.path.isfile(file):
|
||||
return
|
||||
|
||||
# Obtain search index contents
|
||||
with open(file, encoding = "utf-8") as f:
|
||||
data = f.read()
|
||||
|
||||
# Inline search index contents into script
|
||||
file = os.path.join(path, "search_index.js")
|
||||
with open(file, "w", encoding = "utf-8") as f:
|
||||
f.write(f"var __index = {data}")
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
# 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 mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import ListOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Optimize plugin configuration
|
||||
class OptimizeConfig(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/optimize")
|
||||
|
||||
# Settings for optimization
|
||||
optimize = Type(bool, default = True)
|
||||
optimize_png = Type(bool, default = True)
|
||||
optimize_png_speed = Type(int, default = 3)
|
||||
optimize_png_strip = Type(bool, default = True)
|
||||
optimize_jpg = Type(bool, default = True)
|
||||
optimize_jpg_quality = Type(int, default = 60)
|
||||
optimize_jpg_progressive = Type(bool, default = True)
|
||||
optimize_include = ListOfItems(Type(str), default = [])
|
||||
optimize_exclude = ListOfItems(Type(str), default = [])
|
||||
|
||||
# Settings for reporting
|
||||
print_gain = Type(bool, default = True)
|
||||
print_gain_summary = Type(bool, default = True)
|
||||
@@ -0,0 +1,388 @@
|
||||
# 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 functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fnmatch import fnmatch
|
||||
from colorama import Fore, Style
|
||||
from concurrent.futures import Future
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from hashlib import sha1
|
||||
from mkdocs import utils
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin
|
||||
from mkdocs.structure.files import File
|
||||
from shutil import which
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from .config import OptimizeConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Optimize plugin
|
||||
class OptimizePlugin(BasePlugin[OptimizeConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Manifest
|
||||
manifest: dict[str, str] = {}
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
|
||||
# Initialize thread pool
|
||||
self.pool = ThreadPoolExecutor(self.config.concurrency)
|
||||
self.pool_jobs: dict[str, Future] = {}
|
||||
|
||||
# Resolve and load manifest
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Resolve cache directory (once) - this is necessary, so the cache is
|
||||
# always relative to the configuration file, and thus project, and not
|
||||
# relative to the current working directory, or it would not work with
|
||||
# the projects plugin.
|
||||
path = os.path.abspath(self.config.cache_dir)
|
||||
if path != self.config.cache_dir:
|
||||
self.config.cache_dir = os.path.join(
|
||||
os.path.dirname(config.config_file_path),
|
||||
os.path.normpath(self.config.cache_dir)
|
||||
)
|
||||
|
||||
# Ensure cache directory exists
|
||||
os.makedirs(self.config.cache_dir, exist_ok = True)
|
||||
|
||||
# Initialize manifest
|
||||
self.manifest_file = os.path.join(
|
||||
self.config.cache_dir, "manifest.json"
|
||||
)
|
||||
|
||||
# Load manifest if it exists and the cache should be used
|
||||
if os.path.isfile(self.manifest_file) and self.config.cache:
|
||||
try:
|
||||
with open(self.manifest_file) as f:
|
||||
self.manifest = json.load(f)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Initialize optimization pipeline
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if media files should not be optimized
|
||||
if not self.config.optimize:
|
||||
return
|
||||
|
||||
# Filter all optimizable media files and steal reponsibility from MkDocs
|
||||
# by removing them from the files collection. Then, start a concurrent
|
||||
# job that checks if an image was already optimized and can be returned
|
||||
# from the cache, or optimize it accordingly.
|
||||
for file in files.media_files():
|
||||
if self._is_excluded(file):
|
||||
continue
|
||||
|
||||
# Spawn concurrent job to optimize the given image and add future
|
||||
# to job dictionary, as it returns the file we need to copy later
|
||||
path = os.path.join(self.config.cache_dir, file.src_path)
|
||||
self.pool_jobs[file.abs_src_path] = self.pool.submit(
|
||||
self._optimize_image, file, path, config
|
||||
)
|
||||
|
||||
# Steal responsibility from MkDocs
|
||||
files.remove(file)
|
||||
|
||||
# Finish optimization pipeline
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if media files should not be optimized
|
||||
if not self.config.optimize:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs - we need to wait for all jobs to finish
|
||||
# before we can copy the optimized files to the output directory. If an
|
||||
# exception occurred in one of the jobs, we raise it here, so the build
|
||||
# fails and the author can fix the issue.
|
||||
for path, future in self.pool_jobs.items():
|
||||
if future.exception():
|
||||
raise future.exception()
|
||||
else:
|
||||
file: File = future.result()
|
||||
file.copy_file()
|
||||
|
||||
# Save manifest if cache should be used
|
||||
if self.config.cache:
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# Compute and print gains through optimization
|
||||
if self.config.print_gain_summary:
|
||||
print(Style.NORMAL)
|
||||
print(f" Optimizations:")
|
||||
|
||||
# Print summary for file extension
|
||||
for seek in [".png", ".jpg"]:
|
||||
size = size_opt = 0
|
||||
for path, future in self.pool_jobs.items():
|
||||
file: File = future.result()
|
||||
|
||||
# Skip files that are not of the given type
|
||||
_, extension = os.path.splitext(path)
|
||||
extension = ".jpg" if extension == ".jpeg" else extension
|
||||
if extension != seek:
|
||||
continue
|
||||
|
||||
# Compute size before and after optimization
|
||||
size += os.path.getsize(path)
|
||||
size_opt += os.path.getsize(file.abs_dest_path)
|
||||
|
||||
# Compute absolute and relative gain
|
||||
if size and size_opt:
|
||||
gain_abs = size - size_opt
|
||||
gain_rel = (1 - size_opt / size) * 100
|
||||
|
||||
# Print summary for files
|
||||
print(
|
||||
f" *{seek} {Fore.GREEN}{_size(size_opt)}"
|
||||
f"{Fore.WHITE}{Style.DIM} ↓ "
|
||||
f"{_size(gain_abs)} [{gain_rel:3.1f}%]"
|
||||
f"{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
# Reset all styles
|
||||
print(Style.RESET_ALL)
|
||||
|
||||
# Save manifest on shutdown
|
||||
def on_shutdown(self):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Shutdown thread pool - if we're on Python 3.9 and above, cancel all
|
||||
# pending futures that have not yet been scheduled
|
||||
if sys.version_info >= (3, 9):
|
||||
self.pool.shutdown(cancel_futures = True)
|
||||
else:
|
||||
self.pool.shutdown()
|
||||
|
||||
# Save manifest if cache should be used
|
||||
if self.manifest and self.config.cache:
|
||||
with open(self.manifest_file, "w") as f:
|
||||
f.write(json.dumps(self.manifest, indent = 2, sort_keys = True))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Check if a file can be optimized
|
||||
def _is_optimizable(self, file: File):
|
||||
|
||||
# Check if PNG images should be optimized
|
||||
if file.url.endswith((".png")):
|
||||
return self.config.optimize_png
|
||||
|
||||
# Check if JPG images should be optimized
|
||||
if file.url.endswith((".jpg", ".jpeg")):
|
||||
return self.config.optimize_jpg
|
||||
|
||||
# File can not be optimized by the plugin
|
||||
return False
|
||||
|
||||
# Check if the given file is excluded
|
||||
def _is_excluded(self, file: File):
|
||||
if not self._is_optimizable(file):
|
||||
return True
|
||||
|
||||
# Check if file matches one of the inclusion patterns
|
||||
path = file.src_path
|
||||
if self.config.optimize_include:
|
||||
for pattern in self.config.optimize_include:
|
||||
if fnmatch(file.src_uri, pattern):
|
||||
return False
|
||||
|
||||
# File is not included
|
||||
log.debug(f"Excluding file '{path}' due to inclusion patterns")
|
||||
return True
|
||||
|
||||
# Check if file matches one of the exclusion patterns
|
||||
for pattern in self.config.optimize_exclude:
|
||||
if fnmatch(file.src_uri, pattern):
|
||||
log.debug(f"Excluding file '{path}' due to exclusion patterns")
|
||||
return True
|
||||
|
||||
# File is not excluded
|
||||
return False
|
||||
|
||||
# Optimize image and write to cache
|
||||
def _optimize_image(self, file: File, path: str, config: MkDocsConfig):
|
||||
with open(file.abs_src_path, "rb") as f:
|
||||
data = f.read()
|
||||
hash = sha1(data).hexdigest()
|
||||
|
||||
# Check if file hash changed, so we need to optimize again
|
||||
prev = self.manifest.get(file.url, "")
|
||||
if hash != prev or not os.path.isfile(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
|
||||
# Optimize PNG image using pngquant
|
||||
if file.url.endswith((".png")):
|
||||
self._optimize_image_png(file, path, config)
|
||||
|
||||
# Optimize JPG image using pillow
|
||||
if file.url.endswith((".jpg", ".jpeg")):
|
||||
self._optimize_image_jpg(file, path, config)
|
||||
|
||||
# Compute size before and after optimization
|
||||
size = len(data)
|
||||
size_opt = os.path.getsize(path)
|
||||
|
||||
# Compute absolute and relative gain
|
||||
gain_abs = size - size_opt
|
||||
gain_rel = (1 - size_opt / size) * 100
|
||||
|
||||
# Print how much we gained, if we did and desired
|
||||
gain = ""
|
||||
if gain_abs and self.config.print_gain:
|
||||
gain += " ↓ "
|
||||
gain += " ".join([_size(gain_abs), f"[{gain_rel:3.1f}%]"])
|
||||
|
||||
# Print summary for file
|
||||
log.info(
|
||||
f"Optimized media file: {file.src_uri} "
|
||||
f"{Fore.GREEN}{_size(size_opt)}"
|
||||
f"{Fore.WHITE}{Style.DIM}{gain}"
|
||||
f"{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
# Update manifest by associating file with hash
|
||||
self.manifest[file.url] = hash
|
||||
|
||||
# Compute project root
|
||||
root = os.path.dirname(config.config_file_path)
|
||||
|
||||
# Compute source file system path
|
||||
file.abs_src_path = path
|
||||
file.src_path = os.path.relpath(path, root)
|
||||
|
||||
# Return file to be copied from cache
|
||||
return file
|
||||
|
||||
# Optimize PNG image - we first tried to use libimagequant, but encountered
|
||||
# the occassional segmentation fault, which means it's probably not a good
|
||||
# choice. Instead, we just rely on pngquant which seems much more stable.
|
||||
def _optimize_image_png(self, file: File, path: str, config: MkDocsConfig):
|
||||
|
||||
# Check if the required dependencies for optimizing are available, which
|
||||
# is, at the absolute minimum, the 'pngquant' binary, and raise an error
|
||||
# to the caller, so he can decide what to do with the error. The caller
|
||||
# can treat this as a warning or an error to abort the build.
|
||||
if not which("pngquant"):
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't optimize image '{path}' in '{docs}': 'pngquant' "
|
||||
f"not found. Make sure 'pngquant' is installed and in your path"
|
||||
)
|
||||
|
||||
# Build command line arguments
|
||||
args = ["pngquant",
|
||||
"--force", "--skip-if-larger",
|
||||
"--output", path,
|
||||
"--speed", f"{self.config.optimize_png_speed}"
|
||||
]
|
||||
|
||||
# Add flag to remove optional metadata
|
||||
if self.config.optimize_png_strip:
|
||||
args.append("--strip")
|
||||
|
||||
# Set input file and run, then check if pngquant actually wrote a file,
|
||||
# as we instruct it not to if the size of the optimized file is larger.
|
||||
# This can happen if files are already compressed and optimized by
|
||||
# the author. In that case, just copy the original file.
|
||||
subprocess.run([*args, file.abs_src_path])
|
||||
if not os.path.isfile(path):
|
||||
utils.copy_file(file.abs_src_path, path)
|
||||
|
||||
# Optimize JPG image
|
||||
def _optimize_image_jpg(self, file: File, path: str, config: MkDocsConfig):
|
||||
|
||||
# Check if the required dependencies for optimizing are available, which
|
||||
# is, at the absolute minimum, the 'pillow' package, and raise an error
|
||||
# to the caller, so he can decide what to do with the error. The caller
|
||||
# can treat this as a warning or an error to abort the build.
|
||||
if not _supports("Image"):
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't optimize image '{path}' in '{docs}': install "
|
||||
f"required dependencies – pip install 'mkdocs-material[imaging]'"
|
||||
)
|
||||
|
||||
# Open and save optimized image
|
||||
image = Image.open(file.abs_src_path)
|
||||
image.save(path, "jpeg",
|
||||
quality = self.config.optimize_jpg_quality,
|
||||
progressive = self.config.optimize_jpg_progressive
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check for presence of optional imports
|
||||
@functools.lru_cache(maxsize = None)
|
||||
def _supports(name: str):
|
||||
return name in globals()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Print human-readable size
|
||||
def _size(value):
|
||||
for unit in ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
|
||||
if abs(value) < 1000.0:
|
||||
return f"{value:3.1f} {unit}"
|
||||
value /= 1000.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.optimize")
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
# 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 mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import (
|
||||
Choice, Deprecated, DictOfItems, ListOfItems, Type
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for log level
|
||||
LogLevel = (
|
||||
"error",
|
||||
"warn",
|
||||
"info",
|
||||
"debug"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin configuration
|
||||
class PrivacyConfig(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/privacy")
|
||||
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = Choice(LogLevel, default = "info")
|
||||
|
||||
# Settings for external assets
|
||||
assets = Type(bool, default = True)
|
||||
assets_fetch = Type(bool, default = True)
|
||||
assets_fetch_dir = Type(str, default = "assets/external")
|
||||
assets_include = ListOfItems(Type(str), default = [])
|
||||
assets_exclude = ListOfItems(Type(str), default = [])
|
||||
assets_expr_map = DictOfItems(Type(str), default = {})
|
||||
|
||||
# Settings for external links
|
||||
links = Type(bool, default = True)
|
||||
links_attr_map = DictOfItems(Type(str), default = {})
|
||||
links_noopener = Type(bool, default = True)
|
||||
|
||||
# Deprecated settings
|
||||
external_assets = Deprecated(message = "Deprecated, use 'assets_fetch'")
|
||||
external_assets_dir = Deprecated(moved_to = "assets_fetch_dir")
|
||||
external_assets_include = Deprecated(moved_to = "assets_include")
|
||||
external_assets_exclude = Deprecated(moved_to = "assets_exclude")
|
||||
external_assets_expr = Deprecated(moved_to = "assets_expr_map")
|
||||
external_links = Deprecated(moved_to = "links")
|
||||
external_links_attr_map = Deprecated(moved_to = "links_attr_map")
|
||||
external_links_noopener = Deprecated(moved_to = "links_noopener")
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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 html.parser import HTMLParser
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Fragment parser - previously, we used lxml for fault-tolerant HTML5 parsing,
|
||||
# but it blows up the size of the Docker image by 20 MB. We can't just use the
|
||||
# built-in XML parser, as it doesn't handle HTML5 (because, yeah, it's not XML),
|
||||
# so we use a streaming parser and construct the element ourselves.
|
||||
class FragmentParser(HTMLParser):
|
||||
|
||||
# Initialize parser
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs = True)
|
||||
self.result = None
|
||||
|
||||
# Create element
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.result = Element(tag, dict(attrs))
|
||||
@@ -0,0 +1,680 @@
|
||||
# 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 errno
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
|
||||
from colorama import Fore, Style
|
||||
from concurrent.futures import Future, wait
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
from fnmatch import fnmatch
|
||||
from hashlib import sha1
|
||||
from mkdocs.config.config_options import ExtraScriptValue
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure.files import File, Files
|
||||
from mkdocs.utils import is_error_template
|
||||
from re import Match
|
||||
from urllib.parse import ParseResult as URL, urlparse, unquote
|
||||
from xml.etree.ElementTree import Element, tostring
|
||||
|
||||
from .config import PrivacyConfig
|
||||
from .parser import FragmentParser
|
||||
|
||||
DEFAULT_TIMEOUT_IN_SECS = 5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin
|
||||
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Initialize thread pools and asset collections
|
||||
def on_config(self, config):
|
||||
self.site = urlparse(config.site_url or "")
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize thread pool
|
||||
self.pool = ThreadPoolExecutor(self.config.concurrency)
|
||||
self.pool_jobs: list[Future] = []
|
||||
|
||||
# Initialize collections of external assets
|
||||
self.assets = Files([])
|
||||
self.assets_done: list[File] = []
|
||||
self.assets_expr_map = {
|
||||
".css": r"url\(\s*([\"']?)(?P<url>(?:https?:)?//[^)'\"]+)\1\s*\)",
|
||||
".js": r"[\"'](?P<url>(?:https?:)?//[^\"']+\.(?:css|js(?:on)?))[\"']",
|
||||
**self.config.assets_expr_map
|
||||
}
|
||||
|
||||
# Set log level or disable logging altogether - @todo when refactoring
|
||||
# this plugin for the next time, we should put this into a factory
|
||||
if not self.config.log:
|
||||
log.disabled = True
|
||||
else:
|
||||
log.setLevel(self.config.log_level.upper())
|
||||
|
||||
# Process external style sheets and scripts (run latest) - run this after
|
||||
# all other plugins, so they can add additional assets
|
||||
@event_priority(-100)
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return
|
||||
|
||||
# Find all external style sheet and script files that are provided as
|
||||
# part of the build (= already known to MkDocs on startup)
|
||||
for initiator in files.media_files():
|
||||
file = None
|
||||
|
||||
# Check if the file has dependent external assets that must be
|
||||
# downloaded. Create and enqueue a job for each external asset.
|
||||
for url in self._parse_media(initiator):
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config, concurrent = True)
|
||||
|
||||
# If site URL is not given, ensure that Mermaid.js is always
|
||||
# present. This is a special case, as Material for MkDocs
|
||||
# automatically loads Mermaid.js when a Mermaid diagram is
|
||||
# found in the page - https://bit.ly/36tZXsA.
|
||||
if "mermaid.min.js" in url.path and not config.site_url:
|
||||
script = ExtraScriptValue(url.geturl())
|
||||
if script not in config.extra_javascript:
|
||||
config.extra_javascript.append(script)
|
||||
|
||||
# The local asset references at least one external asset, which
|
||||
# means we must download and replace them later
|
||||
if file:
|
||||
self.assets.append(initiator)
|
||||
files.remove(initiator)
|
||||
|
||||
# Process external style sheet files
|
||||
for path in config.extra_css:
|
||||
url = urlparse(path)
|
||||
if not self._is_excluded(url):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external script files
|
||||
for script in config.extra_javascript:
|
||||
if isinstance(script, str):
|
||||
script = ExtraScriptValue(script)
|
||||
|
||||
# Enqueue a job if the script needs to downloaded
|
||||
url = urlparse(script.path)
|
||||
if not self._is_excluded(url):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Process external images in page (run latest) - this stage is the earliest
|
||||
# we can start processing external images, since images are the most common
|
||||
# type of external asset when writing. Thus, we create and enqueue a job for
|
||||
# each image we find that checks if the image needs to be downloaded. Also,
|
||||
# downloading all external images at this stage, we reconcile all concurrent
|
||||
# jobs in `on_env`, which is the stage in which the optimize plugin will
|
||||
# evaluate what images can and need to be optimized. This means we can pass
|
||||
# external images through the optimization pipeline. Additionally, we run
|
||||
# this after all other plugins, so we allow them to add additional images
|
||||
# to the content of the page. How cool is that?
|
||||
@event_priority(-100)
|
||||
def on_page_content(self, html, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return
|
||||
|
||||
# Find all external images and download them if not excluded
|
||||
for match in re.findall(
|
||||
r"<img[^>]+src=['\"]?(?:https?:)?//[^>]+>",
|
||||
html, flags = re.I | re.M
|
||||
):
|
||||
el = self._parse_fragment(match)
|
||||
|
||||
# Create and enqueue job to fetch external image
|
||||
url = urlparse(el.get("src"))
|
||||
if not self._is_excluded(url, page.file):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# Reconcile jobs and pass external assets to MkDocs (run earlier) - allow
|
||||
# other plugins (e.g. optimize plugin) to post-process external assets
|
||||
@event_priority(50)
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
|
||||
# same thread pool for fetching all remaining external assets
|
||||
wait(self.pool_jobs)
|
||||
self.pool_jobs.clear()
|
||||
|
||||
# Append all downloaded assets that are not style sheets or scripts to
|
||||
# MkDocs's collection of files, making them available to other plugins
|
||||
# for further processing. The remaining exteral assets are patched
|
||||
# before copying, which is done at the end of the build process.
|
||||
for file in self.assets:
|
||||
_, extension = posixpath.splitext(file.dest_uri)
|
||||
if extension not in [".css", ".js"]:
|
||||
self.assets_done.append(file)
|
||||
files.append(file)
|
||||
|
||||
# Process external assets in template (run later)
|
||||
@event_priority(-50)
|
||||
def on_post_template(self, output_content, *, template_name, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip sitemap.xml and other non-HTML files
|
||||
if not template_name.endswith(".html"):
|
||||
return
|
||||
|
||||
# Parse and replace links to external assets in template
|
||||
initiator = File(template_name, config.docs_dir, config.site_dir, False)
|
||||
return self._parse_html(output_content, initiator, config)
|
||||
|
||||
# Process external assets in page (run later)
|
||||
@event_priority(-50)
|
||||
def on_post_page(self, output, *, page, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Parse and replace links to external assets
|
||||
return self._parse_html(output, page.file, config)
|
||||
|
||||
# Reconcile jobs (run earlier) - allow other plugins (e.g. optimize plugin)
|
||||
# to process all downloaded assets, which is why we must reconcile here
|
||||
@event_priority(50)
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Reconcile concurrent jobs and clear thread pool, as we will reuse the
|
||||
# same thread pool for patching all links to external assets
|
||||
wait(self.pool_jobs)
|
||||
self.pool_jobs.clear()
|
||||
|
||||
# Spawn concurrent job to patch all links to dependent external asset
|
||||
# in all style sheet and script files
|
||||
for file in self.assets:
|
||||
_, extension = posixpath.splitext(file.dest_uri)
|
||||
if extension in [".css", ".js"]:
|
||||
self.pool_jobs.append(self.pool.submit(
|
||||
self._patch, file
|
||||
))
|
||||
|
||||
# Otherwise just copy external asset to output directory, if we
|
||||
# haven't handed control to MkDocs in `on_env` before
|
||||
elif file not in self.assets_done:
|
||||
if os.path.exists(str(file.abs_src_path)):
|
||||
file.copy_file()
|
||||
|
||||
# Reconcile concurrent jobs for the last time, so the plugins following
|
||||
# in the build process always have a consistent state to work with
|
||||
wait(self.pool_jobs)
|
||||
self.pool.shutdown()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Check if the given URL is external
|
||||
def _is_external(self, url: URL):
|
||||
hostname = url.hostname or self.site.hostname
|
||||
return hostname != self.site.hostname
|
||||
|
||||
# Check if the given URL is excluded
|
||||
def _is_excluded(self, url: URL, initiator: File | None = None):
|
||||
if not self._is_external(url):
|
||||
return True
|
||||
|
||||
# Skip if external assets must not be processed
|
||||
if not self.config.assets:
|
||||
return True
|
||||
|
||||
# If initiator is given, format for printing
|
||||
via = ""
|
||||
if initiator:
|
||||
via = "".join([
|
||||
Fore.WHITE, Style.DIM,
|
||||
f"in '{initiator.src_uri}' ",
|
||||
Style.RESET_ALL
|
||||
])
|
||||
|
||||
# Check if URL matches one of the inclusion patterns
|
||||
if self.config.assets_include:
|
||||
for pattern in self.config.assets_include:
|
||||
if fnmatch(self._path_from_url(url), pattern):
|
||||
return False
|
||||
|
||||
# File is not included
|
||||
log.debug(
|
||||
f"Excluding external file '{url.geturl()}' {via}due to "
|
||||
f"inclusion patterns"
|
||||
)
|
||||
return True
|
||||
|
||||
# Check if URL matches one of the exclusion patterns
|
||||
for pattern in self.config.assets_exclude:
|
||||
if fnmatch(self._path_from_url(url), pattern):
|
||||
log.debug(
|
||||
f"Excluding external file '{url.geturl()}' {via}due to "
|
||||
f"exclusion patterns"
|
||||
)
|
||||
return True
|
||||
|
||||
# Print warning if fetching is not enabled
|
||||
if not self.config.assets_fetch:
|
||||
log.warning(f"External file: {url.geturl()} {via}")
|
||||
return True
|
||||
|
||||
# File is not excluded
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Parse a fragment
|
||||
def _parse_fragment(self, fragment: str):
|
||||
parser = FragmentParser()
|
||||
parser.feed(fragment)
|
||||
parser.close()
|
||||
|
||||
# Check parse result and return element
|
||||
if isinstance(parser.result, Element):
|
||||
return parser.result
|
||||
|
||||
# Otherwise, raise a plugin error - if the author accidentally used
|
||||
# invalid HTML inside of the tag, e.g., forget a opening or closing
|
||||
# quote, we need to catch this here, as we're using pretty basic
|
||||
# regular expression based extraction
|
||||
raise PluginError(
|
||||
"Couldn't parse due to possible syntax error in HTML: \n\n"
|
||||
+ fragment
|
||||
)
|
||||
|
||||
# Parse and extract all external assets from a media file using a preset
|
||||
# regular expression, and return all URLs found.
|
||||
def _parse_media(self, initiator: File) -> list[URL]:
|
||||
_, extension = posixpath.splitext(initiator.dest_uri)
|
||||
if extension not in self.assets_expr_map:
|
||||
return []
|
||||
|
||||
# Skip if source path is not set, which might be true for generated
|
||||
# files or for files that were added programatically in plugins
|
||||
if not initiator.abs_src_path:
|
||||
return []
|
||||
|
||||
# Find and extract all external asset URLs
|
||||
expr = re.compile(self.assets_expr_map[extension], flags = re.I | re.M)
|
||||
with open(initiator.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
results = re.finditer(expr, f.read())
|
||||
return [urlparse(result.group("url")) for result in results]
|
||||
|
||||
# Parse template or page HTML and find all external links that need to be
|
||||
# replaced. Many of the assets should already be downloaded earlier, i.e.,
|
||||
# everything that was directly referenced in the document, but there may
|
||||
# still exist external assets that were added by third-party plugins.
|
||||
def _parse_html(self, output: str, initiator: File, config: MkDocsConfig):
|
||||
|
||||
# Resolve callback
|
||||
def resolve(file: File):
|
||||
if is_error_template(initiator.src_uri):
|
||||
base = urlparse(config.site_url or "/")
|
||||
return posixpath.join(base.path, file.url)
|
||||
else:
|
||||
return file.url_relative_to(initiator)
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
el = self._parse_fragment(match.group())
|
||||
|
||||
# Handle external link
|
||||
if self.config.links and el.tag == "a":
|
||||
for key, value in self.config.links_attr_map.items():
|
||||
el.set(key, value)
|
||||
|
||||
# Set `rel=noopener` if link opens in a new window
|
||||
if self.config.links_noopener:
|
||||
if el.get("target") == "_blank":
|
||||
rel = re.findall(r"\S+", el.get("rel", ""))
|
||||
if "noopener" not in rel:
|
||||
rel.append("noopener")
|
||||
|
||||
# Set relationships after adding `noopener`
|
||||
el.set("rel", " ".join(rel))
|
||||
|
||||
# Handle external style sheet or preconnect hint
|
||||
if el.tag == "link":
|
||||
url = urlparse(el.get("href"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
rel = el.get("rel", "")
|
||||
|
||||
# Replace external preconnect hint
|
||||
if rel == "preconnect":
|
||||
return ""
|
||||
|
||||
# Replace external favicon, preload hint or style sheet
|
||||
if rel in ("icon", "preload", "stylesheet"):
|
||||
file = self._queue(url, config)
|
||||
if file:
|
||||
el.set("href", resolve(file))
|
||||
|
||||
# Handle external script or image
|
||||
if el.tag == "script" or el.tag == "img":
|
||||
url = urlparse(el.get("src"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config)
|
||||
if file:
|
||||
el.set("src", resolve(file))
|
||||
|
||||
# Handle external image in SVG
|
||||
if el.tag == "image":
|
||||
url = urlparse(el.get("href"))
|
||||
if not self._is_excluded(url, initiator):
|
||||
file = self._queue(url, config)
|
||||
if file:
|
||||
el.set("href", resolve(file))
|
||||
|
||||
# Return element as string
|
||||
return self._print(el)
|
||||
|
||||
# Find and replace all external asset URLs in current page
|
||||
return re.sub(
|
||||
r"<(?:(?:a|link|image)[^>]+href|(?:script|img)[^>]+src)=['\"]?(?:https?:)?//[^>]+>",
|
||||
replace, output, flags = re.I | re.M
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Print element as string - what could possibly go wrong? We're parsing
|
||||
# HTML5 with an XML parser, and XML doesn't allow for boolean attributes,
|
||||
# which is why we must add a dummy value to all attributes that are not
|
||||
# strings before printing the element as string.
|
||||
def _print(self, el: Element):
|
||||
temp = "__temp__"
|
||||
for name in el.attrib:
|
||||
if not isinstance(el.attrib[name], str):
|
||||
el.attrib[name] = temp
|
||||
|
||||
# Return void or opening tag as string, strip closing tag
|
||||
data = tostring(el, encoding = "unicode")
|
||||
return data.replace(" />", ">").replace(f"=\"{temp}\"", "")
|
||||
|
||||
# Enqueue external asset for download, if not already done
|
||||
def _queue(self, url: URL, config: MkDocsConfig, concurrent = False):
|
||||
path = self._path_from_url(url)
|
||||
full = posixpath.join(self.config.assets_fetch_dir, path)
|
||||
|
||||
# Try to retrieve existing file
|
||||
file = self.assets.get_file_from_path(full)
|
||||
if not file:
|
||||
|
||||
# Compute path to external asset, which is sourced from the cache
|
||||
# directory, and generate file to register it with MkDocs as soon
|
||||
# as it was downloaded. This allows other plugins to apply
|
||||
# additional processing.
|
||||
file = self._path_to_file(path, config)
|
||||
file.url = url.geturl()
|
||||
|
||||
# Spawn concurrent job to fetch external asset if the extension is
|
||||
# known and the concurrent flag is set. In that case, this function
|
||||
# is called in a context where no replacements are carried out, so
|
||||
# the caller must only ensure to reconcile the concurrent jobs.
|
||||
_, extension = posixpath.splitext(url.path)
|
||||
if extension and concurrent:
|
||||
self.pool_jobs.append(self.pool.submit(
|
||||
self._fetch, file, config
|
||||
))
|
||||
|
||||
# Fetch external asset synchronously, as it either has no extension
|
||||
# or is fetched from a context in which replacements are done
|
||||
else:
|
||||
if not self._fetch(file, config):
|
||||
return None
|
||||
|
||||
# Register external asset as file - it might have already been
|
||||
# registered, and since MkDocs 1.6, trigger a deprecation warning
|
||||
if not self.assets.get_file_from_path(file.src_uri):
|
||||
self.assets.append(file)
|
||||
|
||||
# If the URL of the external asset includes a hash fragment, add it to
|
||||
# the returned file, e.g. for dark/light images - see https://t.ly/7b16Y
|
||||
if url.fragment:
|
||||
file.url += f"#{url.fragment}"
|
||||
|
||||
# Return file associated with external asset
|
||||
return file
|
||||
|
||||
# Fetch external asset referenced through the given file
|
||||
def _fetch(self, file: File, config: MkDocsConfig):
|
||||
|
||||
# Check if external asset needs to be downloaded
|
||||
if not os.path.isfile(file.abs_src_path) or not self.config.cache:
|
||||
path = file.abs_src_path
|
||||
|
||||
# In case the URL is a protocol-relative URL that starts with `//`,
|
||||
# we prepend `http:` as a scheme, assuming that all external assets
|
||||
# are available via HTTP. If we'd require `https:`, some external
|
||||
# assets might not be fetchable.
|
||||
if file.url.startswith("//"):
|
||||
file.url = f"http:{file.url}"
|
||||
|
||||
# Download external asset
|
||||
log.info(f"Downloading external file: {file.url}")
|
||||
try:
|
||||
res = requests.get(
|
||||
file.url,
|
||||
headers = {
|
||||
# Set user agent explicitly, so Google Fonts gives us
|
||||
# *.woff2 files, which according to caniuse.com is the
|
||||
# only format we need to download as it covers the range
|
||||
# range of browsers we're officially supporting.
|
||||
"User-Agent": " ".join(
|
||||
[
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko)",
|
||||
"Chrome/98.0.4758.102 Safari/537.36",
|
||||
]
|
||||
)
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT_IN_SECS,
|
||||
)
|
||||
res.raise_for_status()
|
||||
|
||||
# Intercept errors of type `ConnectionError` and `HTTPError`
|
||||
except Exception as error:
|
||||
log.warning(f"Couldn't retrieve {file.url}: {error}")
|
||||
return False
|
||||
|
||||
# Compute expected file extension and append if missing
|
||||
mime = res.headers["content-type"].split(";")[0]
|
||||
extension = extensions.get(mime)
|
||||
if extension and not path.endswith(extension):
|
||||
path += extension
|
||||
|
||||
# Save to file and create symlink if no extension was present
|
||||
self._save_to_file(path, res.content)
|
||||
if path != file.abs_src_path:
|
||||
|
||||
# Creating symlinks might fail on Windows. Thus, we just print
|
||||
# a warning and continue - see https://bit.ly/3xYFzcZ
|
||||
try:
|
||||
os.symlink(os.path.basename(path), file.abs_src_path)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EEXIST:
|
||||
log.warning(
|
||||
f"Couldn't create symbolic link: {file.src_uri}"
|
||||
)
|
||||
|
||||
# Fall back for when the symlink could not be created. This
|
||||
# means that the plugin will download the original file on
|
||||
# every build, as the content type cannot be resolved from
|
||||
# the file extension.
|
||||
file.abs_src_path = path
|
||||
|
||||
# Resolve destination if file points to a symlink
|
||||
_, extension = os.path.splitext(file.abs_src_path)
|
||||
if os.path.isfile(file.abs_src_path):
|
||||
file.abs_src_path = os.path.realpath(file.abs_src_path)
|
||||
_, extension = os.path.splitext(file.abs_src_path)
|
||||
|
||||
# If the symlink could not be created, we already set the correct
|
||||
# extension, so we need to make sure not to append it again
|
||||
if not file.abs_dest_path.endswith(extension):
|
||||
file.src_uri += extension
|
||||
|
||||
# Compute destination file system path
|
||||
file.dest_uri += extension
|
||||
file.abs_dest_path += extension
|
||||
|
||||
# Compute destination URL
|
||||
file.url = file.dest_uri
|
||||
|
||||
# Parse and enqueue dependent external assets
|
||||
for url in self._parse_media(file):
|
||||
if not self._is_excluded(url, file):
|
||||
self._queue(url, config, concurrent = True)
|
||||
|
||||
# External asset was successfully downloaded
|
||||
return True
|
||||
|
||||
# Patch all links to external assets in the given file
|
||||
def _patch(self, initiator: File):
|
||||
with open(initiator.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
value = match.group("url")
|
||||
|
||||
# Map URL to canonical path
|
||||
path = self._path_from_url(urlparse(value))
|
||||
full = posixpath.join(self.config.assets_fetch_dir, path)
|
||||
|
||||
# Try to retrieve existing file
|
||||
file = self.assets.get_file_from_path(full)
|
||||
if not file:
|
||||
name = os.readlink(os.path.join(self.config.cache_dir, full))
|
||||
full = posixpath.join(posixpath.dirname(full), name)
|
||||
|
||||
# Try again after resolving symlink
|
||||
file = self.assets.get_file_from_path(full)
|
||||
|
||||
# This can theoretically never happen, as we're sure that we
|
||||
# only replace files that we successfully extracted. However,
|
||||
# we might have missed several cases, so it's better to throw
|
||||
# here than to swallow the error.
|
||||
if not file:
|
||||
log.error(
|
||||
"File not found. This is likely a bug in the built-in "
|
||||
"privacy plugin. Please create an issue with a minimal "
|
||||
"reproduction."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Create absolute URL for asset in script
|
||||
if file.url.endswith(".js"):
|
||||
url = posixpath.join(self.site.geturl(), file.url)
|
||||
|
||||
# Create relative URL for everything else
|
||||
else:
|
||||
url = file.url_relative_to(initiator)
|
||||
|
||||
# Switch external asset URL to local path
|
||||
return match.group().replace(value, url)
|
||||
|
||||
# Resolve replacement expression according to asset type
|
||||
_, extension = posixpath.splitext(initiator.dest_uri)
|
||||
expr = re.compile(self.assets_expr_map[extension], re.I | re.M)
|
||||
|
||||
# Resolve links to external assets in file
|
||||
self._save_to_file(
|
||||
initiator.abs_dest_path,
|
||||
expr.sub(replace, f.read())
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Normalize (= canonicalize) path by removing trailing slashes, and ensure
|
||||
# that hidden folders (`.` after `/`) are unhidden. Otherwise MkDocs will
|
||||
# not consider them being part of the build and refuse to copy them.
|
||||
def _path_from_url(self, url: URL):
|
||||
path = posixpath.normpath(url.path)
|
||||
path = re.sub(r"/\.", "/_", path)
|
||||
|
||||
# Compute digest of query string, as some URLs yield different results
|
||||
# for different query strings, e.g. https://unsplash.com/random?Coffee
|
||||
if url.query:
|
||||
name, extension = posixpath.splitext(path)
|
||||
|
||||
# Inject digest after file name and before file extension, as
|
||||
# done for style sheet and script files as well
|
||||
digest = sha1(url.query.encode("utf-8")).hexdigest()[:8]
|
||||
path = f"{name}.{digest}{extension}"
|
||||
|
||||
# Create and return URL without leading double slashes
|
||||
url = url._replace(scheme = "", query = "", fragment = "", path = path)
|
||||
return url.geturl()[2:]
|
||||
|
||||
# Create a file for the given path
|
||||
def _path_to_file(self, path: str, config: MkDocsConfig):
|
||||
return File(
|
||||
posixpath.join(self.config.assets_fetch_dir, unquote(path)),
|
||||
os.path.abspath(self.config.cache_dir),
|
||||
config.site_dir,
|
||||
False
|
||||
)
|
||||
|
||||
# Create a file on the system with the given content
|
||||
def _save_to_file(self, path: str, content: str | bytes):
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
if isinstance(content, str):
|
||||
content = bytes(content, "utf-8")
|
||||
with open(path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.privacy")
|
||||
|
||||
# Expected file extensions
|
||||
extensions = {
|
||||
"application/javascript": ".js",
|
||||
"image/avif": ".avif",
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/svg+xml": ".svg",
|
||||
"image/webp": ".webp",
|
||||
"text/javascript": ".js",
|
||||
"text/css": ".css"
|
||||
}
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+320
@@ -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")
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
@@ -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)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+105
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
BIN
Binary file not shown.
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
# 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 (
|
||||
Choice,
|
||||
Deprecated,
|
||||
Optional,
|
||||
ListOfItems,
|
||||
Type
|
||||
)
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.contrib.search import LangOption
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for search pipeline
|
||||
pipeline = ("stemmer", "stopWordFilter", "trimmer")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Search field configuration
|
||||
class SearchFieldConfig(Config):
|
||||
boost = Type((int, float), default = 1.0)
|
||||
|
||||
# Search plugin configuration
|
||||
class SearchConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for search
|
||||
lang = Optional(LangOption())
|
||||
separator = Optional(Type(str))
|
||||
pipeline = Optional(ListOfItems(Choice(pipeline)))
|
||||
fields = Type(dict, default = {})
|
||||
|
||||
# Settings for text segmentation (Chinese)
|
||||
jieba_dict = Optional(Type(str))
|
||||
jieba_dict_user = Optional(Type(str))
|
||||
|
||||
# Unsupported settings, originally implemented in MkDocs
|
||||
indexing = Deprecated(message = "Unsupported option")
|
||||
prebuild_index = Deprecated(message = "Unsupported option")
|
||||
min_search_length = Deprecated(message = "Unsupported option")
|
||||
@@ -0,0 +1,594 @@
|
||||
# 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 json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from backrefs import bre
|
||||
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from mkdocs import utils
|
||||
from mkdocs.config.config_options import SubConfig
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from .config import SearchConfig, SearchFieldConfig
|
||||
|
||||
try:
|
||||
import jieba
|
||||
except ImportError:
|
||||
jieba = None
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Search plugin
|
||||
class SearchPlugin(BasePlugin[SearchConfig]):
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_dirty = False
|
||||
self.is_dirtyreload = False
|
||||
|
||||
# Initialize search index cache
|
||||
self.search_index_prev = None
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_dirty = dirty
|
||||
|
||||
# Initialize plugin
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Retrieve default value for language
|
||||
if not self.config.lang:
|
||||
self.config.lang = [self._translate(
|
||||
config, "search.config.lang"
|
||||
)]
|
||||
|
||||
# Retrieve default value for separator
|
||||
if not self.config.separator:
|
||||
self.config.separator = self._translate(
|
||||
config, "search.config.separator"
|
||||
)
|
||||
|
||||
# Retrieve default value for pipeline
|
||||
if self.config.pipeline is None:
|
||||
self.config.pipeline = list(filter(len, re.split(
|
||||
r"\s*,\s*", self._translate(config, "search.config.pipeline")
|
||||
)))
|
||||
|
||||
# Validate field configuration
|
||||
validator = SubConfig(SearchFieldConfig)
|
||||
for config in self.config.fields.values():
|
||||
validator.run_validation(config)
|
||||
|
||||
# Merge with default fields
|
||||
if "title" not in self.config.fields:
|
||||
self.config.fields["title"] = { "boost": 1e3 }
|
||||
if "text" not in self.config.fields:
|
||||
self.config.fields["text"] = { "boost": 1e0 }
|
||||
if "tags" not in self.config.fields:
|
||||
self.config.fields["tags"] = { "boost": 1e6 }
|
||||
|
||||
# Initialize search index
|
||||
self.search_index = SearchIndex(**self.config)
|
||||
|
||||
# Set jieba dictionary, if given
|
||||
if self.config.jieba_dict:
|
||||
path = os.path.normpath(self.config.jieba_dict)
|
||||
if os.path.isfile(path):
|
||||
jieba.set_dictionary(path)
|
||||
log.debug(f"Loading jieba dictionary: {path}")
|
||||
else:
|
||||
log.warning(
|
||||
f"Configuration error for 'search.jieba_dict': "
|
||||
f"'{self.config.jieba_dict}' does not exist."
|
||||
)
|
||||
|
||||
# Set jieba user dictionary, if given
|
||||
if self.config.jieba_dict_user:
|
||||
path = os.path.normpath(self.config.jieba_dict_user)
|
||||
if os.path.isfile(path):
|
||||
jieba.load_userdict(path)
|
||||
log.debug(f"Loading jieba user dictionary: {path}")
|
||||
else:
|
||||
log.warning(
|
||||
f"Configuration error for 'search.jieba_dict_user': "
|
||||
f"'{self.config.jieba_dict_user}' does not exist."
|
||||
)
|
||||
|
||||
# Add page to search index
|
||||
def on_page_context(self, context, *, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Index page
|
||||
self.search_index.add_entry_from_context(page)
|
||||
page.content = re.sub(
|
||||
r"\s?data-search-\w+=\"[^\"]+\"",
|
||||
"",
|
||||
page.content
|
||||
)
|
||||
|
||||
# Generate search index
|
||||
def on_post_build(self, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Write search index
|
||||
base = os.path.join(config.site_dir, "search")
|
||||
path = os.path.join(base, "search_index.json")
|
||||
|
||||
# Generate and write search index to file
|
||||
data = self.search_index.generate_search_index(self.search_index_prev)
|
||||
utils.write_file(data.encode("utf-8"), path)
|
||||
|
||||
# Persist search index for repeated invocation
|
||||
if self.is_dirty:
|
||||
self.search_index_prev = self.search_index
|
||||
|
||||
# Determine whether we're running under dirty reload
|
||||
def on_serve(self, server, *, config, builder):
|
||||
self.is_dirtyreload = self.is_dirty
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Translate the given placeholder value
|
||||
def _translate(self, config, value):
|
||||
env = config.theme.get_env()
|
||||
|
||||
# Load language template and return translation for placeholder
|
||||
language = "partials/language.html"
|
||||
template = env.get_template(language, None, { "config": config })
|
||||
return template.module.t(value)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Search index with support for additional fields
|
||||
class SearchIndex:
|
||||
|
||||
# Initialize search index
|
||||
def __init__(self, **config):
|
||||
self.config = config
|
||||
self.entries = []
|
||||
|
||||
# Add page to search index
|
||||
def add_entry_from_context(self, page):
|
||||
search = page.meta.get("search") or {}
|
||||
if search.get("exclude"):
|
||||
return
|
||||
|
||||
# Divide page content into sections
|
||||
parser = Parser()
|
||||
parser.feed(page.content)
|
||||
parser.close()
|
||||
|
||||
# Add sections to index
|
||||
for section in parser.data:
|
||||
if not section.is_excluded():
|
||||
self.create_entry_for_section(section, page.toc, page.url, page)
|
||||
|
||||
# Override: graceful indexing and additional fields
|
||||
def create_entry_for_section(self, section, toc, url, page):
|
||||
item = self._find_toc_by_id(toc, section.id)
|
||||
if item:
|
||||
url = url + item.url
|
||||
elif section.id:
|
||||
url = url + "#" + section.id
|
||||
|
||||
# Set page title as section title if none was given, which happens when
|
||||
# the first headline in a Markdown document is not a h1 headline. Also,
|
||||
# if a page title was set via front matter, use that even though a h1
|
||||
# might be given or the page name was specified in nav in mkdocs.yml
|
||||
if not section.title:
|
||||
section.title = [str(page.meta.get("title", page.title))]
|
||||
|
||||
# Compute title and text
|
||||
title = "".join(section.title).strip()
|
||||
text = "".join(section.text).strip()
|
||||
|
||||
# Segment Chinese characters if jieba is available
|
||||
if jieba:
|
||||
title = self._segment_chinese(title)
|
||||
text = self._segment_chinese(text)
|
||||
|
||||
# Create entry for section
|
||||
entry = {
|
||||
"location": url,
|
||||
"title": title,
|
||||
"text": text
|
||||
}
|
||||
|
||||
# Set document tags
|
||||
tags = page.meta.get("tags")
|
||||
if isinstance(tags, list):
|
||||
entry["tags"] = []
|
||||
for name in tags:
|
||||
if name and isinstance(name, (str, int, float, bool)):
|
||||
entry["tags"].append(str(name))
|
||||
|
||||
# Set document boost
|
||||
search = page.meta.get("search") or {}
|
||||
if "boost" in search:
|
||||
entry["boost"] = search["boost"]
|
||||
|
||||
# Add entry to index
|
||||
self.entries.append(entry)
|
||||
|
||||
# Generate search index
|
||||
def generate_search_index(self, prev):
|
||||
config = {
|
||||
key: self.config[key]
|
||||
for key in ["lang", "separator", "pipeline", "fields"]
|
||||
}
|
||||
|
||||
# Hack: if we're running under dirty reload, the search index will only
|
||||
# include the entries for the current page. However, MkDocs > 1.4 allows
|
||||
# us to persist plugin state across rebuilds, which is exactly what we
|
||||
# do by passing the previously built index to this method. Thus, we just
|
||||
# remove the previous entries for the current page, and append the new
|
||||
# entries to the end of the index, as order doesn't matter.
|
||||
if prev and self.entries:
|
||||
path = self.entries[0]["location"]
|
||||
|
||||
# Since we're sure that we're running under dirty reload, the list
|
||||
# of entries will only contain sections for a single page. Thus, we
|
||||
# use the first entry to remove all entries from the previous run
|
||||
# that belong to the current page. The rationale behind this is that
|
||||
# authors might add or remove section headers, so we need to make
|
||||
# sure that sections are synchronized correctly.
|
||||
entries = [
|
||||
entry for entry in prev.entries
|
||||
if not entry["location"].startswith(path)
|
||||
]
|
||||
|
||||
# Merge previous with current entries
|
||||
self.entries = entries + self.entries
|
||||
|
||||
# Otherwise just set previous entries
|
||||
if prev and not self.entries:
|
||||
self.entries = prev.entries
|
||||
|
||||
# Return search index as JSON
|
||||
data = { "config": config, "docs": self.entries }
|
||||
return json.dumps(
|
||||
data,
|
||||
separators = (",", ":"),
|
||||
default = str
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Retrieve item for anchor
|
||||
def _find_toc_by_id(self, toc, id):
|
||||
for toc_item in toc:
|
||||
if toc_item.id == id:
|
||||
return toc_item
|
||||
|
||||
# Recurse into children of item
|
||||
toc_item = self._find_toc_by_id(toc_item.children, id)
|
||||
if toc_item is not None:
|
||||
return toc_item
|
||||
|
||||
# No item found
|
||||
return None
|
||||
|
||||
# Find and segment Chinese characters in string
|
||||
def _segment_chinese(self, data):
|
||||
expr = bre.compile(r"(\p{script: Han}+)", bre.UNICODE)
|
||||
|
||||
# Replace callback
|
||||
def replace(match):
|
||||
value = match.group(0)
|
||||
|
||||
# Replace occurrence in original string with segmented version and
|
||||
# surround with zero-width whitespace for efficient indexing
|
||||
return "".join([
|
||||
"\u200b",
|
||||
"\u200b".join(jieba.cut(value.encode("utf-8"))),
|
||||
"\u200b",
|
||||
])
|
||||
|
||||
# Return string with segmented occurrences
|
||||
return expr.sub(replace, data).strip("\u200b")
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# HTML element
|
||||
class Element:
|
||||
"""
|
||||
An element with attributes, essentially a small wrapper object for the
|
||||
parser to access attributes in other callbacks than handle_starttag.
|
||||
"""
|
||||
|
||||
# Initialize HTML element
|
||||
def __init__(self, tag, attrs = None):
|
||||
self.tag = tag
|
||||
self.attrs = attrs or {}
|
||||
|
||||
# String representation
|
||||
def __repr__(self):
|
||||
return self.tag
|
||||
|
||||
# Support comparison (compare by tag only)
|
||||
def __eq__(self, other):
|
||||
if other is Element:
|
||||
return self.tag == other.tag
|
||||
else:
|
||||
return self.tag == other
|
||||
|
||||
# Support set operations
|
||||
def __hash__(self):
|
||||
return hash(self.tag)
|
||||
|
||||
# Check whether the element should be excluded
|
||||
def is_excluded(self):
|
||||
return "data-search-exclude" in self.attrs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# HTML section
|
||||
class Section:
|
||||
"""
|
||||
A block of text with markup, preceded by a title (with markup), i.e., a
|
||||
headline with a certain level (h1-h6). Internally used by the parser.
|
||||
"""
|
||||
|
||||
# Initialize HTML section
|
||||
def __init__(self, el, depth = 0):
|
||||
self.el = el
|
||||
self.depth = depth
|
||||
|
||||
# Initialize section data
|
||||
self.text = []
|
||||
self.title = []
|
||||
self.id = None
|
||||
|
||||
# String representation
|
||||
def __repr__(self):
|
||||
if self.id:
|
||||
return "#".join([self.el.tag, self.id])
|
||||
else:
|
||||
return self.el.tag
|
||||
|
||||
# Check whether the section should be excluded
|
||||
def is_excluded(self):
|
||||
return self.el.is_excluded()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# HTML parser
|
||||
class Parser(HTMLParser):
|
||||
"""
|
||||
This parser divides the given string of HTML into a list of sections, each
|
||||
of which are preceded by a h1-h6 level heading. A white- and blacklist of
|
||||
tags dictates which tags should be preserved as part of the index, and
|
||||
which should be ignored in their entirety.
|
||||
"""
|
||||
|
||||
# Initialize HTML parser
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Tags to skip
|
||||
self.skip = set([
|
||||
"object", # Objects
|
||||
"script", # Scripts
|
||||
"style" # Styles
|
||||
])
|
||||
|
||||
# Tags to keep
|
||||
self.keep = set([
|
||||
"p", # Paragraphs
|
||||
"code", "pre", # Code blocks
|
||||
"li", "ol", "ul", # Lists
|
||||
"sub", "sup" # Sub- and superscripts
|
||||
])
|
||||
|
||||
# Current context and section
|
||||
self.context = []
|
||||
self.section = None
|
||||
|
||||
# All parsed sections
|
||||
self.data = []
|
||||
|
||||
# Called at the start of every HTML tag
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
|
||||
# Ignore self-closing tags
|
||||
el = Element(tag, attrs)
|
||||
if not tag in void:
|
||||
self.context.append(el)
|
||||
else:
|
||||
return
|
||||
|
||||
# Handle heading
|
||||
if tag in ([f"h{x}" for x in range(1, 7)]):
|
||||
depth = len(self.context)
|
||||
if "id" in attrs:
|
||||
|
||||
# Ensure top-level section
|
||||
if tag != "h1" and not self.data:
|
||||
self.section = Section(Element("hx"), depth)
|
||||
self.data.append(self.section)
|
||||
|
||||
# Set identifier, if not first section
|
||||
self.section = Section(el, depth)
|
||||
if self.data:
|
||||
self.section.id = attrs["id"]
|
||||
|
||||
# Append section to list
|
||||
self.data.append(self.section)
|
||||
|
||||
# Handle preface - ensure top-level section
|
||||
if not self.section:
|
||||
self.section = Section(Element("hx"))
|
||||
self.data.append(self.section)
|
||||
|
||||
# Handle special cases to skip
|
||||
for key, value in attrs.items():
|
||||
|
||||
# Skip block if explicitly excluded from search
|
||||
if key == "data-search-exclude":
|
||||
self.skip.add(el)
|
||||
return
|
||||
|
||||
# Skip line numbers - see https://bit.ly/3GvubZx
|
||||
if key == "class" and value == "linenodiv":
|
||||
self.skip.add(el)
|
||||
return
|
||||
|
||||
# Render opening tag if kept
|
||||
if not self.skip.intersection(self.context) and tag in self.keep:
|
||||
|
||||
# Check whether we're inside the section title
|
||||
data = self.section.text
|
||||
if self.section.el in self.context:
|
||||
data = self.section.title
|
||||
|
||||
# Append to section title or text
|
||||
data.append(f"<{tag}>")
|
||||
|
||||
# Called at the end of every HTML tag
|
||||
def handle_endtag(self, tag):
|
||||
if not self.context or self.context[-1] != tag:
|
||||
return
|
||||
|
||||
# Check whether we're exiting the current context, which happens when
|
||||
# a headline is nested in another element. In that case, we close the
|
||||
# current section, continuing to append data to the previous section,
|
||||
# which could also be a nested section – see https://bit.ly/3IxxIJZ
|
||||
if self.section.depth > len(self.context):
|
||||
for section in reversed(self.data):
|
||||
if section.depth <= len(self.context):
|
||||
|
||||
# Set depth to infinity in order to denote that the current
|
||||
# section is exited and must never be considered again.
|
||||
self.section.depth = float("inf")
|
||||
self.section = section
|
||||
break
|
||||
|
||||
# Remove element from skip list
|
||||
el = self.context.pop()
|
||||
if el in self.skip:
|
||||
if el.tag not in ["script", "style", "object"]:
|
||||
self.skip.remove(el)
|
||||
return
|
||||
|
||||
# Render closing tag if kept
|
||||
if not self.skip.intersection(self.context) and tag in self.keep:
|
||||
|
||||
# Check whether we're inside the section title
|
||||
data = self.section.text
|
||||
if self.section.el in self.context:
|
||||
data = self.section.title
|
||||
|
||||
# Search for corresponding opening tag
|
||||
index = data.index(f"<{tag}>")
|
||||
for i in range(index + 1, len(data)):
|
||||
if not data[i].isspace():
|
||||
index = len(data)
|
||||
break
|
||||
|
||||
# Remove element if empty (or only whitespace)
|
||||
if len(data) > index:
|
||||
while len(data) > index:
|
||||
data.pop()
|
||||
|
||||
# Append to section title or text
|
||||
else:
|
||||
data.append(f"</{tag}>")
|
||||
|
||||
# Called for the text contents of each tag
|
||||
def handle_data(self, data):
|
||||
if self.skip.intersection(self.context):
|
||||
return
|
||||
|
||||
# Collapse whitespace in non-pre contexts
|
||||
if not "pre" in self.context:
|
||||
if not data.isspace():
|
||||
data = data.replace("\n", " ")
|
||||
else:
|
||||
data = " "
|
||||
|
||||
# Handle preface - ensure top-level section
|
||||
if not self.section:
|
||||
self.section = Section(Element("hx"))
|
||||
self.data.append(self.section)
|
||||
|
||||
# Handle section headline
|
||||
if self.section.el in self.context:
|
||||
permalink = False
|
||||
for el in self.context:
|
||||
if el.tag == "a" and el.attrs.get("class") == "headerlink":
|
||||
permalink = True
|
||||
|
||||
# Ignore permalinks
|
||||
if not permalink:
|
||||
self.section.title.append(
|
||||
escape(data, quote = False)
|
||||
)
|
||||
|
||||
# Collapse adjacent whitespace
|
||||
elif data.isspace():
|
||||
if not self.section.text or not self.section.text[-1].isspace():
|
||||
self.section.text.append(data)
|
||||
elif "pre" in self.context:
|
||||
self.section.text.append(data)
|
||||
|
||||
# Handle everything else
|
||||
else:
|
||||
self.section.text.append(
|
||||
escape(data, quote = False)
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.search")
|
||||
|
||||
# Tags that are self-closing
|
||||
void = set([
|
||||
"area", # Image map areas
|
||||
"base", # Document base
|
||||
"br", # Line breaks
|
||||
"col", # Table columns
|
||||
"embed", # External content
|
||||
"hr", # Horizontal rules
|
||||
"img", # Images
|
||||
"input", # Input fields
|
||||
"link", # Links
|
||||
"meta", # Metadata
|
||||
"param", # External parameters
|
||||
"source", # Image source sets
|
||||
"track", # Text track
|
||||
"wbr" # Line break opportunities
|
||||
])
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
# 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 mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import Deprecated, ListOfItems, Type
|
||||
from mkdocs.config.defaults import _LogLevel
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Social plugin configuration
|
||||
class SocialConfig(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/social")
|
||||
|
||||
# Settings for logging
|
||||
log = Type(bool, default = True)
|
||||
log_level = _LogLevel(default = "warn")
|
||||
|
||||
# Settings for cards
|
||||
cards = Type(bool, default = True)
|
||||
cards_dir = Type(str, default = "assets/images/social")
|
||||
cards_layout_dir = Type(str, default = "layouts")
|
||||
cards_layout = Type(str, default = "default")
|
||||
cards_layout_options = Type(dict, default = {})
|
||||
cards_include = ListOfItems(Type(str), default = [])
|
||||
cards_exclude = ListOfItems(Type(str), default = [])
|
||||
|
||||
# Settings for debugging
|
||||
debug = Type(bool, default = False)
|
||||
debug_on_build = Type(bool, default = False)
|
||||
debug_grid = Type(bool, default = True)
|
||||
debug_grid_step = Type(int, default = 32)
|
||||
debug_color = Type(str, default = "grey")
|
||||
|
||||
# Deprecated settings
|
||||
cards_color = Deprecated(
|
||||
message =
|
||||
"Deprecated, use 'cards_layout_options.background_color' "
|
||||
"and 'cards_layout_options.color' with 'default' layout"
|
||||
)
|
||||
cards_font = Deprecated(
|
||||
message = "Deprecated, use 'cards_layout_options.font_family'"
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
# 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 re
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import (
|
||||
Choice, DictOfItems, ListOfItems, SubConfig, Type
|
||||
)
|
||||
try:
|
||||
from PIL.Image import Image as _Image
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Options
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Options for origin
|
||||
Origin = (
|
||||
"start top", "center top", "end top",
|
||||
"start center", "center", "end center",
|
||||
"start bottom", "center bottom", "end bottom",
|
||||
"start", "end"
|
||||
)
|
||||
|
||||
# Options for overflow
|
||||
Overflow = (
|
||||
"truncate",
|
||||
"shrink"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Size
|
||||
class Size(Config):
|
||||
width = Type(int, default = 0)
|
||||
height = Type(int, default = 0)
|
||||
|
||||
# Offset
|
||||
class Offset(Config):
|
||||
x = Type(int, default = 0)
|
||||
y = Type(int, default = 0)
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Background
|
||||
class Background(Config):
|
||||
color = Type(str, default = "")
|
||||
image = Type(str, default = "")
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Icon
|
||||
class Icon(Config):
|
||||
value = Type(str, default = "")
|
||||
color = Type(str, default = "")
|
||||
|
||||
# # -----------------------------------------------------------------------------
|
||||
|
||||
# Line
|
||||
class Line(Config):
|
||||
amount = Type((int, float), default = 1)
|
||||
height = Type((int, float), default = 1)
|
||||
|
||||
# Font
|
||||
class Font(Config):
|
||||
family = Type(str, default = "Roboto")
|
||||
variant = Type(str, default = "")
|
||||
style = Type(str, default = "Regular")
|
||||
|
||||
# Typography
|
||||
class Typography(Config):
|
||||
content = Type(str, default = "")
|
||||
align = Choice(Origin, default = "start top")
|
||||
overflow = Choice(Overflow, default = "truncate")
|
||||
color = Type(str, default = "")
|
||||
line = SubConfig(Line)
|
||||
font = SubConfig(Font)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Layer
|
||||
class Layer(Config):
|
||||
size = SubConfig(Size)
|
||||
offset = SubConfig(Offset)
|
||||
origin = Choice(Origin, default = "start top")
|
||||
background = SubConfig(Background)
|
||||
icon = SubConfig(Icon)
|
||||
typography = SubConfig(Typography)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Layout
|
||||
class Layout(Config):
|
||||
definitions = ListOfItems(Type(str), default = [])
|
||||
tags = DictOfItems(Type(str), default = {})
|
||||
size = SubConfig(Size)
|
||||
layers = ListOfItems(SubConfig(Layer), default = [])
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Get layer or layout size as tuple
|
||||
def get_size(layer: Layer | Layout):
|
||||
return layer.size.width, layer.size.height
|
||||
|
||||
# Get layer offset as tuple
|
||||
def get_offset(layer: Layer, image: _Image):
|
||||
x, y = layer.offset.x, layer.offset.y
|
||||
|
||||
# Compute offset from origin - if an origin is given, compute the offset
|
||||
# relative to the image and layer size to allow for flexible positioning
|
||||
if layer.origin != "start top":
|
||||
origin = re.split(r"\s+", layer.origin)
|
||||
|
||||
# Get layer size
|
||||
w, h = get_size(layer)
|
||||
|
||||
# Compute origin on x-axis
|
||||
if "start" in origin: pass
|
||||
elif "end" in origin: x += (image.width - w) - 2 * x
|
||||
elif "center" in origin: x += (image.width - w) >> 1
|
||||
|
||||
# Compute origin on y-axis
|
||||
if "top" in origin: pass
|
||||
elif "bottom" in origin: y += (image.height - h) - 2 * y
|
||||
elif "center" in origin: y += (image.height - h) >> 1
|
||||
|
||||
# Return offset
|
||||
return x, y
|
||||
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Filter for coercing everthing that is falsy to an empty string
|
||||
def x_filter(value: str | None):
|
||||
return value or ""
|
||||
BIN
Binary file not shown.
+244
@@ -0,0 +1,244 @@
|
||||
# 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.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: indigo)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ef5552",
|
||||
"pink": "#e92063",
|
||||
"purple": "#ab47bd",
|
||||
"deep-purple": "#7e56c2",
|
||||
"indigo": "#4051b5",
|
||||
"blue": "#2094f3",
|
||||
"light-blue": "#02a6f2",
|
||||
"cyan": "#00bdd6",
|
||||
"teal": "#009485",
|
||||
"green": "#4cae4f",
|
||||
"light-green": "#8bc34b",
|
||||
"lime": "#cbdc38",
|
||||
"yellow": "#ffec3d",
|
||||
"amber": "#ffc105",
|
||||
"orange": "#ffa724",
|
||||
"deep-orange": "#ff6e42",
|
||||
"brown": "#795649",
|
||||
"grey": "#757575",
|
||||
"blue-grey": "#546d78",
|
||||
"black": "#000000",
|
||||
"white": "#ffffff"
|
||||
}[primary] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: white)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff",
|
||||
"brown": "#ffffff",
|
||||
"grey": "#ffffff",
|
||||
"blue-grey": "#ffffff",
|
||||
"black": "#ffffff",
|
||||
"white": "#000000"
|
||||
}[primary] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Logo
|
||||
- size: { width: 144, height: 144 }
|
||||
offset: { x: 992, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 832, height: 42 }
|
||||
offset: { x: 64, y: 64 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 832, height: 310 }
|
||||
offset: { x: 62, y: 160 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 832, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
# 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.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: indigo)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("accent") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set accent = palette.get("accent", "indigo") -%}
|
||||
{%- set accent = accent.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ff1a47",
|
||||
"pink": "#f50056",
|
||||
"purple": "#df41fb",
|
||||
"deep-purple": "#7c4dff",
|
||||
"indigo": "#526cfe",
|
||||
"blue": "#4287ff",
|
||||
"light-blue": "#0091eb",
|
||||
"cyan": "#00bad6",
|
||||
"teal": "#00bda4",
|
||||
"green": "#00c753",
|
||||
"light-green": "#63de17",
|
||||
"lime": "#b0eb00",
|
||||
"yellow": "#ffd500",
|
||||
"amber": "#ffaa00",
|
||||
"orange": "#ff9100",
|
||||
"deep-orange": "#ff6e42"
|
||||
}[accent] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: white)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("accent") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set accent = palette.get("accent", "indigo") -%}
|
||||
{%- set accent = accent.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff"
|
||||
}[accent] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Logo
|
||||
- size: { width: 144, height: 144 }
|
||||
offset: { x: 992, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 832, height: 42 }
|
||||
offset: { x: 64, y: 64 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 832, height: 310 }
|
||||
offset: { x: 62, y: 160 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 832, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
# 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.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: white)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff",
|
||||
"brown": "#ffffff",
|
||||
"grey": "#ffffff",
|
||||
"blue-grey": "#ffffff",
|
||||
"black": "#ffffff",
|
||||
"white": "#000000"
|
||||
}[primary] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: indigo)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ef5552",
|
||||
"pink": "#e92063",
|
||||
"purple": "#ab47bd",
|
||||
"deep-purple": "#7e56c2",
|
||||
"indigo": "#4051b5",
|
||||
"blue": "#2094f3",
|
||||
"light-blue": "#02a6f2",
|
||||
"cyan": "#00bdd6",
|
||||
"teal": "#009485",
|
||||
"green": "#4cae4f",
|
||||
"light-green": "#8bc34b",
|
||||
"lime": "#cbdc38",
|
||||
"yellow": "#ffec3d",
|
||||
"amber": "#ffc105",
|
||||
"orange": "#ffa724",
|
||||
"deep-orange": "#ff6e42",
|
||||
"brown": "#795649",
|
||||
"grey": "#757575",
|
||||
"blue-grey": "#546d78",
|
||||
"black": "#000000",
|
||||
"white": "#ffffff"
|
||||
}[primary] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Logo
|
||||
- size: { width: 144, height: 144 }
|
||||
offset: { x: 992, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 832, height: 42 }
|
||||
offset: { x: 64, y: 64 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 832, height: 310 }
|
||||
offset: { x: 62, y: 160 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 832, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image }}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
# 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.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Definitions
|
||||
definitions:
|
||||
|
||||
# Background image
|
||||
- &background_image >-
|
||||
{{ layout.background_image | x }}
|
||||
|
||||
# Background color (default: indigo)
|
||||
- &background_color >-
|
||||
{%- if layout.background_color -%}
|
||||
{{ layout.background_color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ef5552",
|
||||
"pink": "#e92063",
|
||||
"purple": "#ab47bd",
|
||||
"deep-purple": "#7e56c2",
|
||||
"indigo": "#4051b5",
|
||||
"blue": "#2094f3",
|
||||
"light-blue": "#02a6f2",
|
||||
"cyan": "#00bdd6",
|
||||
"teal": "#009485",
|
||||
"green": "#4cae4f",
|
||||
"light-green": "#8bc34b",
|
||||
"lime": "#cbdc38",
|
||||
"yellow": "#ffec3d",
|
||||
"amber": "#ffc105",
|
||||
"orange": "#ffa724",
|
||||
"deep-orange": "#ff6e42",
|
||||
"brown": "#795649",
|
||||
"grey": "#757575",
|
||||
"blue-grey": "#546d78",
|
||||
"black": "#000000",
|
||||
"white": "#ffffff"
|
||||
}[primary] or "#4051b5" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Text color (default: white)
|
||||
- &color >-
|
||||
{%- if layout.color -%}
|
||||
{{ layout.color }}
|
||||
{%- else -%}
|
||||
{%- set palette = config.theme.palette or {} -%}
|
||||
{%- if not palette is mapping -%}
|
||||
{%- set list = palette | selectattr("primary") | list + palette -%}
|
||||
{%- set palette = list | first -%}
|
||||
{%- endif -%}
|
||||
{%- set primary = palette.get("primary", "indigo") -%}
|
||||
{%- set primary = primary.replace(" ", "-") -%}
|
||||
{{ {
|
||||
"red": "#ffffff",
|
||||
"pink": "#ffffff",
|
||||
"purple": "#ffffff",
|
||||
"deep-purple": "#ffffff",
|
||||
"indigo": "#ffffff",
|
||||
"blue": "#ffffff",
|
||||
"light-blue": "#ffffff",
|
||||
"cyan": "#ffffff",
|
||||
"teal": "#ffffff",
|
||||
"green": "#ffffff",
|
||||
"light-green": "#ffffff",
|
||||
"lime": "#000000",
|
||||
"yellow": "#000000",
|
||||
"amber": "#000000",
|
||||
"orange": "#000000",
|
||||
"deep-orange": "#ffffff",
|
||||
"brown": "#ffffff",
|
||||
"grey": "#ffffff",
|
||||
"blue-grey": "#ffffff",
|
||||
"black": "#ffffff",
|
||||
"white": "#000000"
|
||||
}[primary] or "#ffffff" }}
|
||||
{%- endif -%}
|
||||
|
||||
# Font family (default: Roboto)
|
||||
- &font_family >-
|
||||
{%- if layout.font_family -%}
|
||||
{{ layout.font_family }}
|
||||
{%- elif config.theme.font is mapping -%}
|
||||
{{ config.theme.font.get("text", "Roboto") }}
|
||||
{%- else -%}
|
||||
Roboto
|
||||
{%- endif -%}
|
||||
|
||||
# Font variant
|
||||
- &font_variant >-
|
||||
{%- if layout.font_variant -%}
|
||||
{{ layout.font_variant }}
|
||||
{%- endif -%}
|
||||
|
||||
# Site name
|
||||
- &site_name >-
|
||||
{{ config.site_name }}
|
||||
|
||||
# Page title
|
||||
- &page_title >-
|
||||
{%- if layout.title -%}
|
||||
{{ layout.title }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("title", page.title) }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page title with site name
|
||||
- &page_title_with_site_name >-
|
||||
{%- if not page.is_homepage -%}
|
||||
{{ page.meta.get("title", page.title) }} - {{ config.site_name }}
|
||||
{%- else -%}
|
||||
{{ config.site_name }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page description
|
||||
- &page_description >-
|
||||
{%- if layout.description -%}
|
||||
{{ layout.description }}
|
||||
{%- else -%}
|
||||
{{ page.meta.get("description", config.site_description) | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Page icon
|
||||
- &page_icon >-
|
||||
{{ page.meta.icon | x }}
|
||||
|
||||
# Logo
|
||||
- &logo >-
|
||||
{%- if layout.logo -%}
|
||||
{{ layout.logo }}
|
||||
{%- elif config.theme.logo -%}
|
||||
{{ config.docs_dir }}/{{ config.theme.logo }}
|
||||
{%- endif -%}
|
||||
|
||||
# Logo (icon)
|
||||
- &logo_icon >-
|
||||
{%- if not layout.logo and config.theme.icon -%}
|
||||
{{ config.theme.icon.logo | x }}
|
||||
{%- endif -%}
|
||||
|
||||
# Meta tags
|
||||
tags:
|
||||
|
||||
# Open Graph
|
||||
og:type: website
|
||||
og:title: *page_title_with_site_name
|
||||
og:description: *page_description
|
||||
og:image: "{{ image.url }}"
|
||||
og:image:type: "{{ image.type }}"
|
||||
og:image:width: "{{ image.width }}"
|
||||
og:image:height: "{{ image.height }}"
|
||||
og:url: "{{ page.canonical_url }}"
|
||||
|
||||
# Twitter
|
||||
twitter:card: summary_large_image
|
||||
twitter:title: *page_title_with_site_name
|
||||
twitter:description: *page_description
|
||||
twitter:image: "{{ image.url }}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Specification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Card size and layers
|
||||
size: { width: 1200, height: 630 }
|
||||
layers:
|
||||
|
||||
# Background
|
||||
- background:
|
||||
image: *background_image
|
||||
color: *background_color
|
||||
|
||||
# Page icon
|
||||
- size: { width: 630, height: 630 }
|
||||
offset: { x: 800, y: 0 }
|
||||
icon:
|
||||
value: *page_icon
|
||||
color: "#00000033"
|
||||
|
||||
# Logo
|
||||
- size: { width: 64, height: 64 }
|
||||
offset: { x: 64, y: 64 }
|
||||
background:
|
||||
image: *logo
|
||||
icon:
|
||||
value: *logo_icon
|
||||
color: *color
|
||||
|
||||
# Site name
|
||||
- size: { width: 768, height: 42 }
|
||||
offset: { x: 160, y: 74 }
|
||||
typography:
|
||||
content: *site_name
|
||||
color: *color
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page title
|
||||
- size: { width: 864, height: 256 }
|
||||
offset: { x: 62, y: 192 }
|
||||
typography:
|
||||
content: *page_title
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 3
|
||||
height: 1.25
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Bold
|
||||
|
||||
# Page description
|
||||
- size: { width: 864, height: 64 }
|
||||
offset: { x: 64, y: 512 }
|
||||
typography:
|
||||
content: *page_description
|
||||
align: start
|
||||
color: *color
|
||||
line:
|
||||
amount: 2
|
||||
height: 1.5
|
||||
font:
|
||||
family: *font_family
|
||||
variant: *font_variant
|
||||
style: Regular
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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 .structure.mapping import Mapping
|
||||
from .structure.tag import Tag
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Return tag name for sorting
|
||||
def tag_name(tag: Tag, *args):
|
||||
return tag.name
|
||||
|
||||
# Return casefolded tag name for sorting
|
||||
def tag_name_casefold(tag: Tag, *args):
|
||||
return tag.name.casefold()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Return item title for sorting
|
||||
def item_title(mapping: Mapping):
|
||||
# Note that this must be coerced to a string, as the title might be sourced
|
||||
# from metadata, which can be of any type - see https://t.ly/1AXyo
|
||||
return str(mapping.item.title)
|
||||
|
||||
# Return item URL for sorting
|
||||
def item_url(mapping: Mapping):
|
||||
return mapping.item.url
|
||||
Loaded 100 of 18684 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user