From c15103048d22c8e3171c8965b8cf15ca99494086 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 15 Dec 2023 01:18:57 +0000 Subject: Deployed efb0730e to dev with MkDocs 1.5.3 and mike 2.0.0 --- dev/src/material/plugins/__init__.py | 19 + dev/src/material/plugins/blog/__init__.py | 19 + dev/src/material/plugins/blog/author.py | 38 + dev/src/material/plugins/blog/config.py | 88 ++ dev/src/material/plugins/blog/plugin.py | 884 +++++++++++++++++++++ dev/src/material/plugins/blog/readtime/__init__.py | 51 ++ dev/src/material/plugins/blog/readtime/parser.py | 45 ++ .../material/plugins/blog/structure/__init__.py | 292 +++++++ dev/src/material/plugins/blog/structure/config.py | 37 + .../material/plugins/blog/structure/markdown.py | 58 ++ dev/src/material/plugins/blog/structure/options.py | 87 ++ .../material/plugins/blog/templates/__init__.py | 42 + dev/src/material/plugins/group/__init__.py | 19 + dev/src/material/plugins/group/config.py | 33 + dev/src/material/plugins/group/plugin.py | 151 ++++ dev/src/material/plugins/info/__init__.py | 19 + dev/src/material/plugins/info/config.py | 35 + dev/src/material/plugins/info/plugin.py | 245 ++++++ dev/src/material/plugins/offline/__init__.py | 19 + dev/src/material/plugins/offline/config.py | 30 + dev/src/material/plugins/offline/plugin.py | 69 ++ dev/src/material/plugins/search/__init__.py | 19 + dev/src/material/plugins/search/config.py | 58 ++ dev/src/material/plugins/search/plugin.py | 580 ++++++++++++++ dev/src/material/plugins/social/__init__.py | 19 + dev/src/material/plugins/social/config.py | 48 ++ dev/src/material/plugins/social/plugin.py | 516 ++++++++++++ dev/src/material/plugins/tags/__init__.py | 27 + dev/src/material/plugins/tags/config.py | 38 + dev/src/material/plugins/tags/plugin.py | 182 +++++ 30 files changed, 3767 insertions(+) create mode 100644 dev/src/material/plugins/__init__.py create mode 100644 dev/src/material/plugins/blog/__init__.py create mode 100644 dev/src/material/plugins/blog/author.py create mode 100644 dev/src/material/plugins/blog/config.py create mode 100644 dev/src/material/plugins/blog/plugin.py create mode 100644 dev/src/material/plugins/blog/readtime/__init__.py create mode 100644 dev/src/material/plugins/blog/readtime/parser.py create mode 100644 dev/src/material/plugins/blog/structure/__init__.py create mode 100644 dev/src/material/plugins/blog/structure/config.py create mode 100644 dev/src/material/plugins/blog/structure/markdown.py create mode 100644 dev/src/material/plugins/blog/structure/options.py create mode 100644 dev/src/material/plugins/blog/templates/__init__.py create mode 100644 dev/src/material/plugins/group/__init__.py create mode 100644 dev/src/material/plugins/group/config.py create mode 100644 dev/src/material/plugins/group/plugin.py create mode 100644 dev/src/material/plugins/info/__init__.py create mode 100644 dev/src/material/plugins/info/config.py create mode 100644 dev/src/material/plugins/info/plugin.py create mode 100644 dev/src/material/plugins/offline/__init__.py create mode 100644 dev/src/material/plugins/offline/config.py create mode 100644 dev/src/material/plugins/offline/plugin.py create mode 100644 dev/src/material/plugins/search/__init__.py create mode 100644 dev/src/material/plugins/search/config.py create mode 100644 dev/src/material/plugins/search/plugin.py create mode 100644 dev/src/material/plugins/social/__init__.py create mode 100644 dev/src/material/plugins/social/config.py create mode 100644 dev/src/material/plugins/social/plugin.py create mode 100644 dev/src/material/plugins/tags/__init__.py create mode 100644 dev/src/material/plugins/tags/config.py create mode 100644 dev/src/material/plugins/tags/plugin.py (limited to 'dev/src/material/plugins') diff --git a/dev/src/material/plugins/__init__.py b/dev/src/material/plugins/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/dev/src/material/plugins/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/dev/src/material/plugins/blog/__init__.py b/dev/src/material/plugins/blog/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/dev/src/material/plugins/blog/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/dev/src/material/plugins/blog/author.py b/dev/src/material/plugins/blog/author.py new file mode 100644 index 00000000..1dcfc2de --- /dev/null +++ b/dev/src/material/plugins/blog/author.py @@ -0,0 +1,38 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from mkdocs.config.base import Config +from mkdocs.config.config_options import DictOfItems, SubConfig, Type + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Author +class Author(Config): + name = Type(str) + description = Type(str) + avatar = Type(str) + +# ----------------------------------------------------------------------------- + +# Authors +class Authors(Config): + authors = DictOfItems(SubConfig(Author), default = {}) diff --git a/dev/src/material/plugins/blog/config.py b/dev/src/material/plugins/blog/config.py new file mode 100644 index 00000000..c7a85095 --- /dev/null +++ b/dev/src/material/plugins/blog/config.py @@ -0,0 +1,88 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from functools import partial +from markdown.extensions.toc import slugify +from mkdocs.config.config_options import Choice, Deprecated, Optional, Type +from mkdocs.config.base import Config + +# ----------------------------------------------------------------------------- +# 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((type(slugify), partial), default = slugify) + 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 = "") + 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((type(slugify), partial), default = slugify) + categories_slugify_separator = Type(str, default = "-") + categories_allowed = Type(list, default = []) + categories_toc = Optional(Type(bool)) + + # Settings for pagination + pagination = Type(bool, default = True) + pagination_per_page = Type(int, default = 10) + pagination_url_format = Type(str, default = "page/{page}") + pagination_format = Type(str, default = "~2~") + pagination_if_single_page = Type(bool, default = False) + pagination_keep_content = Type(bool, default = False) + + # Settings for authors + authors = Type(bool, default = True) + authors_file = Type(str, default = "{blog}/.authors.yml") + + # 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") diff --git a/dev/src/material/plugins/blog/plugin.py b/dev/src/material/plugins/blog/plugin.py new file mode 100644 index 00000000..375b8cfe --- /dev/null +++ b/dev/src/material/plugins/blog/plugin.py @@ -0,0 +1,884 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import logging +import os +import posixpath +import yaml + +from babel.dates import format_date +from datetime import datetime +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 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 +from .templates import url_filter + +# ----------------------------------------------------------------------------- +# 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 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: + views = self._generate_archive(config, files) + self.blog.views.extend(views) + + # Generate views for categories + if self.config.categories: + views = self._generate_categories(config, files) + self.blog.views.extend(views) + + # 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): + page.file.inclusion = InclusionLevel.EXCLUDED + 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): + 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: + path = page.file.src_path + if self.config.post_excerpt == "required": + raise PluginError( + f"Couldn't find '{separator}' separator in '{path}'" + ) + else: + page.markdown += f"\n\n{separator}" + + # 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) + + # Register custom template filters + env.filters["date"] = date_filter + env.filters["url"] = url_filter + + # 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 + + # If the current view is paginated, replace and rewire it - the current + # view temporarily becomes the main view, and is reset after rendering + assert isinstance(view, View) + if view != page: + prev = view.pages[view.pages.index(page) - 1] + + # Replace previous page with current page + items = self._resolve_siblings(view, nav) + items[items.index(prev)] = page + + # 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 + + # After rendering a paginated view, replace the URL of the paginated view + # with the URL of the original view - since we need to replace the original + # view with a paginated view in `on_page_context` for correct resolution of + # the active state, we must fix the paginated view URLs after rendering + def on_post_page(self, output, *, page, config): + 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 + + # If the current view is paginated, replace the URL of the paginated + # view with the URL of the original view - see https://t.ly/Yeh-P + assert isinstance(view, View) + if view != page: + page.file.url = view.file.url + + # 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() + if not config.authors and warnings: + log.warning( + f"Action required: the format of the authors file changed.\n" + f"All authors must now be located under the 'authors' key.\n" + f"Please adjust '{file}' to match:\n" + f"\n" + f"authors:\n" + f" squidfunk:\n" + f" avatar: https://avatars.githubusercontent.com/u/932156\n" + f" description: Creator\n" + f" name: Martin Donath\n" + f"\n" + ) + 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): + 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 + self._save_to_file(file.abs_src_path, f"# {name}") + + # Create and yield view - we don't explicitly set the title of + # the view, so authors can override them in the page's content + if not isinstance(file.page, Archive): + yield Archive(None, 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 + self._save_to_file(file.abs_src_path, f"# {name}") + + # Create and yield view - we don't explicitly set the title of + # the view, so authors can override them in the page's content + if not isinstance(file.page, Category): + yield Category(None, 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 + copy_file(view.file.abs_src_path, file.abs_src_path) + + # Create view and attach to previous page + if not isinstance(file.page, View): + yield View(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 = config.theme["language"] + 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") diff --git a/dev/src/material/plugins/blog/readtime/__init__.py b/dev/src/material/plugins/blog/readtime/__init__.py new file mode 100644 index 00000000..a0c149b9 --- /dev/null +++ b/dev/src/material/plugins/blog/readtime/__init__.py @@ -0,0 +1,51 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +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) diff --git a/dev/src/material/plugins/blog/readtime/parser.py b/dev/src/material/plugins/blog/readtime/parser.py new file mode 100644 index 00000000..b91a7b30 --- /dev/null +++ b/dev/src/material/plugins/blog/readtime/parser.py @@ -0,0 +1,45 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from 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) diff --git a/dev/src/material/plugins/blog/structure/__init__.py b/dev/src/material/plugins/blog/structure/__init__.py new file mode 100644 index 00000000..2fc541fe --- /dev/null +++ b/dev/src/material/plugins/blog/structure/__init__.py @@ -0,0 +1,292 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import 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. + # As we want to provide the best possible authoring 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 user 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 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, *_ = self.content.split(separator, 1) + + # 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): + + # Initialize view + def __init__(self, title: str | None, file: File, config: MkDocsConfig): + super().__init__(title, file, config) + self.parent: View | Section + + # 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") diff --git a/dev/src/material/plugins/blog/structure/config.py b/dev/src/material/plugins/blog/structure/config.py new file mode 100644 index 00000000..129491b9 --- /dev/null +++ b/dev/src/material/plugins/blog/structure/config.py @@ -0,0 +1,37 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from 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)) diff --git a/dev/src/material/plugins/blog/structure/markdown.py b/dev/src/material/plugins/blog/structure/markdown.py new file mode 100644 index 00000000..64ade554 --- /dev/null +++ b/dev/src/material/plugins/blog/structure/markdown.py @@ -0,0 +1,58 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from 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 diff --git a/dev/src/material/plugins/blog/structure/options.py b/dev/src/material/plugins/blog/structure/options.py new file mode 100644 index 00000000..281dec9f --- /dev/null +++ b/dev/src/material/plugins/blog/structure/options.py @@ -0,0 +1,87 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from 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, + # since we want to allow the user to specify custom and arbitrary date + # values 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(): + 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 diff --git a/dev/src/material/plugins/blog/templates/__init__.py b/dev/src/material/plugins/blog/templates/__init__.py new file mode 100644 index 00000000..9f7d794b --- /dev/null +++ b/dev/src/material/plugins/blog/templates/__init__.py @@ -0,0 +1,42 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from jinja2 import pass_context +from jinja2.runtime import Context +from material.plugins.blog.structure import View +from mkdocs.utils.templates import url_filter as _url_filter + +# ----------------------------------------------------------------------------- +# Functions +# ----------------------------------------------------------------------------- + +# Filter for normalizing URLs with support for paginated views +@pass_context +def url_filter(context: Context, url: str): + 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): + if page.url == url: + url = page.pages[0].url + + # Forward to original template filter + return _url_filter(context, url) diff --git a/dev/src/material/plugins/group/__init__.py b/dev/src/material/plugins/group/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/dev/src/material/plugins/group/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/dev/src/material/plugins/group/config.py b/dev/src/material/plugins/group/config.py new file mode 100644 index 00000000..fb19222a --- /dev/null +++ b/dev/src/material/plugins/group/config.py @@ -0,0 +1,33 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +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) diff --git a/dev/src/material/plugins/group/plugin.py b/dev/src/material/plugins/group/plugin.py new file mode 100644 index 00000000..4ab13dbf --- /dev/null +++ b/dev/src/material/plugins/group/plugin.py @@ -0,0 +1,151 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +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 + + # 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` events and is effectively + # useless. However, the `on_startup` event 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") diff --git a/dev/src/material/plugins/info/__init__.py b/dev/src/material/plugins/info/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/dev/src/material/plugins/info/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/dev/src/material/plugins/info/config.py b/dev/src/material/plugins/info/config.py new file mode 100644 index 00000000..cbd64d4c --- /dev/null +++ b/dev/src/material/plugins/info/config.py @@ -0,0 +1,35 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from 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) diff --git a/dev/src/material/plugins/info/plugin.py b/dev/src/material/plugins/info/plugin.py new file mode 100644 index 00000000..7c6fdc17 --- /dev/null +++ b/dev/src/material/plugins/info/plugin.py @@ -0,0 +1,245 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import json +import logging +import os +import platform +import requests +import sys + +from colorama import Fore, Style +from importlib.metadata import distributions, version +from io import BytesIO +from markdown.extensions.toc import slugify +from mkdocs.plugins import BasePlugin, event_priority +from mkdocs.structure.files import get_files +from mkdocs.utils import get_theme_dir +from zipfile import ZipFile, ZIP_DEFLATED + +from .config import InfoConfig + +# ----------------------------------------------------------------------------- +# 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 + + # 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 user 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.dirs.index(get_theme_dir(config.theme.name)): + 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() + + # Create in-memory archive and prompt user to enter 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 user 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, "-")]) + + # Create self-contained example from project + files: list[str] = [] + with ZipFile(archive, "a", ZIP_DEFLATED, False) as f: + for path in ["mkdocs.yml", "requirements.txt"]: + if os.path.isfile(path): + f.write(path, os.path.join(example, path)) + + # Append all files visible to MkDocs + for file in get_files(config): + path = os.path.relpath(file.abs_src_path, os.path.curdir) + f.write(path, os.path.join(example, path)) + + # 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(), + "python": platform.python_version() + }, + 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(Style.RESET_ALL) + + # Exit, unless explicitly told not to + if self.config.archive_stop_on_violation: + sys.exit(1) + +# ----------------------------------------------------------------------------- +# 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 + +# ----------------------------------------------------------------------------- +# Data +# ----------------------------------------------------------------------------- + +# Set up logging +log = logging.getLogger("mkdocs.material.info") diff --git a/dev/src/material/plugins/offline/__init__.py b/dev/src/material/plugins/offline/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/dev/src/material/plugins/offline/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/dev/src/material/plugins/offline/config.py b/dev/src/material/plugins/offline/config.py new file mode 100644 index 00000000..49f51a94 --- /dev/null +++ b/dev/src/material/plugins/offline/config.py @@ -0,0 +1,30 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from mkdocs.config.config_options import Type +from mkdocs.config.base import Config + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Offline plugin configuration +class OfflineConfig(Config): + enabled = Type(bool, default = True) diff --git a/dev/src/material/plugins/offline/plugin.py b/dev/src/material/plugins/offline/plugin.py new file mode 100644 index 00000000..abcb2598 --- /dev/null +++ b/dev/src/material/plugins/offline/plugin.py @@ -0,0 +1,69 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +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}") diff --git a/dev/src/material/plugins/search/__init__.py b/dev/src/material/plugins/search/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/dev/src/material/plugins/search/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/dev/src/material/plugins/search/config.py b/dev/src/material/plugins/search/config.py new file mode 100644 index 00000000..e150fbb3 --- /dev/null +++ b/dev/src/material/plugins/search/config.py @@ -0,0 +1,58 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from 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 = ListOfItems(Choice(pipeline), default = []) + + # Settings for text segmentation (Chinese) + jieba_dict = Optional(Type(str)) + jieba_dict_user = Optional(Type(str)) + + # Unsupported settings, originally implemented in MkDocs + indexing = Deprecated(message = "Unsupported option") + prebuild_index = Deprecated(message = "Unsupported option") + min_search_length = Deprecated(message = "Unsupported option") diff --git a/dev/src/material/plugins/search/plugin.py b/dev/src/material/plugins/search/plugin.py new file mode 100644 index 00000000..5c254e3f --- /dev/null +++ b/dev/src/material/plugins/search/plugin.py @@ -0,0 +1,580 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +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_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 not self.config.pipeline: + 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", {}) + 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", {}) + 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 = {}): + self.tag = tag + self.attrs = attrs + + # 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): + if 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): + if 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"") + + # 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 +]) diff --git a/dev/src/material/plugins/social/__init__.py b/dev/src/material/plugins/social/__init__.py new file mode 100644 index 00000000..d1899378 --- /dev/null +++ b/dev/src/material/plugins/social/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/dev/src/material/plugins/social/config.py b/dev/src/material/plugins/social/config.py new file mode 100644 index 00000000..2d87c25e --- /dev/null +++ b/dev/src/material/plugins/social/config.py @@ -0,0 +1,48 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from 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'" + ) diff --git a/dev/src/material/plugins/social/plugin.py b/dev/src/material/plugins/social/plugin.py new file mode 100644 index 00000000..3cdfa3ce --- /dev/null +++ b/dev/src/material/plugins/social/plugin.py @@ -0,0 +1,516 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# ----------------------------------------------------------------------------- +# 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(" + +# 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() diff --git a/dev/src/material/plugins/tags/config.py b/dev/src/material/plugins/tags/config.py new file mode 100644 index 00000000..f2d95084 --- /dev/null +++ b/dev/src/material/plugins/tags/config.py @@ -0,0 +1,38 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from functools import partial +from markdown.extensions.toc import slugify +from mkdocs.config.config_options import Optional, Type +from mkdocs.config.base import Config + +from . import casefold + +# ----------------------------------------------------------------------------- +# 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)) diff --git a/dev/src/material/plugins/tags/plugin.py b/dev/src/material/plugins/tags/plugin.py new file mode 100644 index 00000000..e5ce6bde --- /dev/null +++ b/dev/src/material/plugins/tags/plugin.py @@ -0,0 +1,182 @@ +# Copyright (c) 2016-2023 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +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 not "[TAGS]" in markdown: + markdown += "\n[TAGS]" + + # Replace placeholder in Markdown with rendered tags index + return markdown.replace("[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"## {tag}", ""] + 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") -- cgit v1.2.3-70-g09d2