aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/material/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'material/plugins')
-rw-r--r--material/plugins/__init__.py19
-rw-r--r--material/plugins/blog/__init__.py19
-rw-r--r--material/plugins/blog/author.py38
-rw-r--r--material/plugins/blog/config.py88
-rw-r--r--material/plugins/blog/plugin.py884
-rw-r--r--material/plugins/blog/readtime/__init__.py51
-rw-r--r--material/plugins/blog/readtime/parser.py45
-rw-r--r--material/plugins/blog/structure/__init__.py292
-rw-r--r--material/plugins/blog/structure/config.py37
-rw-r--r--material/plugins/blog/structure/markdown.py58
-rw-r--r--material/plugins/blog/structure/options.py87
-rw-r--r--material/plugins/blog/templates/__init__.py42
-rw-r--r--material/plugins/group/__init__.py19
-rw-r--r--material/plugins/group/config.py33
-rw-r--r--material/plugins/group/plugin.py151
-rw-r--r--material/plugins/info/__init__.py19
-rw-r--r--material/plugins/info/config.py35
-rw-r--r--material/plugins/info/plugin.py245
-rw-r--r--material/plugins/offline/__init__.py19
-rw-r--r--material/plugins/offline/config.py30
-rw-r--r--material/plugins/offline/plugin.py69
-rw-r--r--material/plugins/search/__init__.py19
-rw-r--r--material/plugins/search/config.py58
-rw-r--r--material/plugins/search/plugin.py580
-rw-r--r--material/plugins/social/__init__.py19
-rw-r--r--material/plugins/social/config.py48
-rw-r--r--material/plugins/social/plugin.py516
-rw-r--r--material/plugins/tags/__init__.py27
-rw-r--r--material/plugins/tags/config.py38
-rw-r--r--material/plugins/tags/plugin.py182
30 files changed, 3767 insertions, 0 deletions
diff --git a/material/plugins/__init__.py b/material/plugins/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/material/plugins/__init__.py
@@ -0,0 +1,19 @@
+# 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/material/plugins/blog/__init__.py b/material/plugins/blog/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/material/plugins/blog/__init__.py
@@ -0,0 +1,19 @@
+# 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/material/plugins/blog/author.py b/material/plugins/blog/author.py
new file mode 100644
index 00000000..1dcfc2de
--- /dev/null
+++ b/material/plugins/blog/author.py
@@ -0,0 +1,38 @@
+# 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/material/plugins/blog/config.py b/material/plugins/blog/config.py
new file mode 100644
index 00000000..c7a85095
--- /dev/null
+++ b/material/plugins/blog/config.py
@@ -0,0 +1,88 @@
+# 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/material/plugins/blog/plugin.py b/material/plugins/blog/plugin.py
new file mode 100644
index 00000000..375b8cfe
--- /dev/null
+++ b/material/plugins/blog/plugin.py
@@ -0,0 +1,884 @@
+# 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/material/plugins/blog/readtime/__init__.py b/material/plugins/blog/readtime/__init__.py
new file mode 100644
index 00000000..a0c149b9
--- /dev/null
+++ b/material/plugins/blog/readtime/__init__.py
@@ -0,0 +1,51 @@
+# 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/material/plugins/blog/readtime/parser.py b/material/plugins/blog/readtime/parser.py
new file mode 100644
index 00000000..b91a7b30
--- /dev/null
+++ b/material/plugins/blog/readtime/parser.py
@@ -0,0 +1,45 @@
+# 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/material/plugins/blog/structure/__init__.py b/material/plugins/blog/structure/__init__.py
new file mode 100644
index 00000000..2fc541fe
--- /dev/null
+++ b/material/plugins/blog/structure/__init__.py
@@ -0,0 +1,292 @@
+# 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/material/plugins/blog/structure/config.py b/material/plugins/blog/structure/config.py
new file mode 100644
index 00000000..129491b9
--- /dev/null
+++ b/material/plugins/blog/structure/config.py
@@ -0,0 +1,37 @@
+# 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/material/plugins/blog/structure/markdown.py b/material/plugins/blog/structure/markdown.py
new file mode 100644
index 00000000..64ade554
--- /dev/null
+++ b/material/plugins/blog/structure/markdown.py
@@ -0,0 +1,58 @@
+# 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/material/plugins/blog/structure/options.py b/material/plugins/blog/structure/options.py
new file mode 100644
index 00000000..281dec9f
--- /dev/null
+++ b/material/plugins/blog/structure/options.py
@@ -0,0 +1,87 @@
+# 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/material/plugins/blog/templates/__init__.py b/material/plugins/blog/templates/__init__.py
new file mode 100644
index 00000000..9f7d794b
--- /dev/null
+++ b/material/plugins/blog/templates/__init__.py
@@ -0,0 +1,42 @@
+# 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/material/plugins/group/__init__.py b/material/plugins/group/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/material/plugins/group/__init__.py
@@ -0,0 +1,19 @@
+# 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/material/plugins/group/config.py b/material/plugins/group/config.py
new file mode 100644
index 00000000..fb19222a
--- /dev/null
+++ b/material/plugins/group/config.py
@@ -0,0 +1,33 @@
+# 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/material/plugins/group/plugin.py b/material/plugins/group/plugin.py
new file mode 100644
index 00000000..4ab13dbf
--- /dev/null
+++ b/material/plugins/group/plugin.py
@@ -0,0 +1,151 @@
+# 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/material/plugins/info/__init__.py b/material/plugins/info/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/material/plugins/info/__init__.py
@@ -0,0 +1,19 @@
+# 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/material/plugins/info/config.py b/material/plugins/info/config.py
new file mode 100644
index 00000000..cbd64d4c
--- /dev/null
+++ b/material/plugins/info/config.py
@@ -0,0 +1,35 @@
+# 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/material/plugins/info/plugin.py b/material/plugins/info/plugin.py
new file mode 100644
index 00000000..7c6fdc17
--- /dev/null
+++ b/material/plugins/info/plugin.py
@@ -0,0 +1,245 @@
+# 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/material/plugins/offline/__init__.py b/material/plugins/offline/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/material/plugins/offline/__init__.py
@@ -0,0 +1,19 @@
+# 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/material/plugins/offline/config.py b/material/plugins/offline/config.py
new file mode 100644
index 00000000..49f51a94
--- /dev/null
+++ b/material/plugins/offline/config.py
@@ -0,0 +1,30 @@
+# 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/material/plugins/offline/plugin.py b/material/plugins/offline/plugin.py
new file mode 100644
index 00000000..abcb2598
--- /dev/null
+++ b/material/plugins/offline/plugin.py
@@ -0,0 +1,69 @@
+# 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/material/plugins/search/__init__.py b/material/plugins/search/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/material/plugins/search/__init__.py
@@ -0,0 +1,19 @@
+# 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/material/plugins/search/config.py b/material/plugins/search/config.py
new file mode 100644
index 00000000..e150fbb3
--- /dev/null
+++ b/material/plugins/search/config.py
@@ -0,0 +1,58 @@
+# 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/material/plugins/search/plugin.py b/material/plugins/search/plugin.py
new file mode 100644
index 00000000..5c254e3f
--- /dev/null
+++ b/material/plugins/search/plugin.py
@@ -0,0 +1,580 @@
+# 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/material/plugins/social/__init__.py b/material/plugins/social/__init__.py
new file mode 100644
index 00000000..d1899378
--- /dev/null
+++ b/material/plugins/social/__init__.py
@@ -0,0 +1,19 @@
+# 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/material/plugins/social/config.py b/material/plugins/social/config.py
new file mode 100644
index 00000000..2d87c25e
--- /dev/null
+++ b/material/plugins/social/config.py
@@ -0,0 +1,48 @@
+# 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/material/plugins/social/plugin.py b/material/plugins/social/plugin.py
new file mode 100644
index 00000000..3cdfa3ce
--- /dev/null
+++ b/material/plugins/social/plugin.py
@@ -0,0 +1,516 @@
+# 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/material/plugins/tags/__init__.py b/material/plugins/tags/__init__.py
new file mode 100644
index 00000000..19994c95
--- /dev/null
+++ b/material/plugins/tags/__init__.py
@@ -0,0 +1,27 @@
+# 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/material/plugins/tags/config.py b/material/plugins/tags/config.py
new file mode 100644
index 00000000..f2d95084
--- /dev/null
+++ b/material/plugins/tags/config.py
@@ -0,0 +1,38 @@
+# 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/material/plugins/tags/plugin.py b/material/plugins/tags/plugin.py
new file mode 100644
index 00000000..e5ce6bde
--- /dev/null
+++ b/material/plugins/tags/plugin.py
@@ -0,0 +1,182 @@
+# 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")