diff options
Diffstat (limited to 'docs/src/plugins')
30 files changed, 0 insertions, 3767 deletions
diff --git a/docs/src/plugins/__init__.py b/docs/src/plugins/__init__.py deleted file mode 100644 index d1899378..00000000 --- a/docs/src/plugins/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. diff --git a/docs/src/plugins/blog/__init__.py b/docs/src/plugins/blog/__init__.py deleted file mode 100644 index d1899378..00000000 --- a/docs/src/plugins/blog/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. diff --git a/docs/src/plugins/blog/author.py b/docs/src/plugins/blog/author.py deleted file mode 100644 index 1dcfc2de..00000000 --- a/docs/src/plugins/blog/author.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from mkdocs.config.base import Config -from mkdocs.config.config_options import DictOfItems, 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/docs/src/plugins/blog/config.py b/docs/src/plugins/blog/config.py deleted file mode 100644 index c7a85095..00000000 --- a/docs/src/plugins/blog/config.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from 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 = "<!-- more -->") - post_readtime = Type(bool, default = True) - post_readtime_words_per_minute = Type(int, default = 265) - - # Settings for archive - archive = Type(bool, default = True) - archive_name = Type(str, default = "blog.archive") - archive_date_format = Type(str, default = "yyyy") - archive_url_date_format = Type(str, default = "yyyy") - archive_url_format = Type(str, default = "archive/{date}") - archive_toc = Optional(Type(bool)) - - # Settings for categories - categories = Type(bool, default = True) - categories_name = Type(str, default = "blog.categories") - categories_url_format = Type(str, default = "category/{slug}") - categories_slugify = Type((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/docs/src/plugins/blog/plugin.py b/docs/src/plugins/blog/plugin.py deleted file mode 100644 index 375b8cfe..00000000 --- a/docs/src/plugins/blog/plugin.py +++ /dev/null @@ -1,884 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from __future__ import annotations - -import logging -import os -import 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/docs/src/plugins/blog/readtime/__init__.py b/docs/src/plugins/blog/readtime/__init__.py deleted file mode 100644 index a0c149b9..00000000 --- a/docs/src/plugins/blog/readtime/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -import re - -from math import ceil - -from .parser import ReadtimeParser - -# ----------------------------------------------------------------------------- -# Functions -# ----------------------------------------------------------------------------- - -# Compute readtime - we first used the original readtime library, but the list -# of dependencies it brings with it increased the size of the Docker image by -# 20 MB (packed), which is an increase of 50%. For this reason, we adapt the -# original readtime algorithm to our needs - see https://t.ly/fPZ7L -def readtime(html: str, words_per_minute: int): - parser = ReadtimeParser() - parser.feed(html) - parser.close() - - # Extract words from text and compute readtime in seconds - words = len(re.split(r"\W+", "".join(parser.text))) - seconds = ceil(words / words_per_minute * 60) - - # Account for additional images - delta = 12 - for _ in range(parser.images): - seconds += delta - if delta > 3: delta -= 1 - - # Return readtime in minutes - return ceil(seconds / 60) diff --git a/docs/src/plugins/blog/readtime/parser.py b/docs/src/plugins/blog/readtime/parser.py deleted file mode 100644 index b91a7b30..00000000 --- a/docs/src/plugins/blog/readtime/parser.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from html.parser import HTMLParser - -# ----------------------------------------------------------------------------- -# 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/docs/src/plugins/blog/structure/__init__.py b/docs/src/plugins/blog/structure/__init__.py deleted file mode 100644 index 2fc541fe..00000000 --- a/docs/src/plugins/blog/structure/__init__.py +++ /dev/null @@ -1,292 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from __future__ import annotations - -import logging -import os -import yaml - -from copy import copy -from markdown import Markdown -from material.plugins.blog.author import Author -from 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/docs/src/plugins/blog/structure/config.py b/docs/src/plugins/blog/structure/config.py deleted file mode 100644 index 129491b9..00000000 --- a/docs/src/plugins/blog/structure/config.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from mkdocs.config.base import Config -from mkdocs.config.config_options import 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/docs/src/plugins/blog/structure/markdown.py b/docs/src/plugins/blog/structure/markdown.py deleted file mode 100644 index 64ade554..00000000 --- a/docs/src/plugins/blog/structure/markdown.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from 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/docs/src/plugins/blog/structure/options.py b/docs/src/plugins/blog/structure/options.py deleted file mode 100644 index 281dec9f..00000000 --- a/docs/src/plugins/blog/structure/options.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from datetime import date, datetime, time -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/docs/src/plugins/blog/templates/__init__.py b/docs/src/plugins/blog/templates/__init__.py deleted file mode 100644 index 9f7d794b..00000000 --- a/docs/src/plugins/blog/templates/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from 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/docs/src/plugins/group/__init__.py b/docs/src/plugins/group/__init__.py deleted file mode 100644 index d1899378..00000000 --- a/docs/src/plugins/group/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. diff --git a/docs/src/plugins/group/config.py b/docs/src/plugins/group/config.py deleted file mode 100644 index fb19222a..00000000 --- a/docs/src/plugins/group/config.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from __future__ import annotations - -from 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/docs/src/plugins/group/plugin.py b/docs/src/plugins/group/plugin.py deleted file mode 100644 index 4ab13dbf..00000000 --- a/docs/src/plugins/group/plugin.py +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -import logging - -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/docs/src/plugins/info/__init__.py b/docs/src/plugins/info/__init__.py deleted file mode 100644 index d1899378..00000000 --- a/docs/src/plugins/info/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. diff --git a/docs/src/plugins/info/config.py b/docs/src/plugins/info/config.py deleted file mode 100644 index cbd64d4c..00000000 --- a/docs/src/plugins/info/config.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from mkdocs.config.config_options import Type -from mkdocs.config.base import Config - -# ----------------------------------------------------------------------------- -# Classes -# ----------------------------------------------------------------------------- - -# Info plugin configuration -class InfoConfig(Config): - enabled = Type(bool, default = True) - enabled_on_serve = Type(bool, default = False) - - # Settings for archive - archive = Type(bool, default = True) - archive_stop_on_violation = Type(bool, default = True) diff --git a/docs/src/plugins/info/plugin.py b/docs/src/plugins/info/plugin.py deleted file mode 100644 index 7c6fdc17..00000000 --- a/docs/src/plugins/info/plugin.py +++ /dev/null @@ -1,245 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -import json -import logging -import os -import 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/docs/src/plugins/offline/__init__.py b/docs/src/plugins/offline/__init__.py deleted file mode 100644 index d1899378..00000000 --- a/docs/src/plugins/offline/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. diff --git a/docs/src/plugins/offline/config.py b/docs/src/plugins/offline/config.py deleted file mode 100644 index 49f51a94..00000000 --- a/docs/src/plugins/offline/config.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from mkdocs.config.config_options import Type -from mkdocs.config.base import Config - -# ----------------------------------------------------------------------------- -# Classes -# ----------------------------------------------------------------------------- - -# Offline plugin configuration -class OfflineConfig(Config): - enabled = Type(bool, default = True) diff --git a/docs/src/plugins/offline/plugin.py b/docs/src/plugins/offline/plugin.py deleted file mode 100644 index abcb2598..00000000 --- a/docs/src/plugins/offline/plugin.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -import os - -from mkdocs.plugins import BasePlugin, event_priority - -from .config import OfflineConfig - -# ----------------------------------------------------------------------------- -# Classes -# ----------------------------------------------------------------------------- - -# Offline plugin -class OfflinePlugin(BasePlugin[OfflineConfig]): - - # Set configuration for offline build - def on_config(self, config): - if not self.config.enabled: - return - - # Ensure correct resolution of links when viewing the site from the - # file system by disabling directory URLs - config.use_directory_urls = False - - # Append iframe-worker to polyfills/shims - config.extra["polyfills"] = config.extra.get("polyfills", []) - if not any("iframe-worker" in url for url in config.extra["polyfills"]): - script = "https://unpkg.com/iframe-worker/shim" - config.extra["polyfills"].append(script) - - # Add support for offline search (run latest) - the search index is copied - # and inlined into a script, so that it can be used without a server - @event_priority(-100) - def on_post_build(self, *, config): - if not self.config.enabled: - return - - # Ensure presence of search index - path = os.path.join(config.site_dir, "search") - file = os.path.join(path, "search_index.json") - if not os.path.isfile(file): - return - - # Obtain search index contents - with open(file, encoding = "utf-8") as f: - data = f.read() - - # Inline search index contents into script - file = os.path.join(path, "search_index.js") - with open(file, "w", encoding = "utf-8") as f: - f.write(f"var __index = {data}") diff --git a/docs/src/plugins/search/__init__.py b/docs/src/plugins/search/__init__.py deleted file mode 100644 index d1899378..00000000 --- a/docs/src/plugins/search/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. diff --git a/docs/src/plugins/search/config.py b/docs/src/plugins/search/config.py deleted file mode 100644 index e150fbb3..00000000 --- a/docs/src/plugins/search/config.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from mkdocs.config.config_options import ( - Choice, - Deprecated, - Optional, - ListOfItems, - Type -) -from mkdocs.config.base import Config -from mkdocs.contrib.search import LangOption - -# ----------------------------------------------------------------------------- -# Options -# ----------------------------------------------------------------------------- - -# Options for search pipeline -pipeline = ("stemmer", "stopWordFilter", "trimmer") - -# ----------------------------------------------------------------------------- -# Classes -# ----------------------------------------------------------------------------- - -# Search 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/docs/src/plugins/search/plugin.py b/docs/src/plugins/search/plugin.py deleted file mode 100644 index 5c254e3f..00000000 --- a/docs/src/plugins/search/plugin.py +++ /dev/null @@ -1,580 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -import json -import logging -import os -import 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"</{tag}>") - - # Called for the text contents of each tag - def handle_data(self, data): - if self.skip.intersection(self.context): - return - - # Collapse whitespace in non-pre contexts - if not "pre" in self.context: - if not data.isspace(): - data = data.replace("\n", " ") - else: - data = " " - - # Handle preface - ensure top-level section - if not self.section: - self.section = Section(Element("hx")) - self.data.append(self.section) - - # Handle section headline - if self.section.el in self.context: - permalink = False - for el in self.context: - if el.tag == "a" and el.attrs.get("class") == "headerlink": - permalink = True - - # Ignore permalinks - if not permalink: - self.section.title.append( - escape(data, quote = False) - ) - - # Collapse adjacent whitespace - elif data.isspace(): - if not self.section.text or not self.section.text[-1].isspace(): - self.section.text.append(data) - elif "pre" in self.context: - self.section.text.append(data) - - # Handle everything else - else: - self.section.text.append( - escape(data, quote = False) - ) - -# ----------------------------------------------------------------------------- -# Data -# ----------------------------------------------------------------------------- - -# Set up logging -log = logging.getLogger("mkdocs.material.search") - -# Tags that are self-closing -void = set([ - "area", # Image map areas - "base", # Document base - "br", # Line breaks - "col", # Table columns - "embed", # External content - "hr", # Horizontal rules - "img", # Images - "input", # Input fields - "link", # Links - "meta", # Metadata - "param", # External parameters - "source", # Image source sets - "track", # Text track - "wbr" # Line break opportunities -]) diff --git a/docs/src/plugins/social/__init__.py b/docs/src/plugins/social/__init__.py deleted file mode 100644 index d1899378..00000000 --- a/docs/src/plugins/social/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. diff --git a/docs/src/plugins/social/config.py b/docs/src/plugins/social/config.py deleted file mode 100644 index 2d87c25e..00000000 --- a/docs/src/plugins/social/config.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from mkdocs.config.base import Config -from mkdocs.config.config_options import 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/docs/src/plugins/social/plugin.py b/docs/src/plugins/social/plugin.py deleted file mode 100644 index 3cdfa3ce..00000000 --- a/docs/src/plugins/social/plugin.py +++ /dev/null @@ -1,516 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -# ----------------------------------------------------------------------------- -# Disclaimer -# ----------------------------------------------------------------------------- -# Please note: this version of the social plugin is not actively development -# anymore. Instead, Material for MkDocs Insiders ships a complete rewrite of -# the plugin which is much more powerful and addresses all shortcomings of -# this implementation. Additionally, the new social plugin allows to create -# entirely custom social cards. You can probably imagine, that this was a lot -# of work to pull off. If you run into problems, or want to have additional -# functionality, please consider sponsoring the project. You can then use the -# new version of the plugin immediately. -# ----------------------------------------------------------------------------- - -import concurrent.futures -import functools -import logging -import os -import posixpath -import re -import requests -import sys - -from collections import defaultdict -from hashlib import md5 -from io import BytesIO -from mkdocs.commands.build import DuplicateFilter -from mkdocs.exceptions import PluginError -from mkdocs.plugins import BasePlugin -from shutil import copyfile -from tempfile import TemporaryFile -from zipfile import ZipFile -try: - from cairosvg import svg2png - from PIL import Image, ImageDraw, ImageFont -except ImportError: - pass - -from .config import SocialConfig - - -# ----------------------------------------------------------------------------- -# Classes -# ----------------------------------------------------------------------------- - -# Social plugin -class SocialPlugin(BasePlugin[SocialConfig]): - - def __init__(self): - self._executor = concurrent.futures.ThreadPoolExecutor(4) - - # Retrieve configuration - def on_config(self, config): - self.color = colors.get("indigo") - self.config.cards = self.config.enabled - if not self.config.cards: - return - - # Check dependencies - if "Image" not in globals(): - raise PluginError( - "Required dependencies of \"social\" plugin not found. " - "Install with: pip install \"mkdocs-material[imaging]\"" - ) - - # Move color options - if self.config.cards_color: - - # Move background color to new option - value = self.config.cards_color.get("fill") - if value: - self.config.cards_layout_options["background_color"] = value - - # Move color to new option - value = self.config.cards_color.get("text") - if value: - self.config.cards_layout_options["color"] = value - - # Move font family to new option - if self.config.cards_font: - value = self.config.cards_font - self.config.cards_layout_options["font_family"] = value - - # Check if site URL is defined - if not config.site_url: - log.warning( - "The \"site_url\" option is not set. The cards are generated, " - "but not linked, so they won't be visible on social media." - ) - - # Ensure presence of cache directory - self.cache = self.config.cache_dir - if not os.path.isdir(self.cache): - os.makedirs(self.cache) - - # Retrieve palette from theme configuration - theme = config.theme - if "palette" in theme: - palette = theme["palette"] - - # Use first palette, if multiple are defined - if isinstance(palette, list): - palette = palette[0] - - # Set colors according to palette - if "primary" in palette and palette["primary"]: - primary = palette["primary"].replace(" ", "-") - self.color = colors.get(primary, self.color) - - # Retrieve color overrides - options = self.config.cards_layout_options - self.color = { - "fill": options.get("background_color", self.color["fill"]), - "text": options.get("color", self.color["text"]) - } - - # Retrieve logo and font - self._resized_logo_promise = self._executor.submit(self._load_resized_logo, config) - self.font = self._load_font(config) - - self._image_promises = [] - - # Create social cards - def on_page_markdown(self, markdown, page, config, files): - if not self.config.cards: - return - - # Resolve image directory - directory = self.config.cards_dir - file, _ = os.path.splitext(page.file.src_path) - - # Resolve path of image - path = "{}.png".format(os.path.join( - config.site_dir, - directory, - file - )) - - # Resolve path of image directory - directory = os.path.dirname(path) - if not os.path.isdir(directory): - os.makedirs(directory) - - # Compute site name - site_name = config.site_name - - # Compute page title and description - title = page.meta.get("title", page.title) - description = config.site_description or "" - if "description" in page.meta: - description = page.meta["description"] - - # Check type of meta title - see https://t.ly/m1Us - if not isinstance(title, str): - log.error( - f"Page meta title of page '{page.file.src_uri}' must be a " - f"string, but is of type \"{type(title)}\"." - ) - sys.exit(1) - - # Check type of meta description - see https://t.ly/m1Us - if not isinstance(description, str): - log.error( - f"Page meta description of '{page.file.src_uri}' must be a " - f"string, but is of type \"{type(description)}\"." - ) - sys.exit(1) - - # Generate social card if not in cache - hash = md5("".join([ - site_name, - str(title), - description - ]).encode("utf-8")) - file = os.path.join(self.cache, f"{hash.hexdigest()}.png") - self._image_promises.append(self._executor.submit( - self._cache_image, - cache_path = file, dest_path = path, - render_function = lambda: self._render_card(site_name, title, description) - )) - - # Inject meta tags into page - meta = page.meta.get("meta", []) - page.meta["meta"] = meta + self._generate_meta(page, config) - - def on_post_build(self, config): - if not self.config.cards: - return - - # Check for exceptions - for promise in self._image_promises: - promise.result() - - # ------------------------------------------------------------------------- - - # Render image to cache (if not present), then copy from cache to site - def _cache_image(self, cache_path, dest_path, render_function): - if not os.path.isfile(cache_path): - image = render_function() - image.save(cache_path) - - # Copy file from cache - copyfile(cache_path, dest_path) - - @functools.lru_cache(maxsize=None) - def _get_font(self, kind, size): - return ImageFont.truetype(self.font[kind], size) - - # Render social card - def _render_card(self, site_name, title, description): - # Render background and logo - image = self._render_card_background((1200, 630), self.color["fill"]) - image.alpha_composite( - self._resized_logo_promise.result(), - (1200 - 228, 64 - 4) - ) - - # Render site name - font = self._get_font("Bold", 36) - image.alpha_composite( - self._render_text((826, 48), font, site_name, 1, 20), - (64 + 4, 64) - ) - - # Render page title - font = self._get_font("Bold", 92) - image.alpha_composite( - self._render_text((826, 328), font, title, 3, 30), - (64, 160) - ) - - # Render page description - font = self._get_font("Regular", 28) - image.alpha_composite( - self._render_text((826, 80), font, description, 2, 14), - (64 + 4, 512) - ) - - # Return social card image - return image - - # Render social card background - def _render_card_background(self, size, fill): - return Image.new(mode = "RGBA", size = size, color = fill) - - @functools.lru_cache(maxsize=None) - def _tmp_context(self): - image = Image.new(mode = "RGBA", size = (50, 50)) - return ImageDraw.Draw(image) - - @functools.lru_cache(maxsize=None) - def _text_bounding_box(self, text, font): - return self._tmp_context().textbbox((0, 0), text, font = font) - - # Render social card text - def _render_text(self, size, font, text, lmax, spacing = 0): - width = size[0] - lines, words = [], [] - - # Remove remnant HTML tags - text = re.sub(r"(<[^>]+>)", "", text) - - # Retrieve y-offset of textbox to correct for spacing - yoffset = 0 - - # Create drawing context and split text into lines - for word in text.split(" "): - combine = " ".join(words + [word]) - textbox = self._text_bounding_box(combine, font = font) - yoffset = textbox[1] - if not words or textbox[2] <= width: - words.append(word) - else: - lines.append(words) - words = [word] - - # Join words for each line and create image - lines.append(words) - lines = [" ".join(line) for line in lines] - image = Image.new(mode = "RGBA", size = size) - - # Create drawing context and split text into lines - context = ImageDraw.Draw(image) - context.text( - (0, spacing / 2 - yoffset), "\n".join(lines[:lmax]), - font = font, fill = self.color["text"], spacing = spacing - yoffset - ) - - # Return text image - return image - - # ------------------------------------------------------------------------- - - # Generate meta tags - def _generate_meta(self, page, config): - directory = self.config.cards_dir - file, _ = os.path.splitext(page.file.src_uri) - - # Compute page title - title = page.meta.get("title", page.title) - if not page.is_homepage: - title = f"{title} - {config.site_name}" - - # Compute page description - description = config.site_description - if "description" in page.meta: - description = page.meta["description"] - - # Resolve image URL - url = "{}.png".format(posixpath.join( - config.site_url or ".", - directory, - file - )) - - # Ensure forward slashes - url = url.replace(os.path.sep, "/") - - # Return meta tags - return [ - - # Meta tags for Open Graph - { "property": "og:type", "content": "website" }, - { "property": "og:title", "content": title }, - { "property": "og:description", "content": description }, - { "property": "og:image", "content": url }, - { "property": "og:image:type", "content": "image/png" }, - { "property": "og:image:width", "content": "1200" }, - { "property": "og:image:height", "content": "630" }, - { "property": "og:url", "content": page.canonical_url }, - - # Meta tags for Twitter - { "name": "twitter:card", "content": "summary_large_image" }, - # { "name": "twitter:site", "content": user }, - # { "name": "twitter:creator", "content": user }, - { "name": "twitter:title", "content": title }, - { "name": "twitter:description", "content": description }, - { "name": "twitter:image", "content": url } - ] - - def _load_resized_logo(self, config, width = 144): - logo = self._load_logo(config) - height = int(width * logo.height / logo.width) - return logo.resize((width, height)) - - # Retrieve logo image or icon - def _load_logo(self, config): - theme = config.theme - - # Handle images (precedence over icons) - if "logo" in theme: - _, extension = os.path.splitext(theme["logo"]) - - path = os.path.join(config.docs_dir, theme["logo"]) - - # Allow users to put the logo inside their custom_dir (theme["logo"] case) - if theme.custom_dir: - custom_dir_logo = os.path.join(theme.custom_dir, theme["logo"]) - if os.path.exists(custom_dir_logo): - path = custom_dir_logo - - # Load SVG and convert to PNG - if extension == ".svg": - return self._load_logo_svg(path) - - # Load PNG, JPEG, etc. - return Image.open(path).convert("RGBA") - - # Handle icons - icon = theme["icon"] or {} - if "logo" in icon and icon["logo"]: - logo = icon["logo"] - else: - logo = "material/library" - - # Resolve path of package - base = os.path.abspath(os.path.join( - os.path.dirname(__file__), - "../.." - )) - - path = f"{base}/templates/.icons/{logo}.svg" - - # Allow users to put the logo inside their custom_dir (theme["icon"]["logo"] case) - if theme.custom_dir: - custom_dir_logo = os.path.join(theme.custom_dir, ".icons", f"{logo}.svg") - if os.path.exists(custom_dir_logo): - path = custom_dir_logo - - # Load icon data and fill with color - return self._load_logo_svg(path, self.color["text"]) - - # Load SVG file and convert to PNG - def _load_logo_svg(self, path, fill = None): - file = BytesIO() - data = open(path).read() - - # Fill with color, if given - if fill: - data = data.replace("<svg", f"<svg fill=\"{fill}\"") - - # Convert to PNG and return image - svg2png(bytestring = data, write_to = file, scale = 10) - return Image.open(file) - - # Retrieve font - def _load_font(self, config): - name = self.config.cards_layout_options.get("font_family") - if not name: - - # Retrieve from theme (default: Roboto) - theme = config.theme - if isinstance(theme["font"], dict) and "text" in theme["font"]: - name = theme["font"]["text"] - else: - name = "Roboto" - - # Google fonts can return varients like OpenSans_Condensed-Regular.ttf so - # we only use the font requested e.g. OpenSans-Regular.ttf - font_filename_base = name.replace(' ', '') - filename_regex = re.escape(font_filename_base)+r"-(\w+)\.[ot]tf$" - - font = {} - # Check for cached files - note these may be in subfolders - for currentpath, folders, files in os.walk(self.cache): - for file in files: - # Map available font weights to file paths - fname = os.path.join(currentpath, file) - match = re.search(filename_regex, fname) - if match: - font[match.group(1)] = fname - - # If none found, fetch from Google and try again - if len(font) == 0: - self._load_font_from_google(name) - for currentpath, folders, files in os.walk(self.cache): - for file in files: - # Map available font weights to file paths - fname = os.path.join(currentpath, file) - match = re.search(filename_regex, fname) - if match: - font[match.group(1)] = fname - - # Return available font weights with fallback - return defaultdict(lambda: font["Regular"], font) - - # Retrieve font from Google Fonts - def _load_font_from_google(self, name): - url = "https://fonts.google.com/download?family={}" - res = requests.get(url.format(name.replace(" ", "+")), stream = True) - - # Write archive to temporary file - tmp = TemporaryFile() - for chunk in res.iter_content(chunk_size = 32768): - tmp.write(chunk) - - # Unzip fonts from temporary file - zip = ZipFile(tmp) - files = [file for file in zip.namelist() if file.endswith(".ttf") or file.endswith(".otf")] - zip.extractall(self.cache, files) - - # Close and delete temporary file - tmp.close() - return files - -# ----------------------------------------------------------------------------- -# Data -# ----------------------------------------------------------------------------- - -# Set up logging -log = logging.getLogger("mkdocs") -log.addFilter(DuplicateFilter()) - -# Color palette -colors = dict({ - "red": { "fill": "#ef5552", "text": "#ffffff" }, - "pink": { "fill": "#e92063", "text": "#ffffff" }, - "purple": { "fill": "#ab47bd", "text": "#ffffff" }, - "deep-purple": { "fill": "#7e56c2", "text": "#ffffff" }, - "indigo": { "fill": "#4051b5", "text": "#ffffff" }, - "blue": { "fill": "#2094f3", "text": "#ffffff" }, - "light-blue": { "fill": "#02a6f2", "text": "#ffffff" }, - "cyan": { "fill": "#00bdd6", "text": "#ffffff" }, - "teal": { "fill": "#009485", "text": "#ffffff" }, - "green": { "fill": "#4cae4f", "text": "#ffffff" }, - "light-green": { "fill": "#8bc34b", "text": "#ffffff" }, - "lime": { "fill": "#cbdc38", "text": "#000000" }, - "yellow": { "fill": "#ffec3d", "text": "#000000" }, - "amber": { "fill": "#ffc105", "text": "#000000" }, - "orange": { "fill": "#ffa724", "text": "#000000" }, - "deep-orange": { "fill": "#ff6e42", "text": "#ffffff" }, - "brown": { "fill": "#795649", "text": "#ffffff" }, - "grey": { "fill": "#757575", "text": "#ffffff" }, - "blue-grey": { "fill": "#546d78", "text": "#ffffff" }, - "black": { "fill": "#000000", "text": "#ffffff" }, - "white": { "fill": "#ffffff", "text": "#000000" } -}) diff --git a/docs/src/plugins/tags/__init__.py b/docs/src/plugins/tags/__init__.py deleted file mode 100644 index 19994c95..00000000 --- a/docs/src/plugins/tags/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -# ----------------------------------------------------------------------------- -# Functions -# ----------------------------------------------------------------------------- - -# Casefold a string for comparison when sorting -def casefold(tag: str): - return tag.casefold() diff --git a/docs/src/plugins/tags/config.py b/docs/src/plugins/tags/config.py deleted file mode 100644 index f2d95084..00000000 --- a/docs/src/plugins/tags/config.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -from 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/docs/src/plugins/tags/plugin.py b/docs/src/plugins/tags/plugin.py deleted file mode 100644 index e5ce6bde..00000000 --- a/docs/src/plugins/tags/plugin.py +++ /dev/null @@ -1,182 +0,0 @@ -# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -# IN THE SOFTWARE. - -import logging -import 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"## <span class=\"{classes}\">{tag}</span>", ""] - for page in pages: - url = utils.get_relative_url( - page.file.src_uri, - self.tags_file.src_uri - ) - - # Render link to page - title = page.meta.get("title", page.title) - content.append(f"- [{title}]({url})") - - # Return rendered tag links - return "\n".join(content) - - # Render the given tag, linking to the tags index (if enabled) - def _render_tag(self, tag): - type = self.tags_map.get(tag) if self.tags_map else None - if not self.tags_file or not self.slugify: - return dict(name = tag, type = type) - else: - url = f"{self.tags_file.url}#{self.slugify(tag)}" - return dict(name = tag, type = type, url = url) - -# ----------------------------------------------------------------------------- -# Data -# ----------------------------------------------------------------------------- - -# Set up logging -log = logging.getLogger("mkdocs.material.tags") |
