mirror of
https://github.com/TheBinaryNinja/tvapp2.git
synced 2026-06-04 04:35:41 -04:00
docs: integrate mkdocs into repository
This commit is contained in:
21
docs/material/plugins/__init__.py
Normal file
21
docs/material/plugins/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
21
docs/material/plugins/blog/__init__.py
Normal file
21
docs/material/plugins/blog/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
42
docs/material/plugins/blog/author.py
Normal file
42
docs/material/plugins/blog/author.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 = {})
|
||||
90
docs/material/plugins/blog/config.py
Normal file
90
docs/material/plugins/blog/config.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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_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_allowed = Type(list, default = [])
|
||||
categories_toc = Optional(Type(bool))
|
||||
|
||||
# Settings for authors
|
||||
authors = Type(bool, default = True)
|
||||
authors_file = Type(str, default = "{blog}/.authors.yml")
|
||||
|
||||
# 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")
|
||||
868
docs/material/plugins/blog/plugin.py
Normal file
868
docs/material/plugins/blog/plugin.py
Normal file
@@ -0,0 +1,868 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 posixpath
|
||||
import yaml
|
||||
|
||||
from babel.dates import format_date
|
||||
from datetime import datetime
|
||||
from jinja2 import pass_context
|
||||
from jinja2.runtime import Context
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure import StructureItem
|
||||
from mkdocs.structure.files import File, Files, InclusionLevel
|
||||
from mkdocs.structure.nav import Navigation, Section
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.utils import copy_file, get_relative_url
|
||||
from mkdocs.utils.templates import url_filter
|
||||
from paginate import Page as Pagination
|
||||
from shutil import rmtree
|
||||
from tempfile import mkdtemp
|
||||
from yaml import SafeLoader
|
||||
|
||||
from .author import Authors
|
||||
from .config import BlogConfig
|
||||
from .readtime import readtime
|
||||
from .structure import Archive, Category, Excerpt, Post, View
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Blog plugin
|
||||
class BlogPlugin(BasePlugin[BlogConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Initialize plugin
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
self.is_dirty = False
|
||||
|
||||
# Initialize temporary directory
|
||||
self.temp_dir = mkdtemp()
|
||||
|
||||
# Determine whether we're serving the site
|
||||
def on_startup(self, *, command, dirty):
|
||||
self.is_serve = command == "serve"
|
||||
self.is_dirty = dirty
|
||||
|
||||
# Initialize authors and set defaults
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize entrypoint
|
||||
self.blog: View
|
||||
|
||||
# Initialize and resolve authors, if enabled
|
||||
if self.config.authors:
|
||||
self.authors = self._resolve_authors(config)
|
||||
|
||||
# Initialize table of contents settings
|
||||
if not isinstance(self.config.archive_toc, bool):
|
||||
self.config.archive_toc = self.config.blog_toc
|
||||
if not isinstance(self.config.categories_toc, bool):
|
||||
self.config.categories_toc = self.config.blog_toc
|
||||
|
||||
# By default, drafts are rendered when the documentation is served,
|
||||
# but not when it is built, for a better user experience
|
||||
if self.is_serve and self.config.draft_on_serve:
|
||||
self.config.draft = True
|
||||
|
||||
# Resolve and load posts and generate views (run later) - we want to allow
|
||||
# other plugins to add generated posts or views, so we run this plugin as
|
||||
# late as possible. We also need to remove the posts from the navigation
|
||||
# before navigation is constructed, as the entrypoint should be considered
|
||||
# to be the active page for each post. The URLs of posts are computed before
|
||||
# Markdown processing, so that when linking to and from posts, behavior is
|
||||
# exactly the same as with regular documentation pages. We create all pages
|
||||
# related to posts as part of this plugin, so we control the entire process.
|
||||
@event_priority(-50)
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Resolve path to entrypoint and site directory
|
||||
root = posixpath.normpath(self.config.blog_dir)
|
||||
site = config.site_dir
|
||||
|
||||
# Compute and normalize path to posts directory
|
||||
path = self.config.post_dir.format(blog = root)
|
||||
path = posixpath.normpath(path)
|
||||
|
||||
# Adjust destination paths for media files
|
||||
for file in files.media_files():
|
||||
if not file.src_uri.startswith(path):
|
||||
continue
|
||||
|
||||
# We need to adjust destination paths for assets to remove the
|
||||
# purely functional posts directory prefix when building
|
||||
file.dest_uri = file.dest_uri.replace(path, root)
|
||||
file.abs_dest_path = os.path.join(site, file.dest_path)
|
||||
file.url = file.url.replace(path, root)
|
||||
|
||||
# Resolve entrypoint and posts sorted by descending date - if the posts
|
||||
# directory or entrypoint do not exist, they are automatically created
|
||||
self.blog = self._resolve(files, config)
|
||||
self.blog.posts = sorted(
|
||||
self._resolve_posts(files, config),
|
||||
key = lambda post: post.config.date.created,
|
||||
reverse = True
|
||||
)
|
||||
|
||||
# Generate views for archive
|
||||
if self.config.archive:
|
||||
self.blog.views.extend(
|
||||
self._generate_archive(config, files)
|
||||
)
|
||||
|
||||
# Generate views for categories
|
||||
if self.config.categories:
|
||||
self.blog.views.extend(sorted(
|
||||
self._generate_categories(config, files),
|
||||
key = lambda view: view.name,
|
||||
reverse = False
|
||||
))
|
||||
|
||||
# Generate pages for views
|
||||
if self.config.pagination:
|
||||
for view in self._resolve_views(self.blog):
|
||||
for page in self._generate_pages(view, config, files):
|
||||
view.pages.append(page)
|
||||
|
||||
# Ensure that entrypoint is always included in navigation
|
||||
self.blog.file.inclusion = InclusionLevel.INCLUDED
|
||||
|
||||
# Attach posts and views to navigation (run later) - again, we allow other
|
||||
# plugins to alter the navigation before we start to attach posts and views
|
||||
# generated by this plugin at the correct locations in the navigation. Also,
|
||||
# we make sure to correct links to the parent and siblings of each page.
|
||||
@event_priority(-50)
|
||||
def on_nav(self, nav, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# If we're not building a standalone blog, the entrypoint will always
|
||||
# have a parent when it is included in the navigation. The parent is
|
||||
# essential to correctly resolve the location where the archive and
|
||||
# category views are attached. If the entrypoint doesn't have a parent,
|
||||
# we know that the author did not include it in the navigation, so we
|
||||
# explicitly mark it as not included.
|
||||
if not self.blog.parent and self.config.blog_dir != ".":
|
||||
self.blog.file.inclusion = InclusionLevel.NOT_IN_NAV
|
||||
|
||||
# Attach posts to entrypoint without adding them to the navigation, so
|
||||
# that the entrypoint is considered to be the active page for each post
|
||||
self._attach(self.blog, [None, *reversed(self.blog.posts), None])
|
||||
for post in self.blog.posts:
|
||||
post.file.inclusion = InclusionLevel.NOT_IN_NAV
|
||||
|
||||
# Revert temporary exclusion of views from navigation
|
||||
for view in self._resolve_views(self.blog):
|
||||
view.file.inclusion = self.blog.file.inclusion
|
||||
for page in view.pages:
|
||||
page.file.inclusion = self.blog.file.inclusion
|
||||
|
||||
# Attach views for archive
|
||||
if self.config.archive:
|
||||
title = self._translate(self.config.archive_name, config)
|
||||
views = [_ for _ in self.blog.views if isinstance(_, Archive)]
|
||||
|
||||
# Attach and link views for archive
|
||||
if self.blog.file.inclusion.is_in_nav():
|
||||
self._attach_to(self.blog, Section(title, views), nav)
|
||||
|
||||
# Attach views for categories
|
||||
if self.config.categories:
|
||||
title = self._translate(self.config.categories_name, config)
|
||||
views = [_ for _ in self.blog.views if isinstance(_, Category)]
|
||||
|
||||
# Attach and link views for categories, if any
|
||||
if self.blog.file.inclusion.is_in_nav() and views:
|
||||
self._attach_to(self.blog, Section(title, views), nav)
|
||||
|
||||
# Attach pages for views
|
||||
if self.config.pagination:
|
||||
for view in self._resolve_views(self.blog):
|
||||
for at in range(1, len(view.pages)):
|
||||
self._attach_at(view.parent, view, view.pages[at])
|
||||
|
||||
# Prepare post for rendering (run later) - allow other plugins to alter
|
||||
# the contents or metadata of a post before it is rendered and make sure
|
||||
# that the post includes a separator, which is essential for rendering
|
||||
# excerpts that should be included in views
|
||||
@event_priority(-50)
|
||||
def on_page_markdown(self, markdown, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if page is not a post managed by this instance - this plugin has
|
||||
# support for multiple instances, which is why this check is necessary
|
||||
if page not in self.blog.posts:
|
||||
if not self.config.pagination:
|
||||
return
|
||||
|
||||
# We set the contents of the view to its title if pagination should
|
||||
# not keep the content of the original view on paginated views
|
||||
if not self.config.pagination_keep_content:
|
||||
view = self._resolve_original(page)
|
||||
if view in self._resolve_views(self.blog):
|
||||
|
||||
# If the current view is paginated, use the rendered title
|
||||
# of the original view in case the author set the title in
|
||||
# the page's contents, or it would be overridden with the
|
||||
# one set in mkdocs.yml, leading to inconsistent headings
|
||||
assert isinstance(view, View)
|
||||
if view != page:
|
||||
name = view._title_from_render or view.title
|
||||
return f"# {name}"
|
||||
|
||||
# Nothing more to be done for views
|
||||
return
|
||||
|
||||
# Extract and assign authors to post, if enabled
|
||||
if self.config.authors:
|
||||
for name in page.config.authors:
|
||||
if name not in self.authors:
|
||||
raise PluginError(f"Couldn't find author '{name}'")
|
||||
|
||||
# Append to list of authors
|
||||
page.authors.append(self.authors[name])
|
||||
|
||||
# Extract settings for excerpts
|
||||
separator = self.config.post_excerpt_separator
|
||||
max_authors = self.config.post_excerpt_max_authors
|
||||
max_categories = self.config.post_excerpt_max_categories
|
||||
|
||||
# Ensure presence of separator and throw, if its absent and required -
|
||||
# we append the separator to the end of the contents of the post, if it
|
||||
# is not already present, so we can remove footnotes or other content
|
||||
# from the excerpt without affecting the content of the excerpt
|
||||
if separator not in page.markdown:
|
||||
if self.config.post_excerpt == "required":
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(page.file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Couldn't find '{separator}' in post '{path}' in '{docs}'"
|
||||
)
|
||||
|
||||
# Create excerpt for post and inherit authors and categories - excerpts
|
||||
# can contain a subset of the authors and categories of the post
|
||||
page.excerpt = Excerpt(page, config, files)
|
||||
page.excerpt.authors = page.authors[:max_authors]
|
||||
page.excerpt.categories = page.categories[:max_categories]
|
||||
|
||||
# Process posts
|
||||
def on_page_content(self, html, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if page is not a post managed by this instance - this plugin has
|
||||
# support for multiple instances, which is why this check is necessary
|
||||
if page not in self.blog.posts:
|
||||
return
|
||||
|
||||
# Compute readtime of post, if enabled and not explicitly set
|
||||
if self.config.post_readtime:
|
||||
words_per_minute = self.config.post_readtime_words_per_minute
|
||||
if not page.config.readtime:
|
||||
page.config.readtime = readtime(html, words_per_minute)
|
||||
|
||||
# Register template filters for plugin
|
||||
def on_env(self, env, *, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Filter for formatting dates related to posts
|
||||
def date_filter(date: datetime):
|
||||
return self._format_date_for_post(date, config)
|
||||
|
||||
# Patch URL template filter to add support for paginated views, i.e.,
|
||||
# that paginated views never link to themselves but to the main view
|
||||
@pass_context
|
||||
def url_filter_with_pagination(context: Context, url: str | None):
|
||||
page = context["page"]
|
||||
|
||||
# If the current page is a view, check if the URL links to the page
|
||||
# itself, and replace it with the URL of the main view
|
||||
if isinstance(page, View):
|
||||
view = self._resolve_original(page)
|
||||
if page.url == url:
|
||||
url = view.url
|
||||
|
||||
# Forward to original template filter
|
||||
return url_filter(context, url)
|
||||
|
||||
# Register custom template filters
|
||||
env.filters["date"] = date_filter
|
||||
env.filters["url"] = url_filter_with_pagination
|
||||
|
||||
# Prepare view for rendering (run latest) - views are rendered last, as we
|
||||
# need to mutate the navigation to account for pagination. The main problem
|
||||
# is that we need to replace the view in the navigation, because otherwise
|
||||
# the view would not be considered active.
|
||||
@event_priority(-100)
|
||||
def on_page_context(self, context, *, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if page is not a view managed by this instance - this plugin has
|
||||
# support for multiple instances, which is why this check is necessary
|
||||
view = self._resolve_original(page)
|
||||
if view not in self._resolve_views(self.blog):
|
||||
return
|
||||
|
||||
# Render excerpts and prepare pagination
|
||||
posts, pagination = self._render(page)
|
||||
|
||||
# Render pagination links
|
||||
def pager(args: object):
|
||||
return pagination.pager(
|
||||
format = self.config.pagination_format,
|
||||
show_if_single_page = self.config.pagination_if_single_page,
|
||||
**args
|
||||
)
|
||||
|
||||
# Assign posts and pagination to context
|
||||
context["posts"] = posts
|
||||
context["pagination"] = pager if pagination else None
|
||||
|
||||
# Remove temporary directory on shutdown
|
||||
def on_shutdown(self):
|
||||
rmtree(self.temp_dir)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Check if the given post is excluded
|
||||
def _is_excluded(self, post: Post):
|
||||
if self.config.draft:
|
||||
return False
|
||||
|
||||
# If a post was not explicitly marked or unmarked as draft, and the
|
||||
# date should be taken into account, we automatically mark it as draft
|
||||
# if the publishing date is in the future. This, of course, is opt-in
|
||||
# and must be explicitly enabled by the author.
|
||||
if not isinstance(post.config.draft, bool):
|
||||
if self.config.draft_if_future_date:
|
||||
return post.config.date.created > datetime.now()
|
||||
|
||||
# Post might be a draft
|
||||
return bool(post.config.draft)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Resolve entrypoint - the entrypoint of the blog must have been created
|
||||
# if it did not exist before, and hosts all posts sorted by descending date
|
||||
def _resolve(self, files: Files, config: MkDocsConfig):
|
||||
path = os.path.join(self.config.blog_dir, "index.md")
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Create entrypoint, if it does not exist - note that the entrypoint is
|
||||
# created in the docs directory, not in the temporary directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
name = os.path.join(docs, path)
|
||||
if not os.path.isfile(name):
|
||||
file = self._path_to_file(path, config, temp = False)
|
||||
files.append(file)
|
||||
|
||||
# Create file in docs directory
|
||||
self._save_to_file(file.abs_src_path, "# Blog\n\n")
|
||||
|
||||
# Create and return entrypoint
|
||||
file = files.get_file_from_path(path)
|
||||
return View(None, file, config)
|
||||
|
||||
# Resolve post - the caller must make sure that the given file points to an
|
||||
# actual post (and not a page), or behavior might be unpredictable
|
||||
def _resolve_post(self, file: File, config: MkDocsConfig):
|
||||
post = Post(file, config)
|
||||
|
||||
# Compute path and create a temporary file for path resolution
|
||||
path = self._format_path_for_post(post, config)
|
||||
temp = self._path_to_file(path, config, temp = False)
|
||||
|
||||
# Replace destination file system path and URL
|
||||
file.dest_uri = temp.dest_uri
|
||||
file.abs_dest_path = temp.abs_dest_path
|
||||
file.url = temp.url
|
||||
|
||||
# Replace canonical URL and return post
|
||||
post._set_canonical_url(config.site_url)
|
||||
return post
|
||||
|
||||
# Resolve posts from directory - traverse all documentation pages and filter
|
||||
# and yield those that are located in the posts directory
|
||||
def _resolve_posts(self, files: Files, config: MkDocsConfig):
|
||||
path = self.config.post_dir.format(blog = self.config.blog_dir)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Create posts directory, if it does not exist
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
name = os.path.join(docs, path)
|
||||
if not os.path.isdir(name):
|
||||
os.makedirs(name, exist_ok = True)
|
||||
|
||||
# Filter posts from pages
|
||||
for file in files.documentation_pages():
|
||||
if not file.src_path.startswith(path):
|
||||
continue
|
||||
|
||||
# Temporarily remove post from navigation
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Resolve post - in order to determine whether a post should be
|
||||
# excluded, we must load it and analyze its metadata. All posts
|
||||
# marked as drafts are excluded, except for when the author has
|
||||
# configured drafts to be included in the navigation.
|
||||
post = self._resolve_post(file, config)
|
||||
if not self._is_excluded(post):
|
||||
yield post
|
||||
|
||||
# Resolve authors - check if there's an authors file at the configured
|
||||
# location, and if one was found, load and validate it
|
||||
def _resolve_authors(self, config: MkDocsConfig):
|
||||
path = self.config.authors_file.format(blog = self.config.blog_dir)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Resolve path relative to docs directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
file = os.path.join(docs, path)
|
||||
|
||||
# If the authors file does not exist, return here
|
||||
config: Authors = Authors()
|
||||
if not os.path.isfile(file):
|
||||
return config.authors
|
||||
|
||||
# Open file and parse as YAML
|
||||
with open(file, encoding = "utf-8") as f:
|
||||
config.config_file_path = os.path.abspath(file)
|
||||
try:
|
||||
config.load_dict(yaml.load(f, SafeLoader) or {})
|
||||
|
||||
# The authors 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 authors file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Validate authors and throw if errors occurred
|
||||
errors, warnings = config.validate()
|
||||
for _, w in warnings:
|
||||
log.warning(w)
|
||||
for _, e in errors:
|
||||
raise PluginError(
|
||||
f"Error reading authors file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Return authors
|
||||
return config.authors
|
||||
|
||||
# Resolve views of the given view in pre-order
|
||||
def _resolve_views(self, view: View):
|
||||
yield view
|
||||
|
||||
# Resolve views recursively
|
||||
for page in view.views:
|
||||
for next in self._resolve_views(page):
|
||||
assert isinstance(next, View)
|
||||
yield next
|
||||
|
||||
# Resolve siblings of a navigation item
|
||||
def _resolve_siblings(self, item: StructureItem, nav: Navigation):
|
||||
if isinstance(item.parent, Section):
|
||||
return item.parent.children
|
||||
else:
|
||||
return nav.items
|
||||
|
||||
# Resolve original page or view (e.g. for paginated views)
|
||||
def _resolve_original(self, page: Page):
|
||||
if isinstance(page, View) and page.pages:
|
||||
return page.pages[0]
|
||||
else:
|
||||
return page
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generate views for archive - analyze posts and generate the necessary
|
||||
# views, taking the date format provided by the author into account
|
||||
def _generate_archive(self, config: MkDocsConfig, files: Files):
|
||||
for post in self.blog.posts:
|
||||
date = post.config.date.created
|
||||
|
||||
# Compute name and path of archive view
|
||||
name = self._format_date_for_archive(date, config)
|
||||
path = self._format_path_for_archive(post, config)
|
||||
|
||||
# Create file for view, if it does not exist
|
||||
file = files.get_file_from_path(path)
|
||||
if not file or self.temp_dir not in file.abs_src_path:
|
||||
file = self._path_to_file(path, config)
|
||||
files.append(file)
|
||||
|
||||
# Create file in temporary directory and temporarily remove
|
||||
# from navigation, as we'll add it at a specific location
|
||||
self._save_to_file(file.abs_src_path, f"# {name}")
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Create and yield view
|
||||
if not isinstance(file.page, Archive):
|
||||
yield Archive(name, file, config)
|
||||
|
||||
# Assign post to archive
|
||||
assert isinstance(file.page, Archive)
|
||||
file.page.posts.append(post)
|
||||
|
||||
# Generate views for categories - analyze posts and generate the necessary
|
||||
# views, taking the allowed categories as set by the author into account
|
||||
def _generate_categories(self, config: MkDocsConfig, files: Files):
|
||||
for post in self.blog.posts:
|
||||
for name in post.config.categories:
|
||||
path = self._format_path_for_category(name)
|
||||
|
||||
# Ensure category is in non-empty allow list
|
||||
categories = self.config.categories_allowed or [name]
|
||||
if name not in categories:
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(post.file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Error reading categories of post '{path}' in "
|
||||
f"'{docs}': category '{name}' not in allow list"
|
||||
)
|
||||
|
||||
# Create file for view, if it does not exist
|
||||
file = files.get_file_from_path(path)
|
||||
if not file or self.temp_dir not in file.abs_src_path:
|
||||
file = self._path_to_file(path, config)
|
||||
files.append(file)
|
||||
|
||||
# Create file in temporary directory and temporarily remove
|
||||
# from navigation, as we'll add it at a specific location
|
||||
self._save_to_file(file.abs_src_path, f"# {name}")
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Create and yield view
|
||||
if not isinstance(file.page, Category):
|
||||
yield Category(name, file, config)
|
||||
|
||||
# Assign post to category and vice versa
|
||||
assert isinstance(file.page, Category)
|
||||
file.page.posts.append(post)
|
||||
post.categories.append(file.page)
|
||||
|
||||
# Generate pages for pagination - analyze view and generate the necessary
|
||||
# pages, creating a chain of views for simple rendering and replacement
|
||||
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
|
||||
yield view
|
||||
|
||||
# Compute pagination boundaries and create pages - pages are internally
|
||||
# handled as copies of a view, as they map to the same source location
|
||||
step = self.config.pagination_per_page
|
||||
for at in range(step, len(view.posts), step):
|
||||
path = self._format_path_for_pagination(view, 1 + at // step)
|
||||
|
||||
# Create file for view, if it does not exist
|
||||
file = files.get_file_from_path(path)
|
||||
if not file or self.temp_dir not in file.abs_src_path:
|
||||
file = self._path_to_file(path, config)
|
||||
files.append(file)
|
||||
|
||||
# Copy file to temporary directory and temporarily remove
|
||||
# from navigation, as we'll add it at a specific location
|
||||
copy_file(view.file.abs_src_path, file.abs_src_path)
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Create and yield view
|
||||
if not isinstance(file.page, View):
|
||||
yield view.__class__(None, file, config)
|
||||
|
||||
# Assign pages and posts to view
|
||||
assert isinstance(file.page, View)
|
||||
file.page.pages = view.pages
|
||||
file.page.posts = view.posts
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Attach a list of pages to each other and to the given parent item without
|
||||
# explicitly adding them to the navigation, which can be done by the caller
|
||||
def _attach(self, parent: StructureItem, pages: list[Page]):
|
||||
for tail, page, head in zip(pages, pages[1:], pages[2:]):
|
||||
|
||||
# Link page to parent and siblings
|
||||
page.parent = parent
|
||||
page.previous_page = tail
|
||||
page.next_page = head
|
||||
|
||||
# If the page is a view, we know that we generated it and need to
|
||||
# link its siblings back to the view
|
||||
if isinstance(page, View):
|
||||
view = self._resolve_original(page)
|
||||
if tail: tail.next_page = view
|
||||
if head: head.previous_page = view
|
||||
|
||||
# Attach a page to the given parent and link it to the previous and next
|
||||
# page of the given host - this is exclusively used for paginated views
|
||||
def _attach_at(self, parent: StructureItem, host: Page, page: Page):
|
||||
self._attach(parent, [host.previous_page, page, host.next_page])
|
||||
|
||||
# Attach a section as a sibling to the given view, make sure its pages are
|
||||
# part of the navigation, and ensure all pages are linked correctly
|
||||
def _attach_to(self, view: View, section: Section, nav: Navigation):
|
||||
section.parent = view.parent
|
||||
|
||||
# Resolve siblings, which are the children of the parent section, or
|
||||
# the top-level list of navigation items if the view is at the root of
|
||||
# the project, and append the given section to it. It's currently not
|
||||
# possible to chose the position of a section.
|
||||
items = self._resolve_siblings(view, nav)
|
||||
items.append(section)
|
||||
|
||||
# Find last sibling that is a page, skipping sections, as we need to
|
||||
# append the given section after all other pages
|
||||
tail = next(item for item in reversed(items) if isinstance(item, Page))
|
||||
head = tail.next_page
|
||||
|
||||
# Attach section to navigation and pages to each other
|
||||
nav.pages.extend(section.children)
|
||||
self._attach(section, [tail, *section.children, head])
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Render excerpts and pagination for the given view
|
||||
def _render(self, view: View):
|
||||
posts, pagination = view.posts, None
|
||||
|
||||
# Create pagination, if enabled
|
||||
if self.config.pagination:
|
||||
at = view.pages.index(view)
|
||||
|
||||
# Compute pagination boundaries
|
||||
step = self.config.pagination_per_page
|
||||
p, q = at * step, at * step + step
|
||||
|
||||
# Extract posts in pagination boundaries
|
||||
posts = view.posts[p:q]
|
||||
pagination = self._render_pagination(view, (p, q))
|
||||
|
||||
# Render excerpts for selected posts
|
||||
posts = [
|
||||
self._render_post(post.excerpt, view)
|
||||
for post in posts if post.excerpt
|
||||
]
|
||||
|
||||
# Return posts and pagination
|
||||
return posts, pagination
|
||||
|
||||
# Render excerpt in the context of the given view
|
||||
def _render_post(self, excerpt: Excerpt, view: View):
|
||||
excerpt.render(view, self.config.post_excerpt_separator)
|
||||
|
||||
# Determine whether to add posts to the table of contents of the view -
|
||||
# note that those settings can be changed individually for each type of
|
||||
# view, which is why we need to check the type of view and the table of
|
||||
# contents setting for that type of view
|
||||
toc = self.config.blog_toc
|
||||
if isinstance(view, Archive):
|
||||
toc = self.config.archive_toc
|
||||
if isinstance(view, Category):
|
||||
toc = self.config.categories_toc
|
||||
|
||||
# Attach top-level table of contents item to view if it should be added
|
||||
# and both, the view and excerpt contain table of contents items
|
||||
if toc and excerpt.toc.items and view.toc.items:
|
||||
view.toc.items[0].children.append(excerpt.toc.items[0])
|
||||
|
||||
# Return excerpt
|
||||
return excerpt
|
||||
|
||||
# Create pagination for the given view and range
|
||||
def _render_pagination(self, view: View, range: tuple[int, int]):
|
||||
p, q = range
|
||||
|
||||
# Create URL from the given page to another page
|
||||
def url_maker(n: int):
|
||||
return get_relative_url(view.pages[n - 1].url, view.url)
|
||||
|
||||
# Return pagination
|
||||
return Pagination(
|
||||
view.posts, page = q // (q - p),
|
||||
items_per_page = q - p,
|
||||
url_maker = url_maker
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Format path for post
|
||||
def _format_path_for_post(self, post: Post, config: MkDocsConfig):
|
||||
categories = post.config.categories[:self.config.post_url_max_categories]
|
||||
categories = [self._slugify_category(name) for name in categories]
|
||||
|
||||
# Replace placeholders in format string
|
||||
date = post.config.date.created
|
||||
path = self.config.post_url_format.format(
|
||||
categories = "/".join(categories),
|
||||
date = self._format_date_for_post_url(date, config),
|
||||
file = post.file.name,
|
||||
slug = post.config.slug or self._slugify_post(post)
|
||||
)
|
||||
|
||||
# Normalize path and strip slashes at the beginning and end
|
||||
path = posixpath.normpath(path.strip("/"))
|
||||
return posixpath.join(self.config.blog_dir, f"{path}.md")
|
||||
|
||||
# Format path for archive
|
||||
def _format_path_for_archive(self, post: Post, config: MkDocsConfig):
|
||||
date = post.config.date.created
|
||||
path = self.config.archive_url_format.format(
|
||||
date = self._format_date_for_archive_url(date, config)
|
||||
)
|
||||
|
||||
# Normalize path and strip slashes at the beginning and end
|
||||
path = posixpath.normpath(path.strip("/"))
|
||||
return posixpath.join(self.config.blog_dir, f"{path}.md")
|
||||
|
||||
# Format path for category
|
||||
def _format_path_for_category(self, name: str):
|
||||
path = self.config.categories_url_format.format(
|
||||
slug = self._slugify_category(name)
|
||||
)
|
||||
|
||||
# Normalize path and strip slashes at the beginning and end
|
||||
path = posixpath.normpath(path.strip("/"))
|
||||
return posixpath.join(self.config.blog_dir, f"{path}.md")
|
||||
|
||||
# Format path for pagination
|
||||
def _format_path_for_pagination(self, view: View, page: int):
|
||||
path = self.config.pagination_url_format.format(
|
||||
page = page
|
||||
)
|
||||
|
||||
# Compute base path for pagination - if the given view is an index file,
|
||||
# we need to pop the file name from the base so it's not part of the URL
|
||||
# and we need to append `index` to the path, so the paginated view is
|
||||
# also an index page - see https://t.ly/71MKF
|
||||
base, _ = posixpath.splitext(view.file.src_uri)
|
||||
if view.is_index:
|
||||
base = posixpath.dirname(base)
|
||||
path = posixpath.join(path, "index")
|
||||
|
||||
# Normalize path and strip slashes at the beginning and end
|
||||
path = posixpath.normpath(path.strip("/"))
|
||||
return posixpath.join(base, f"{path}.md")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Format date
|
||||
def _format_date(self, date: datetime, format: str, config: MkDocsConfig):
|
||||
locale: str = config.theme["language"].replace("-", "_")
|
||||
return format_date(date, format = format, locale = locale)
|
||||
|
||||
# Format date for post
|
||||
def _format_date_for_post(self, date: datetime, config: MkDocsConfig):
|
||||
format = self.config.post_date_format
|
||||
return self._format_date(date, format, config)
|
||||
|
||||
# Format date for post URL
|
||||
def _format_date_for_post_url(self, date: datetime, config: MkDocsConfig):
|
||||
format = self.config.post_url_date_format
|
||||
return self._format_date(date, format, config)
|
||||
|
||||
# Format date for archive
|
||||
def _format_date_for_archive(self, date: datetime, config: MkDocsConfig):
|
||||
format = self.config.archive_date_format
|
||||
return self._format_date(date, format, config)
|
||||
|
||||
# Format date for archive URL
|
||||
def _format_date_for_archive_url(self, date: datetime, config: MkDocsConfig):
|
||||
format = self.config.archive_url_date_format
|
||||
return self._format_date(date, format, config)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Slugify post title
|
||||
def _slugify_post(self, post: Post):
|
||||
separator = self.config.post_slugify_separator
|
||||
return self.config.post_slugify(post.title, separator)
|
||||
|
||||
# Slugify category
|
||||
def _slugify_category(self, name: str):
|
||||
separator = self.config.categories_slugify_separator
|
||||
return self.config.categories_slugify(name, separator)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Create a file for the given path, which must point to a valid source file,
|
||||
# either inside the temporary directory or the docs directory
|
||||
def _path_to_file(self, path: str, config: MkDocsConfig, *, temp = True):
|
||||
assert path.endswith(".md")
|
||||
file = File(
|
||||
path,
|
||||
config.docs_dir if not temp else self.temp_dir,
|
||||
config.site_dir,
|
||||
config.use_directory_urls
|
||||
)
|
||||
|
||||
# Hack: mark file as generated, so other plugins don't think it's part
|
||||
# of the file system. This is more or less a new quasi-standard that
|
||||
# still needs to be adopted by MkDocs, and was introduced by the
|
||||
# git-revision-date-localized-plugin - see https://bit.ly/3ZUmdBx
|
||||
if temp:
|
||||
file.generated_by = "material/blog"
|
||||
|
||||
# Return file
|
||||
return file
|
||||
|
||||
# Create a file with the given content on disk
|
||||
def _save_to_file(self, path: str, content: str):
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
with open(path, "w", encoding = "utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Translate the placeholder referenced by the given key
|
||||
def _translate(self, key: str, config: MkDocsConfig) -> str:
|
||||
env = config.theme.get_env()
|
||||
template = env.get_template(
|
||||
"partials/language.html", globals = { "config": config }
|
||||
)
|
||||
|
||||
# Translate placeholder
|
||||
return template.module.t(key)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.blog")
|
||||
53
docs/material/plugins/blog/readtime/__init__.py
Normal file
53
docs/material/plugins/blog/readtime/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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)
|
||||
47
docs/material/plugins/blog/readtime/parser.py
Normal file
47
docs/material/plugins/blog/readtime/parser.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Readtime parser
|
||||
class ReadtimeParser(HTMLParser):
|
||||
|
||||
# Initialize parser
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs = True)
|
||||
|
||||
# Keep track of text and images
|
||||
self.text = []
|
||||
self.images = 0
|
||||
|
||||
# Collect images
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "img":
|
||||
self.images += 1
|
||||
|
||||
# Collect text
|
||||
def handle_data(self, data):
|
||||
self.text.append(data)
|
||||
308
docs/material/plugins/blog/structure/__init__.py
Normal file
308
docs/material/plugins/blog/structure/__init__.py
Normal file
@@ -0,0 +1,308 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.files import File, Files
|
||||
from mkdocs.structure.nav import 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") 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}"
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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")
|
||||
39
docs/material/plugins/blog/structure/config.py
Normal file
39
docs/material/plugins/blog/structure/config.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 ListOfItems, Optional, Type
|
||||
|
||||
from .options import PostDate
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Post configuration
|
||||
class PostConfig(Config):
|
||||
authors = ListOfItems(Type(str), default = [])
|
||||
categories = ListOfItems(Type(str), default = [])
|
||||
date = PostDate()
|
||||
draft = Optional(Type(bool))
|
||||
readtime = Optional(Type(int))
|
||||
slug = Optional(Type(str))
|
||||
60
docs/material/plugins/blog/structure/markdown.py
Normal file
60
docs/material/plugins/blog/structure/markdown.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 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):
|
||||
self.page = page
|
||||
self.base = base
|
||||
|
||||
# Transform HTML after Markdown processing
|
||||
def run(self, root: Element):
|
||||
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
|
||||
97
docs/material/plugins/blog/structure/options.py
Normal file
97
docs/material/plugins/blog/structure/options.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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
|
||||
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
|
||||
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):
|
||||
continue
|
||||
|
||||
# Handle date - we set 00:00:00 as the default time, if the author
|
||||
# only supplied a date, and convert it to datetime
|
||||
if isinstance(value, date):
|
||||
config[key_name][key] = datetime.combine(value, time())
|
||||
|
||||
# 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
|
||||
21
docs/material/plugins/group/__init__.py
Normal file
21
docs/material/plugins/group/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
33
docs/material/plugins/group/config.py
Normal file
33
docs/material/plugins/group/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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))
|
||||
163
docs/material/plugins/group/plugin.py
Normal file
163
docs/material/plugins/group/plugin.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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")
|
||||
21
docs/material/plugins/info/__init__.py
Normal file
21
docs/material/plugins/info/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
37
docs/material/plugins/info/config.py
Normal file
37
docs/material/plugins/info/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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)
|
||||
27
docs/material/plugins/info/patterns.py
Normal file
27
docs/material/plugins/info/patterns.py
Normal file
@@ -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/",
|
||||
]
|
||||
498
docs/material/plugins/info/plugin.py
Normal file
498
docs/material/plugins/info/plugin.py
Normal file
@@ -0,0 +1,498 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import regex
|
||||
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 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]
|
||||
|
||||
# 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 = [
|
||||
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(os.getcwd()):
|
||||
paths_to_validate.remove(path)
|
||||
|
||||
# Report the invalid paths to the user
|
||||
if paths_to_validate:
|
||||
log.error(f"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 config.site_dir.startswith(os.getcwd()):
|
||||
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(os.getcwd()):
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
# 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()
|
||||
]))
|
||||
)
|
||||
|
||||
# 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", ""),
|
||||
"sys.path": sys.path,
|
||||
"excluded_entries": self.excluded_entries
|
||||
},
|
||||
default = str,
|
||||
indent = 2
|
||||
)
|
||||
)
|
||||
|
||||
# Retrieve list of processed files
|
||||
for a in f.filelist:
|
||||
files.append("".join([
|
||||
Fore.LIGHTBLACK_EX, 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")
|
||||
|
||||
# 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 regex.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, "r", 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 = abspath.replace(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") 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.info")
|
||||
21
docs/material/plugins/offline/__init__.py
Normal file
21
docs/material/plugins/offline/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
32
docs/material/plugins/offline/config.py
Normal file
32
docs/material/plugins/offline/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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)
|
||||
71
docs/material/plugins/offline/plugin.py
Normal file
71
docs/material/plugins/offline/plugin.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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}")
|
||||
21
docs/material/plugins/privacy/__init__.py
Normal file
21
docs/material/plugins/privacy/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
45
docs/material/plugins/privacy/config.py
Normal file
45
docs/material/plugins/privacy/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 DictOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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 external assets
|
||||
assets = Type(bool, default = True)
|
||||
assets_fetch = Type(bool, default = True)
|
||||
assets_fetch_dir = Type(str, default = "assets/external")
|
||||
assets_expr_map = DictOfItems(Type(str), default = {})
|
||||
43
docs/material/plugins/privacy/parser.py
Normal file
43
docs/material/plugins/privacy/parser.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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))
|
||||
552
docs/material/plugins/privacy/plugin.py
Normal file
552
docs/material/plugins/privacy/plugin.py
Normal file
@@ -0,0 +1,552 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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, ThreadPoolExecutor, wait
|
||||
from hashlib import sha1
|
||||
from mkdocs.config.config_options import ExtraScriptValue
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Privacy plugin
|
||||
class PrivacyPlugin(BasePlugin[PrivacyConfig]):
|
||||
|
||||
# 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_expr_map = {
|
||||
".css": r"url\((\s*http?[^)]+)\)",
|
||||
".js": r"[\"'](http[^\"']+\.(?:css|js(?:on)?))[\"']",
|
||||
**self.config.assets_expr_map
|
||||
}
|
||||
|
||||
# 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:
|
||||
path = url.geturl()
|
||||
if path not in config.extra_javascript:
|
||||
config.extra_javascript.append(
|
||||
ExtraScriptValue(path)
|
||||
)
|
||||
|
||||
# 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.
|
||||
@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=['\"]?http[^>]+>",
|
||||
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)
|
||||
|
||||
# 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)
|
||||
@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
|
||||
else:
|
||||
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
|
||||
])
|
||||
|
||||
# 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()
|
||||
|
||||
# Return element
|
||||
assert isinstance(parser.result, Element)
|
||||
return parser.result
|
||||
|
||||
# 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 []
|
||||
|
||||
# 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") as f:
|
||||
return [urlparse(url) for url in re.findall(expr, f.read())]
|
||||
|
||||
# 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 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 style sheet or favicon
|
||||
if rel == "stylesheet" or rel == "icon":
|
||||
file = self._queue(url, config)
|
||||
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)
|
||||
el.set("src", 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)[^>]+href|(?:script|img)[^>]+src)=['\"]?http[^>]+>",
|
||||
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:
|
||||
self._fetch(file, config)
|
||||
|
||||
# Register external asset as file
|
||||
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
|
||||
|
||||
# Download external asset
|
||||
log.info(f"Downloading external file: {file.url}")
|
||||
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 entire 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"
|
||||
])
|
||||
})
|
||||
|
||||
# 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)
|
||||
|
||||
# Patch all links to external assets in the given file
|
||||
def _patch(self, initiator: File):
|
||||
with open(initiator.abs_src_path, encoding = "utf-8") as f:
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match):
|
||||
value = match.group(1)
|
||||
|
||||
# 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"
|
||||
}
|
||||
21
docs/material/plugins/search/__init__.py
Normal file
21
docs/material/plugins/search/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
60
docs/material/plugins/search/config.py
Normal file
60
docs/material/plugins/search/config.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 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)))
|
||||
|
||||
# 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")
|
||||
581
docs/material/plugins/search/plugin.py
Normal file
581
docs/material/plugins/search/plugin.py
Normal file
@@ -0,0 +1,581 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 regex as re
|
||||
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from mkdocs import utils
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
from .config import SearchConfig
|
||||
|
||||
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")
|
||||
)))
|
||||
|
||||
# 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(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"]
|
||||
}
|
||||
|
||||
# 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 = re.compile(r"(\p{IsHan}+)", re.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
|
||||
])
|
||||
21
docs/material/plugins/social/__init__.py
Normal file
21
docs/material/plugins/social/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
50
docs/material/plugins/social/config.py
Normal file
50
docs/material/plugins/social/config.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 Deprecated, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Social plugin configuration
|
||||
class SocialConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
cache_dir = Type(str, default = ".cache/plugin/social")
|
||||
|
||||
# Settings for social cards
|
||||
cards = Type(bool, default = True)
|
||||
cards_dir = Type(str, default = "assets/images/social")
|
||||
cards_layout_options = Type(dict, default = {})
|
||||
|
||||
# Deprecated settings
|
||||
cards_color = Deprecated(
|
||||
option_type = Type(dict, default = {}),
|
||||
message =
|
||||
"Deprecated, use 'cards_layout_options.background_color' "
|
||||
"and 'cards_layout_options.color' with 'default' layout"
|
||||
)
|
||||
cards_font = Deprecated(
|
||||
option_type = Type(str),
|
||||
message = "Deprecated, use 'cards_layout_options.font_family'"
|
||||
)
|
||||
518
docs/material/plugins/social/plugin.py
Normal file
518
docs/material/plugins/social/plugin.py
Normal file
@@ -0,0 +1,518 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Disclaimer
|
||||
# -----------------------------------------------------------------------------
|
||||
# Please note: this version of the social plugin is not actively development
|
||||
# anymore. Instead, Material for MkDocs Insiders ships a complete rewrite of
|
||||
# the plugin which is much more powerful and addresses all shortcomings of
|
||||
# this implementation. Additionally, the new social plugin allows to create
|
||||
# entirely custom social cards. You can probably imagine, that this was a lot
|
||||
# of work to pull off. If you run into problems, or want to have additional
|
||||
# functionality, please consider sponsoring the project. You can then use the
|
||||
# new version of the plugin immediately.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
|
||||
from collections import defaultdict
|
||||
from hashlib import md5
|
||||
from io import BytesIO
|
||||
from mkdocs.commands.build import DuplicateFilter
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin
|
||||
from shutil import copyfile
|
||||
from tempfile import TemporaryFile
|
||||
from zipfile import ZipFile
|
||||
try:
|
||||
from cairosvg import svg2png
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from .config import SocialConfig
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Social plugin
|
||||
class SocialPlugin(BasePlugin[SocialConfig]):
|
||||
|
||||
def __init__(self):
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(4)
|
||||
|
||||
# Retrieve configuration
|
||||
def on_config(self, config):
|
||||
self.color = colors.get("indigo")
|
||||
self.config.cards = self.config.enabled
|
||||
if not self.config.cards:
|
||||
return
|
||||
|
||||
# Check dependencies
|
||||
if "Image" not in globals():
|
||||
raise PluginError(
|
||||
"Required dependencies of \"social\" plugin not found. "
|
||||
"Install with: pip install \"mkdocs-material[imaging]\""
|
||||
)
|
||||
|
||||
# Move color options
|
||||
if self.config.cards_color:
|
||||
|
||||
# Move background color to new option
|
||||
value = self.config.cards_color.get("fill")
|
||||
if value:
|
||||
self.config.cards_layout_options["background_color"] = value
|
||||
|
||||
# Move color to new option
|
||||
value = self.config.cards_color.get("text")
|
||||
if value:
|
||||
self.config.cards_layout_options["color"] = value
|
||||
|
||||
# Move font family to new option
|
||||
if self.config.cards_font:
|
||||
value = self.config.cards_font
|
||||
self.config.cards_layout_options["font_family"] = value
|
||||
|
||||
# Check if site URL is defined
|
||||
if not config.site_url:
|
||||
log.warning(
|
||||
"The \"site_url\" option is not set. The cards are generated, "
|
||||
"but not linked, so they won't be visible on social media."
|
||||
)
|
||||
|
||||
# Ensure presence of cache directory
|
||||
self.cache = self.config.cache_dir
|
||||
if not os.path.isdir(self.cache):
|
||||
os.makedirs(self.cache)
|
||||
|
||||
# Retrieve palette from theme configuration
|
||||
theme = config.theme
|
||||
if "palette" in theme:
|
||||
palette = theme["palette"]
|
||||
|
||||
# Use first palette, if multiple are defined
|
||||
if isinstance(palette, list):
|
||||
palette = palette[0]
|
||||
|
||||
# Set colors according to palette
|
||||
if "primary" in palette and palette["primary"]:
|
||||
primary = palette["primary"].replace(" ", "-")
|
||||
self.color = colors.get(primary, self.color)
|
||||
|
||||
# Retrieve color overrides
|
||||
options = self.config.cards_layout_options
|
||||
self.color = {
|
||||
"fill": options.get("background_color", self.color["fill"]),
|
||||
"text": options.get("color", self.color["text"])
|
||||
}
|
||||
|
||||
# Retrieve logo and font
|
||||
self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config)
|
||||
self.font = self._load_font(config)
|
||||
|
||||
self._image_promises = []
|
||||
|
||||
# Create social cards
|
||||
def on_page_markdown(self, markdown, page, config, files):
|
||||
if not self.config.cards:
|
||||
return
|
||||
|
||||
# Resolve image directory
|
||||
directory = self.config.cards_dir
|
||||
file, _ = os.path.splitext(page.file.src_path)
|
||||
|
||||
# Resolve path of image
|
||||
path = "{}.png".format(os.path.join(
|
||||
config.site_dir,
|
||||
directory,
|
||||
file
|
||||
))
|
||||
|
||||
# Resolve path of image directory
|
||||
directory = os.path.dirname(path)
|
||||
if not os.path.isdir(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
# Compute site name
|
||||
site_name = config.site_name
|
||||
|
||||
# Compute page title and description
|
||||
title = page.meta.get("title", page.title)
|
||||
description = config.site_description or ""
|
||||
if "description" in page.meta:
|
||||
description = page.meta["description"]
|
||||
|
||||
# Check type of meta title - see https://t.ly/m1Us
|
||||
if not isinstance(title, str):
|
||||
log.error(
|
||||
f"Page meta title of page '{page.file.src_uri}' must be a "
|
||||
f"string, but is of type \"{type(title)}\"."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Check type of meta description - see https://t.ly/m1Us
|
||||
if not isinstance(description, str):
|
||||
log.error(
|
||||
f"Page meta description of '{page.file.src_uri}' must be a "
|
||||
f"string, but is of type \"{type(description)}\"."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Generate social card if not in cache
|
||||
hash = md5("".join([
|
||||
site_name,
|
||||
str(title),
|
||||
description
|
||||
]).encode("utf-8"))
|
||||
file = os.path.join(self.cache, f"{hash.hexdigest()}.png")
|
||||
self._image_promises.append(self._executor.submit(
|
||||
self._cache_image,
|
||||
cache_path = file, dest_path = path,
|
||||
render_function = lambda: self._render_card(site_name, title, description)
|
||||
))
|
||||
|
||||
# Inject meta tags into page
|
||||
meta = page.meta.get("meta", [])
|
||||
page.meta["meta"] = meta + self._generate_meta(page, config)
|
||||
|
||||
def on_post_build(self, config):
|
||||
if not self.config.cards:
|
||||
return
|
||||
|
||||
# Check for exceptions
|
||||
for promise in self._image_promises:
|
||||
promise.result()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Render image to cache (if not present), then copy from cache to site
|
||||
def _cache_image(self, cache_path, dest_path, render_function):
|
||||
if not os.path.isfile(cache_path):
|
||||
image = render_function()
|
||||
image.save(cache_path)
|
||||
|
||||
# Copy file from cache
|
||||
copyfile(cache_path, dest_path)
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _get_font(self, kind, size):
|
||||
return ImageFont.truetype(self.font[kind], size)
|
||||
|
||||
# Render social card
|
||||
def _render_card(self, site_name, title, description):
|
||||
# Render background and logo
|
||||
image = self._render_card_background((1200, 630), self.color["fill"])
|
||||
image.alpha_composite(
|
||||
self._resized_logo_promise.result(),
|
||||
(1200 - 228, 64 - 4)
|
||||
)
|
||||
|
||||
# Render site name
|
||||
font = self._get_font("Bold", 36)
|
||||
image.alpha_composite(
|
||||
self._render_text((826, 48), font, site_name, 1, 20),
|
||||
(64 + 4, 64)
|
||||
)
|
||||
|
||||
# Render page title
|
||||
font = self._get_font("Bold", 92)
|
||||
image.alpha_composite(
|
||||
self._render_text((826, 328), font, title, 3, 30),
|
||||
(64, 160)
|
||||
)
|
||||
|
||||
# Render page description
|
||||
font = self._get_font("Regular", 28)
|
||||
image.alpha_composite(
|
||||
self._render_text((826, 80), font, description, 2, 14),
|
||||
(64 + 4, 512)
|
||||
)
|
||||
|
||||
# Return social card image
|
||||
return image
|
||||
|
||||
# Render social card background
|
||||
def _render_card_background(self, size, fill):
|
||||
return Image.new(mode = "RGBA", size = size, color = fill)
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _tmp_context(self):
|
||||
image = Image.new(mode = "RGBA", size = (50, 50))
|
||||
return ImageDraw.Draw(image)
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _text_bounding_box(self, text, font):
|
||||
return self._tmp_context().textbbox((0, 0), text, font = font)
|
||||
|
||||
# Render social card text
|
||||
def _render_text(self, size, font, text, lmax, spacing = 0):
|
||||
width = size[0]
|
||||
lines, words = [], []
|
||||
|
||||
# Remove remnant HTML tags
|
||||
text = re.sub(r"(<[^>]+>)", "", text)
|
||||
|
||||
# Retrieve y-offset of textbox to correct for spacing
|
||||
yoffset = 0
|
||||
|
||||
# Create drawing context and split text into lines
|
||||
for word in text.split(" "):
|
||||
combine = " ".join(words + [word])
|
||||
textbox = self._text_bounding_box(combine, font = font)
|
||||
yoffset = textbox[1]
|
||||
if not words or textbox[2] <= width:
|
||||
words.append(word)
|
||||
else:
|
||||
lines.append(words)
|
||||
words = [word]
|
||||
|
||||
# Join words for each line and create image
|
||||
lines.append(words)
|
||||
lines = [" ".join(line) for line in lines]
|
||||
image = Image.new(mode = "RGBA", size = size)
|
||||
|
||||
# Create drawing context and split text into lines
|
||||
context = ImageDraw.Draw(image)
|
||||
context.text(
|
||||
(0, spacing / 2 - yoffset), "\n".join(lines[:lmax]),
|
||||
font = font, fill = self.color["text"], spacing = spacing - yoffset
|
||||
)
|
||||
|
||||
# Return text image
|
||||
return image
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Generate meta tags
|
||||
def _generate_meta(self, page, config):
|
||||
directory = self.config.cards_dir
|
||||
file, _ = os.path.splitext(page.file.src_uri)
|
||||
|
||||
# Compute page title
|
||||
title = page.meta.get("title", page.title)
|
||||
if not page.is_homepage:
|
||||
title = f"{title} - {config.site_name}"
|
||||
|
||||
# Compute page description
|
||||
description = config.site_description
|
||||
if "description" in page.meta:
|
||||
description = page.meta["description"]
|
||||
|
||||
# Resolve image URL
|
||||
url = "{}.png".format(posixpath.join(
|
||||
config.site_url or ".",
|
||||
directory,
|
||||
file
|
||||
))
|
||||
|
||||
# Ensure forward slashes
|
||||
url = url.replace(os.path.sep, "/")
|
||||
|
||||
# Return meta tags
|
||||
return [
|
||||
|
||||
# Meta tags for Open Graph
|
||||
{ "property": "og:type", "content": "website" },
|
||||
{ "property": "og:title", "content": title },
|
||||
{ "property": "og:description", "content": description },
|
||||
{ "property": "og:image", "content": url },
|
||||
{ "property": "og:image:type", "content": "image/png" },
|
||||
{ "property": "og:image:width", "content": "1200" },
|
||||
{ "property": "og:image:height", "content": "630" },
|
||||
{ "property": "og:url", "content": page.canonical_url },
|
||||
|
||||
# Meta tags for Twitter
|
||||
{ "name": "twitter:card", "content": "summary_large_image" },
|
||||
# { "name": "twitter:site", "content": user },
|
||||
# { "name": "twitter:creator", "content": user },
|
||||
{ "name": "twitter:title", "content": title },
|
||||
{ "name": "twitter:description", "content": description },
|
||||
{ "name": "twitter:image", "content": url }
|
||||
]
|
||||
|
||||
def _load_resized_logo(self, config, width = 144):
|
||||
logo = self._load_logo(config)
|
||||
height = int(width * logo.height / logo.width)
|
||||
return logo.resize((width, height))
|
||||
|
||||
# Retrieve logo image or icon
|
||||
def _load_logo(self, config):
|
||||
theme = config.theme
|
||||
|
||||
# Handle images (precedence over icons)
|
||||
if "logo" in theme:
|
||||
_, extension = os.path.splitext(theme["logo"])
|
||||
|
||||
path = os.path.join(config.docs_dir, theme["logo"])
|
||||
|
||||
# Allow users to put the logo inside their custom_dir (theme["logo"] case)
|
||||
if theme.custom_dir:
|
||||
custom_dir_logo = os.path.join(theme.custom_dir, theme["logo"])
|
||||
if os.path.exists(custom_dir_logo):
|
||||
path = custom_dir_logo
|
||||
|
||||
# Load SVG and convert to PNG
|
||||
if extension == ".svg":
|
||||
return self._load_logo_svg(path)
|
||||
|
||||
# Load PNG, JPEG, etc.
|
||||
return Image.open(path).convert("RGBA")
|
||||
|
||||
# Handle icons
|
||||
icon = theme["icon"] or {}
|
||||
if "logo" in icon and icon["logo"]:
|
||||
logo = icon["logo"]
|
||||
else:
|
||||
logo = "material/library"
|
||||
|
||||
# Resolve path of package
|
||||
base = os.path.abspath(os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"../.."
|
||||
))
|
||||
|
||||
path = f"{base}/templates/.icons/{logo}.svg"
|
||||
|
||||
# Allow users to put the logo inside their custom_dir (theme["icon"]["logo"] case)
|
||||
if theme.custom_dir:
|
||||
custom_dir_logo = os.path.join(theme.custom_dir, ".icons", f"{logo}.svg")
|
||||
if os.path.exists(custom_dir_logo):
|
||||
path = custom_dir_logo
|
||||
|
||||
# Load icon data and fill with color
|
||||
return self._load_logo_svg(path, self.color["text"])
|
||||
|
||||
# Load SVG file and convert to PNG
|
||||
def _load_logo_svg(self, path, fill = None):
|
||||
file = BytesIO()
|
||||
data = open(path).read()
|
||||
|
||||
# Fill with color, if given
|
||||
if fill:
|
||||
data = data.replace("<svg", f"<svg fill=\"{fill}\"")
|
||||
|
||||
# Convert to PNG and return image
|
||||
svg2png(bytestring = data, write_to = file, scale = 10)
|
||||
return Image.open(file)
|
||||
|
||||
# Retrieve font
|
||||
def _load_font(self, config):
|
||||
name = self.config.cards_layout_options.get("font_family")
|
||||
if not name:
|
||||
|
||||
# Retrieve from theme (default: Roboto)
|
||||
theme = config.theme
|
||||
if isinstance(theme["font"], dict) and "text" in theme["font"]:
|
||||
name = theme["font"]["text"]
|
||||
else:
|
||||
name = "Roboto"
|
||||
|
||||
# Google fonts can return varients like OpenSans_Condensed-Regular.ttf so
|
||||
# we only use the font requested e.g. OpenSans-Regular.ttf
|
||||
font_filename_base = name.replace(' ', '')
|
||||
filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$"
|
||||
|
||||
font = {}
|
||||
# Check for cached files - note these may be in subfolders
|
||||
for currentpath, folders, files in os.walk(self.cache):
|
||||
for file in files:
|
||||
# Map available font weights to file paths
|
||||
fname = os.path.join(currentpath, file)
|
||||
match = re.search(filename_regex, fname)
|
||||
if match:
|
||||
font[match.group(1)] = fname
|
||||
|
||||
# If none found, fetch from Google and try again
|
||||
if len(font) == 0:
|
||||
self._load_font_from_google(name)
|
||||
for currentpath, folders, files in os.walk(self.cache):
|
||||
for file in files:
|
||||
# Map available font weights to file paths
|
||||
fname = os.path.join(currentpath, file)
|
||||
match = re.search(filename_regex, fname)
|
||||
if match:
|
||||
font[match.group(1)] = fname
|
||||
|
||||
# Return available font weights with fallback
|
||||
return defaultdict(lambda: font["Regular"], font)
|
||||
|
||||
# Retrieve font from Google Fonts
|
||||
def _load_font_from_google(self, name):
|
||||
url = "https://fonts.google.com/download?family={}"
|
||||
res = requests.get(url.format(name.replace(" ", "+")), stream = True)
|
||||
|
||||
# Write archive to temporary file
|
||||
tmp = TemporaryFile()
|
||||
for chunk in res.iter_content(chunk_size = 32768):
|
||||
tmp.write(chunk)
|
||||
|
||||
# Unzip fonts from temporary file
|
||||
zip = ZipFile(tmp)
|
||||
files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")]
|
||||
zip.extractall(self.cache, files)
|
||||
|
||||
# Close and delete temporary file
|
||||
tmp.close()
|
||||
return files
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs")
|
||||
log.addFilter(DuplicateFilter())
|
||||
|
||||
# Color palette
|
||||
colors = {
|
||||
"red": { "fill": "#ef5552", "text": "#ffffff" },
|
||||
"pink": { "fill": "#e92063", "text": "#ffffff" },
|
||||
"purple": { "fill": "#ab47bd", "text": "#ffffff" },
|
||||
"deep-purple": { "fill": "#7e56c2", "text": "#ffffff" },
|
||||
"indigo": { "fill": "#4051b5", "text": "#ffffff" },
|
||||
"blue": { "fill": "#2094f3", "text": "#ffffff" },
|
||||
"light-blue": { "fill": "#02a6f2", "text": "#ffffff" },
|
||||
"cyan": { "fill": "#00bdd6", "text": "#ffffff" },
|
||||
"teal": { "fill": "#009485", "text": "#ffffff" },
|
||||
"green": { "fill": "#4cae4f", "text": "#ffffff" },
|
||||
"light-green": { "fill": "#8bc34b", "text": "#ffffff" },
|
||||
"lime": { "fill": "#cbdc38", "text": "#000000" },
|
||||
"yellow": { "fill": "#ffec3d", "text": "#000000" },
|
||||
"amber": { "fill": "#ffc105", "text": "#000000" },
|
||||
"orange": { "fill": "#ffa724", "text": "#000000" },
|
||||
"deep-orange": { "fill": "#ff6e42", "text": "#ffffff" },
|
||||
"brown": { "fill": "#795649", "text": "#ffffff" },
|
||||
"grey": { "fill": "#757575", "text": "#ffffff" },
|
||||
"blue-grey": { "fill": "#546d78", "text": "#ffffff" },
|
||||
"black": { "fill": "#000000", "text": "#ffffff" },
|
||||
"white": { "fill": "#ffffff", "text": "#000000" }
|
||||
}
|
||||
29
docs/material/plugins/tags/__init__.py
Normal file
29
docs/material/plugins/tags/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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.
|
||||
# #
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Casefold a string for comparison when sorting
|
||||
def casefold(tag: str):
|
||||
return tag.casefold()
|
||||
37
docs/material/plugins/tags/config.py
Normal file
37
docs/material/plugins/tags/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 Optional, Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Tags plugin configuration
|
||||
class TagsConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for tags
|
||||
tags = Type(bool, default = True)
|
||||
tags_file = Optional(Type(str))
|
||||
186
docs/material/plugins/tags/plugin.py
Normal file
186
docs/material/plugins/tags/plugin.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# #
|
||||
# Copyright (c) 2025 Aetherinox
|
||||
#
|
||||
# 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 sys
|
||||
|
||||
from collections import defaultdict
|
||||
from markdown.extensions.toc import slugify
|
||||
from mkdocs import utils
|
||||
from mkdocs.plugins import BasePlugin
|
||||
|
||||
# deprecated, but kept for downward compatibility. Use 'material.plugins.tags'
|
||||
# as an import source instead. This import is removed in the next major version.
|
||||
from . import casefold
|
||||
from .config import TagsConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Tags plugin
|
||||
class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
supports_multiple_instances = True
|
||||
|
||||
# Initialize plugin
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Initialize tags
|
||||
self.tags = defaultdict(list)
|
||||
self.tags_file = None
|
||||
|
||||
# Retrieve tags mapping from configuration
|
||||
self.tags_map = config.extra.get("tags")
|
||||
|
||||
# Use override of slugify function
|
||||
toc = { "slugify": slugify, "separator": "-" }
|
||||
if "toc" in config.mdx_configs:
|
||||
toc = { **toc, **config.mdx_configs["toc"] }
|
||||
|
||||
# Partially apply slugify function
|
||||
self.slugify = lambda value: (
|
||||
toc["slugify"](str(value), toc["separator"])
|
||||
)
|
||||
|
||||
# Hack: 2nd pass for tags index page(s)
|
||||
def on_nav(self, nav, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Resolve tags index page
|
||||
file = self.config.tags_file
|
||||
if file:
|
||||
self.tags_file = self._get_tags_file(files, file)
|
||||
|
||||
# Build and render tags index page
|
||||
def on_page_markdown(self, markdown, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Skip, if page is excluded
|
||||
if page.file.inclusion.is_excluded():
|
||||
return
|
||||
|
||||
# Render tags index page
|
||||
if page.file == self.tags_file:
|
||||
return self._render_tag_index(markdown)
|
||||
|
||||
# Add page to tags index
|
||||
for tag in page.meta.get("tags", []):
|
||||
self.tags[tag].append(page)
|
||||
|
||||
# Inject tags into page (after search and before minification)
|
||||
def on_page_context(self, context, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Provide tags for page
|
||||
if "tags" in page.meta:
|
||||
context["tags"] = [
|
||||
self._render_tag(tag)
|
||||
for tag in page.meta["tags"]
|
||||
]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Obtain tags file
|
||||
def _get_tags_file(self, files, path):
|
||||
file = files.get_file_from_path(path)
|
||||
if not file:
|
||||
log.error(f"Tags file '{path}' does not exist.")
|
||||
sys.exit(1)
|
||||
|
||||
# Add tags file to files
|
||||
files.append(file)
|
||||
return file
|
||||
|
||||
# Render tags index
|
||||
def _render_tag_index(self, markdown):
|
||||
if "[TAGS]" in markdown:
|
||||
markdown = markdown.replace("[TAGS]", "<!-- material/tags -->")
|
||||
if not "<!-- material/tags -->" in markdown:
|
||||
markdown += "\n<!-- material/tags -->"
|
||||
|
||||
# Replace placeholder in Markdown with rendered tags index
|
||||
return markdown.replace("<!-- material/tags -->", "\n".join([
|
||||
self._render_tag_links(*args)
|
||||
for args in sorted(self.tags.items())
|
||||
]))
|
||||
|
||||
# Render the given tag and links to all pages with occurrences
|
||||
def _render_tag_links(self, tag, pages):
|
||||
classes = ["md-tag"]
|
||||
if isinstance(self.tags_map, dict):
|
||||
classes.append("md-tag-icon")
|
||||
type = self.tags_map.get(tag)
|
||||
if type:
|
||||
classes.append(f"md-tag--{type}")
|
||||
|
||||
# Render section for tag and a link to each page
|
||||
classes = " ".join(classes)
|
||||
content = [f"## <span class=\"{classes}\">{tag}</span>", ""]
|
||||
for page in pages:
|
||||
url = utils.get_relative_url(
|
||||
page.file.src_uri,
|
||||
self.tags_file.src_uri
|
||||
)
|
||||
|
||||
# Render link to page
|
||||
title = page.meta.get("title", page.title)
|
||||
content.append(f"- [{title}]({url})")
|
||||
|
||||
# Return rendered tag links
|
||||
return "\n".join(content)
|
||||
|
||||
# Render the given tag, linking to the tags index (if enabled)
|
||||
def _render_tag(self, tag):
|
||||
type = self.tags_map.get(tag) if self.tags_map else None
|
||||
if not self.tags_file or not self.slugify:
|
||||
return dict(name = tag, type = type)
|
||||
else:
|
||||
url = f"{self.tags_file.url}#{self.slugify(tag)}"
|
||||
return dict(name = tag, type = type, url = url)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.tags")
|
||||
Reference in New Issue
Block a user